GitGroundworkGit
1 / 18 written chapters

G1 · Part 1 · Foundations

The mental model

Snapshots, a graph of commits, and branches that are only pointers. Everything else follows from these three.

10 min read4 diagrams8 sections
08On this page
  1. 1. A commit is a snapshot, not a diff
  2. 2. History is a directed acyclic graph
  3. 3. A branch is a movable pointer
  4. HEAD: where you are
  5. Reasoning with the graph: ancestry
  6. Naming commits without copying hashes
  7. Range notation: A..B and A...B
  8. Reachability is also garbage collection
Fresher Mid Senior

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.

commit 1 full snapshot commit 2 full snapshot commit 3 full snapshot README.md one copy, used 3 times cart.js v1 used by commit 1 cart.js v2 used by commits 2, 3 tax.js v1 added in commit 3
Every commit points at every file, but a file is stored once per distinct content. Three snapshots cost four stored files here, not nine. Directories (trees) are left out to keep the picture small; the next chapter puts them back.

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.

ARROWS POINT FROM CHILD TO PARENT A B C D E M N a feature line root: no parent merge: two parents main
A commit knows its parents and nothing else. It has no idea which children it has, or which branch it was made on. "The history of main" is simply every commit you can reach by walking the arrows backwards from where 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 main A B C one 41-byte file before HEAD main A B C D rewritten to point at D after git commit
A commit does three things: writes the new objects, writes a commit whose parent is the old tip, and overwrites the branch file with the new ID. 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:

QuestionAnswer from the graphCommand 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>
For seniors: what makes these questions fast

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 typeIt means
HEAD, @The commit you have checked out.
HEAD~3Three steps back, always following the first parent.
M^2The 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 totalThe newest commit whose message matches "cart total".
HEAD:src/cart.jsNot 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.

git log main..feature A B C D E main feature in feature, not in main: D, E git log main...feature A B C D E main feature in either, but not in both: C, D, E
Two dots: "reachable from the right side, minus everything reachable from the left". Three dots: "reachable from exactly one side", the symmetric difference. A and B are reachable from both tips, so neither form ever shows them.

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.

ExpressionWith git logWith git diff
A..BCommits 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...BCommits 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/mainWhat you would get by pulling.The reverse comparison.
The diff trap

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.

Why this matters

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.