So far, we've seen how to stage and commit files to create linear commit graphs that look something like this.
Of course, the purpose of Git isn't just to make commits! 😆 We want the ability to
- experiment with drastic changes to the codebase
- experiment with divergent changes in parallel
- restore our work to previous versions (commits) when necessary
This means our commit graph should have the ability to branch
and merge.
Commit IDs¶
Every commit has a unique ID. You can see them when you run git log
(although the full IDs may be truncated for the display).
bill@gates:~$ git log
commit {==8436ff07b9752ddb387f913f47e4a15052413e0d==} (HEAD -> main)
Author: bill <[email protected]>
Date: Fri Sep 16 19:44:08 2022 -0500
inserted some text in s1
commit {==562322d16ecde3e8109e7fb57fb8fbf81bb08b43==}
Author: bill <[email protected]>
Date: Fri Sep 16 19:43:37 2022 -0500
added s2
commit {==c59ba58182b4b4009f3ed5ef7ee1df04b65ed083==}
Author: bill <[email protected]>
Date: Fri Sep 16 19:43:27 2022 -0500
added s1
Each ID is a 40-character SHA-1 hash of the commit content, author, message, parent commit ID, and other things.
Tip
Suppose Bob and Jane each look at the most recent commit ID on their computers and they are the same..
Bob@Smith:~$ git log --format="%H" -n 1
562322d16ecde3e8109e7fb57fb8fbf81bb08b43
Jane@Doe:~$ git log --format="%H" -n 1
562322d16ecde3e8109e7fb57fb8fbf81bb08b43
Because they are the same, Bob and Jane can be confident that they both have access to a copy of the same exact commit and the same exact history leading up to that commit.
Branching¶
By default, every git repo starts with one branch - main. If you have a project with at least one commit, you can make a new branch using git branch <branchname>
.
bill@gates:~$ git branch feature1
What exactly is a branch?¶
Internally, a branch is just a pointer (i.e. a reference) to a particular commit ID.
Consider the following commit graph.
It has three branches.
- feature1: points to commit ID
de3e
- feature2: points to commit ID
2d16
- main: points to commit ID
8b43
Although each branch is stored as a pointer to a commit, most people interpret a branch as the path of reachable commits from the branch tip.
For example, you can interpret the feature1 branch as this path of commits.
And you can interpret the feature2 branch as this path of commits.
See for yourself
Which branch am I on?¶
git branch
shows a list of available branches.
bill@gates:~$ git branch
feature1
feature2
* main
The one with the asterisk is
- the "current branch"
- AKA "the branch you're on"
- AKA "the branch you've checked out".
You can also see the current branch by running git status
.
bill@gates:~$ git status
On branch main
nothing to commit, working tree clean
How do I change the current branch?¶
To change the current branch, use git switch <branchname>
.
bill@gates:~$ git switch feature1
Switched to branch 'feature1'
What happens when I make a new commit?
- The new commit maps to the previous commit (its parent).
- The branch (pointer) you were on when you made the commit updates to point at the new commit.
What's HEAD?¶
By now, you've probably noticed something named HEAD. HEAD is a special pointer (i.e. a reference) to a commit. It comes in two flavors:
-
Non-Detached HEAD:
HEAD references a branch. But since a branch references a commit, this means that HEAD references a commit indirectly. -
Detached HEAD:
HEAD references a commit directly.(Even though HEAD and main point to the same commit, HEAD is still considered "detached" because it points at a commit instead of a branch.)
What's the purpose of HEAD?¶
Git needs to compare your working tree against something to determine if you've modified files, added files, deleted files, etc. That something is the commit pointed to by HEAD.
See for yourself
cd path/to/parent/dir/
mkdir roux && cd roux
git init
touch foo.txt
git add foo.txt
git commit -m "added foo"
touch bar.txt
git add bar.txt
git commit -m "added bar"
git log
shows that HEAD points at main.
bill@gates:~$ git log --oneline
df98e21 (HEAD -> main) added bar
4fc1b3d added foo
We can inspect the contents of .git/HEAD
to confirm that HEAD points at main.
bill@gates:~$ cat .git/HEAD
ref: refs/heads/main
We can tell HEAD to point at a particular commit via git checkout <commit_id>
.
bill@gates:~$ git checkout 4fc1b3d
Note: switching to '4fc1b3d'.
You are in 'detached HEAD' state. You can look around, make experimental
changes and commit them, and you can discard any commits you make in this
state without impacting any branches by switching back to a branch.
If you want to create a new branch to retain commits you create, you may
do so (now or later) by using -c with the switch command. Example:
git switch -c <new-branch-name>
Or undo this operation with:
git switch -
Turn off this advice by setting config variable advice.detachedHead to false
HEAD is now at 4fc1b3d added foo
Now when we inspect the contents of .git/HEAD
, it shows a commit ID.
bill@gates:~$ cat .git/HEAD
4fc1b3d5463b12c03f24004a4ed16c3c9230408a
How do I get out of a detached HEAD state?¶
Use
git switch -
to switch to the latest checked out branch, or
git switch <branchname>
to switch to a particular branch.
Relative commits¶
We've seen how to reference commits using their SHA1 hash IDs, but sometimes you'll want to reference them relatively.
You can achieve this with ~
and ^
.
In the definitions below, Z
represents a commit. However, you can replace Z
with HEAD
. If HEAD
points to Z
, then HEAD~1
and Z~1
mean the same thing.
Definitions
Z~N
: Traverse the commit graph N steps up from Z
. If a commit has multiple parents, step towards the first parent.
Z^N
: Return Z
's Nth parent.
Z^M^N
: Return Z
's Mth parent. Then return that commit's Nth parent.
When a commit has multiple parents, how are they ordered?
When merging commit Y
into X
to create Z
, X
becomes Z
's first parent.
git switch X
git merge Y
Z <-- latest commit
/ \
/ \
X Y <-- earlier commits
git switch Y
git merge X
Z <-- latest commit
/ \
/ \
Y X <-- earlier commits
Examples
D E <-- HEAD
| {==/==} \
|{==/==} \
{==B==} C
\ /
\/
A <-- first commit
HEAD~1 = E~1 = B
D E <-- HEAD
| {==/==} \
|{==/==} \
{==B==} C
{==\==} /
{==\==}/
{==A==} <-- first commit
HEAD~2 = E~2 = A
D E <-- HEAD
| {==/==} \
|{==/==} \
{==B==} C
\ /
\/
A <-- first commit
HEAD^1 = E^1 = B
D E <-- HEAD
| / {==\==}
|/ {==\==}
B {==C==}
\ /
\/
A <-- first commit
HEAD^2 = E^2 = C
D E <-- HEAD
| / {==\==}
|/ {==\==}
B {==C==}
\ {==/==}
\{==/==}
{==A==} <-- first commit
HEAD^2^1 = E^2^1 = A
Merging¶
After splitting a commit graph into branches, you'll eventually want to merge them back together. You can merge two branches using git merge
.
There are two patterns for merging in Git: fast forward and three way.
Fast Forward merge¶
Suppose you have a commit graph like this, and you want to merge branches A and B.
Merge B into A
In order to merge branch B into branch A, do
git switch A
git merge B
In this case, A's pointer updates to point at the commit pointed to by B. (The working tree will update as well.)
Merge A into B
In order to merge branch A into branch B, do
git switch B
git merge A
In this case, A already exists in B's history, so Git doesn't do anything.
Some tools will tell you "B is ahead of A by one commit"
Three Way merge¶
Suppose you have a commit graph like this, and you want to merge branches A and B.
In this case, Git creates a new commit to represent the merge.
Merge B into A
git switch A
git merge B
Merge A into B
git switch B
git merge A
In a three way merge, it's possible that A and B conflict with each other. (Perhaps A modified a file that B deleted.) When a merge has conflicts, you'll need to resolve them. We'll show examples of this in the problem set.
git reset
¶
The git reset
command can be used to reset the index (staging area) and working tree back to a previous state. You can also use it to move a branch pointer.
For example, suppose you set up a repository like this
cd path/to/parent/dir/
mkdir cookie && cd cookie && git init
echo "me want cookie" > f1
git add .
git commit -m "added f1"
echo "me love cookies" >> f1
echo "chocolate chip" > f2
git add .
git commit -m "modified f1 and added f2"
echo "oatmeal raisin" >> f2
echo "flour" >> f3
Here's what happens when you call git reset
under various scenarios.
Stage all files¶
In this scenario, we stage all modified and untracked files. (See the initial code here.)
bill@gates:~$ git add .
Click on the tabs below to see what git reset
does in each case 👇
-
Staged files become unstaged.
-
No changes to the commit graph.
-
The contents of
f1
,f2
, andf3
are preserved in the working tree .
-
Staged files become unstaged.
-
HEAD and main move to commit
4d7bd61
. Since there are no remaining references to commite3ccf9c
, it's garbage collected :octicons-trash-24:. -
The contents of
f1
,f2
, andf3
are preserved in the working tree .
Notegit reset <commit ID>
is equivalent to git reset --mixed <commit ID>
-
Diffs from commit
4d7bd61
to commite3ccf9c
are put in the staging area (unless they conflict with the items in staging) . -
HEAD and main move to commit
4d7bd61
. Since there are no remaining references to commite3ccf9c
, it's garbage collected :octicons-trash-24:. -
The contents of
f1
,f2
, andf3
are preserved in the working tree .
-
Staged files become unstaged.
-
HEAD and main move to commit
4d7bd61
. Since there are no remaining references to commite3ccf9c
, it's garbage collected :octicons-trash-24:. -
The contents of
f1
,f2
, andf3
are preserved in the working tree .
Notegit reset --mixed <commit ID>
is equivalent to git reset <commit ID>
-
Staged files become unstaged.
-
HEAD and main move to commit
4d7bd61
. Since there are no remaining references to commite3ccf9c
, it's garbage collected :octicons-trash-24:. -
f1
,f2
, andf3
revert to their states as of commite3ccf9c
. Sincef2
andf3
didn't exist in commite3ccf9c
, they are removed. .
Stage all files, then make changes¶
In this scenario, we stage all modified and untracked files. Then we append a line to f2
and modify the only line
in f3
. (See the initial code here.)
bill@gates:~$ git add .
bill@gates:~$ echo "m&m" >> f2
bill@gates:~$ echo "butter" > f3
Click on the tabs below to see what git reset
does in each case 👇
-
Staged files become unstaged.
-
No changes to the commit graph.
-
The contents of
f1
,f2
, andf3
are preserved in the working tree .
-
Staged files become unstaged.
-
HEAD and main move to commit
4d7bd61
. Since there are no remaining references to commite3ccf9c
, it's garbage collected :octicons-trash-24:. -
The contents of
f1
,f2
, andf3
are preserved in the working tree .
Notegit reset <commit ID>
is equivalent to git reset --mixed <commit ID>
-
Diffs from commit
4d7bd61
to commite3ccf9c
are put in the staging area (unless they conflict with the items in staging) . -
HEAD and main move to commit
4d7bd61
. Since there are no remaining references to commite3ccf9c
, it's garbage collected :octicons-trash-24:. -
The contents of
f1
,f2
, andf3
are preserved in the working tree .
-
Staged files become unstaged.
-
HEAD and main move to commit
4d7bd61
. Since there are no remaining references to commite3ccf9c
, it's garbage collected :octicons-trash-24:. -
The contents of
f1
,f2
, andf3
are preserved in the working tree .
Notegit reset --mixed <commit ID>
is equivalent to git reset <commit ID>
-
Staged files become unstaged.
-
HEAD and main move to commit
4d7bd61
. Since there are no remaining references to commite3ccf9c
, it's garbage collected :octicons-trash-24:. -
f1
,f2
, andf3
revert to their states as of commite3ccf9c
. Sincef2
andf3
didn't exist in commite3ccf9c
, they are removed. .