G1 · Part 1 · Foundations
The mental model
Snapshots, a graph of commits, and branches that are only pointers. Everything else follows from these three.
08On this page
Git is a distributed version control system. "Distributed" means every clone is a complete repository with the entire history, not a thin checkout that phones home. You can commit, branch, merge, inspect ten years of history and dig through old versions with your Wi-Fi off. That single design choice is why Git feels different from SVN or Perforce, where the server holds the history and your machine holds a working copy.
It also changes what a "server" is. GitHub and GitLab hold a repository that is no more special than yours. It is the copy the team agrees to treat as the shared one. Push and fetch are just two repositories exchanging objects they are missing.
Three ideas carry almost all the weight:
1. A commit is a snapshot, not a diff
People often picture Git storing "the changes you made." It
doesn't. Each commit records the
complete state of your project at that moment. The
diffs you see in git log -p or a pull request are
calculated on the fly by comparing two snapshots. Git avoids
wasting space by storing files by content: if a file didn't
change between commits, both commits point at the same stored
copy.
Snapshots make the common operations cheap. Checking out an old commit means reading one snapshot, not replaying a thousand patches. Comparing any two commits, even ones years apart, is a direct comparison. Git does compress similar files against each other on disk, inside packfiles, but that is a storage trick underneath the model, not the model itself.
2. History is a directed acyclic graph
Every commit stores a pointer to its parent (or two parents, for a merge). Follow those pointers backwards and you walk the whole history. Nothing points forward, and nothing loops, hence "acyclic." When you hear people say "the graph," this is it. Merging joins two lines; branching splits one.
main
points.
Two details follow from the way commits are stored. First, a
commit's ID is a hash of its contents, and its contents include
its parent's ID. So a commit can never change. If you "edit" one
with --amend or a rebase, Git writes a brand new
commit with a new ID, and the old one is still sitting in the
database. Second, the first commit in a repository has no parent
(a root commit), a normal commit has one, and a merge
commit has two or more. A merge with three or more parents is
called an octopus merge. It is legal but rare.
3. A branch is a movable pointer
This is the one that unlocks everything. A branch is not a
folder, not a copy of your code, not a container of commits. It
is a 41-byte file: a 40-character commit ID and a newline.
main is a sticky note stuck to a commit. When you
commit, Git moves the sticky note forward one node. Creating a
branch is instant because it writes one small file.
proof: a branch really is one line of text
$ cat .git/refs/heads/main
a3f9c1d8e2b47f0c9a1e6d3b5f8c2a7e4d0b9f16
$ cat .git/HEAD
ref: refs/heads/main
$ git rev-parse main
a3f9c1d8e2b47f0c9a1e6d3b5f8c2a7e4d0b9f16
The first file is the branch. The second is HEAD,
which says which branch you are on. If cat fails
with "No such file", the branch has been moved into
.git/packed-refs by housekeeping. The pointer is the
same, just stored in a shared file. git rev-parse
reads either form, which is why scripts should use it.
HEAD did not change at all.
It still says "I am on main".
HEAD: where you are
HEAD is usually a symbolic reference: it
names a branch, and the branch names a commit. That indirection
is what makes git commit move the right branch.
When HEAD holds a commit ID directly, instead of a
branch name, you are in detached HEAD. You can
still commit, but no branch moves to follow you, so those commits
are easy to lose when you switch away.
Branches, HEAD, and detached HEAD
covers the recovery.
Reasoning with the graph: ancestry
Almost every question you ask Git is really a question about reachability. Commit X is an ancestor of Y if you can reach X by walking parent arrows back from Y. A branch "contains" a commit when the commit is an ancestor of the branch tip (or is the tip itself). Using the graph above:
| Question | Answer from the graph | Command that asks it |
|---|---|---|
| Is my fix in main yet? | Yes if the fix commit is an ancestor of main's tip. | git branch --contains <sha> |
| Can main fast-forward to feature? | Only if main's tip is an ancestor of feature's tip. | git merge-base --is-ancestor main feature |
| Where did these branches split? | The newest common ancestor: B. | git merge-base main feature |
| How far ahead and behind am I? | Count commits reachable from one side only. | git rev-list --left-right --count main...feature |
| Which release first shipped this? | A tag that contains it, printed like v1.2~3: three commits before v1.2. | git describe --contains <sha> |
Every question in that table is a graph walk, and on a large
repository a naive walk opens hundreds of thousands of commit
objects. Git avoids most of that with the
commit-graph file: a compact index of each
commit's parents, root tree and generation number (its
distance from the roots). If X has a higher generation number
than Y, X cannot be an ancestor of Y, so whole regions of the
graph are skipped without being read. git maintenance
keeps the file current, and git branch --contains,
merge-base and log --graph all speed up
because of it. When a script needs ahead and behind counts for
many branches at once, git for-each-ref
--format='%(refname:short) %(ahead-behind:main)' (Git 2.41
and later) does it in one walk instead of one
rev-list per branch.
The merge base matters more than it looks. A three-way merge compares both tips against the merge base, not against each other. That is how Git can tell "you changed this line and they didn't" from "you both changed this line", which is the only case it calls a conflict.
Naming commits without copying hashes
Anywhere Git wants a commit, it accepts a revision expression. These are worth knowing by heart:
| You type | It means |
|---|---|
HEAD, @ | The commit you have checked out. |
HEAD~3 | Three steps back, always following the first parent. |
M^2 | The second parent of a merge. M^ alone is M^1, the first parent. |
main@{2} | Where main pointed two moves ago, from your reflog. |
main@{yesterday} | Where main pointed yesterday, on this machine. |
@{u} | The upstream of the current branch, such as origin/main. |
@{-1} | The branch you were on before this one. git switch - uses it. |
v1.0^{} | Peel the tag v1.0 down to the commit it marks. |
:/cart total | The newest commit whose message matches "cart total". |
HEAD:src/cart.js | Not a commit: the file src/cart.js as it is in HEAD. |
~ and ^ only differ at merges.
M~1 and M^1 are both C in the graph
above; M^2 is E, a commit M~ can never
reach. The first parent of a merge is the branch you were on when
you merged, so git log --first-parent main reads main
as a clean list of "what landed", one entry per merged pull
request.
Range notation: A..B and A...B
Git's range syntax is set arithmetic on reachability, and it trips up even experienced users. Here is the graph it is easiest to reason with: main and feature split at B, main gained C, and feature gained D and E.
the same graph, in a real repository
$ git log --oneline --graph --all
* 4fc8819 E
* 77889d9 D
| * d517f81 C
|/
* 3db0ce5 B
* 944e723 A
$ git log --oneline main..feature
4fc8819 E
77889d9 D
$ git log --oneline --left-right main...feature
< d517f81 C
> 4fc8819 E
> 77889d9 D
$ git merge-base main feature
3db0ce5e7218e32789953cc5af7762c8e52d1093
$ git rev-list --left-right --count main...feature
1 2
--left-right marks which side each commit came from,
and the last line reads "main has 1 commit feature lacks, feature
has 2 that main lacks". That pair is exactly what
git status prints as "ahead 2, behind 1".
main..feature is shorthand for
feature ^main, and the caret form scales:
git log release ^main ^hotfix shows what is only on
release.
| Expression | With git log | With git diff |
|---|---|---|
A..B | Commits in B that are not in A. "What would merging B bring in?" | Same as git diff A B: the two tips compared directly. |
A...B | Commits in either, not both. | Changes on B since it split from A: git diff $(git merge-base A B) B. This is what a pull request shows. |
origin/main.. | An empty side means HEAD: your unpushed commits. | Your changes against the remote tip. |
..origin/main | What you would get by pulling. | The reverse comparison. |
git diff main..feature does not mean "the
commits in the range". Diff only compares two snapshots, so two
dots is just the two tips, and it will show main's newer work
as if feature had deleted it. When you want "what did this
branch change", use three dots: git diff main...feature,
or the more explicit git diff --merge-base main feature
(Git 2.30 and later). git diff --merge-base main
with one name compares your working tree against the point
where your branch left main, uncommitted edits included.
Reachability is also garbage collection
Git keeps everything that can be reached from a reference: a
branch, a tag, a remote-tracking branch, the stash, or an entry
in a reflog. Anything else is unreachable. It is not deleted
straight away. By default git gc keeps unreachable
objects for two weeks, and reflog entries keep old branch
positions alive for 30 to 90 days. That window is why a "lost"
commit after a bad reset is almost always recoverable with
git reflog, and why a secret you pushed cannot be
fixed by deleting a branch.
Once branches are pointers, the scary commands stop being
scary. git reset moves a pointer.
git rebase writes new commits and moves a
pointer. git merge creates a commit with two
parents and moves a pointer. You stop memorising and start
reasoning: draw the graph, ask where each pointer is, and the
command you need is usually obvious.
Finished reading?
0 of 18 chapters read in this series