Git's Commit Graph, Explained
After reading this you will be able to predict exactly where every branch label and the HEAD pointer land after a commit, merge, rebase, cherry-pick or reset, because you will see git as a graph of nodes and movable labels rather than a pile of files.
What git actually stores
Git does not track "changes" as its core unit. It stores commits. A commit is a snapshot plus a small header: an author, a message, and a list of parent commits. Each commit has a name, its 40-character SHA-1 hash (or 64-character SHA-256 in newer repositories). Because a commit's hash is computed from its content, and its content includes the parent hash, the whole thing forms a directed acyclic graph. Change any ancestor and every descendant hash changes too.
Three concepts sit on top of that graph. A commit is a node. A branch is a label that points at one commit and moves as you work. HEAD is a pointer to a branch (or occasionally to a commit directly, which git calls "detached HEAD"). That is the entire model. Almost every confusing git command is just moving one of those three things.
The sandbox draws each commit as a circle labeled with a short hash like a1b2c3, each branch as a tag attached to a commit, and HEAD as an arrow pointing at a branch. Type a command, watch the graph move, and read the one-line explanation of what changed.
A first example you can reproduce
Start from the default state: one branch main at a single commit, with HEAD pointing at main. Run these four commands:
git commit: creates a new commit whose parent is the oldmaintip.mainmoves forward to the new commit. HEAD still points atmain, so HEAD moves with it.git branch feature: creates a new labelfeatureat the current commit. Nothing else moves. You now have two labels on the same node.git checkout feature: HEAD stops pointing atmainand points atfeatureinstead. No commit is created.git commit: a new commit lands, its parent is the shared commit, andfeaturemoves forward.mainstays put.
After those four steps the graph forks: main and feature share a common ancestor but each has moved on. That fork is the reason merge and rebase exist.
The one number that decides a merge: how far behind
When you merge branch B into branch A, git first asks a graph question: is A's tip an ancestor of B's tip? If yes, A is strictly behind B on the same line, and git performs a fast-forward: it slides A's label forward to B's commit and creates no new commit.
If neither tip is an ancestor of the other, the branches have diverged and git builds a merge commit: a single new node with two parents, A's old tip and B's tip. The number of parents is the whole story. Count them:
Here c is any commit. A root commit has zero parents. Ordinary commits and fast-forwards leave the parent count at one, since a fast-forward creates nothing new. A merge of two diverged branches produces a commit with two parents. Octopus merges of n branches produce n parents, but that is rare.
Rebase does not move commits, it copies them
Rebasing feels like it slides your work onto a new base. What it really does is replay each of your commits as a brand-new commit with a new hash, then move your branch label to the last of those new commits. The originals are not deleted; they become unreferenced and fade until garbage collection removes them.
Say feature has 3 commits on top of the fork point, and main has moved 2 commits ahead. Running git rebase main while on feature creates 3 new commits, one per replayed change, stacked on top of main's current tip. The commit count you can reach from feature goes from 3-past-the-fork to 3-past-main's-new-tip, and 3 old commits go dark.
Because the new commits have different hashes, anyone who already pulled the old ones now has history that disagrees with yours. That is why the rule "do not rebase published branches" exists. The sandbox shows this literally: watch the old hashes fade while fresh hashes appear.
Cherry-pick, reset, and tag in one graph vocabulary
Once you see everything as node-plus-label, three more commands collapse into simple graph edits.
- cherry-pick
- Copies the changes from one commit and makes a new commit with those changes on your current branch. One node in, one new node out, with a new hash. The source stays exactly where it was.
- reset --hard
- Moves your current branch label to any commit you name, and points HEAD's working state at it. It creates and destroys nothing in the graph. If you reset
mainback 2 commits, those 2 commits are still reachable through git's reflog, exactly as in the sandbox where they linger instead of vanishing. - tag
- A label like a branch, but it does not move when you commit. Use it to pin a release.
Reproducing the feature-branch preset
Load the feature-branch scenario (it uses the field defaults) and run the sequence below. The hashes are illustrative; yours will differ, but the shape will match exactly.
- Start:
mainat commitC1, HEAD onmain. Reachable commits: 1. git committwice:mainadvances toC3. Reachable: 3.git checkout -b feature: createsfeatureatC3and moves HEAD to it. Reachable fromfeature: 3.git committwice:featurereachesC5.mainis still atC3. The graph now forks 0 commits (they still shareC3) butfeatureis 2 ahead.git checkout mainthengit commit:mainmoves toC6, a sibling ofC4. Now the branches have genuinely diverged: neither tip is an ancestor of the other.git merge feature: git cannot fast-forward, so it buildsC7with two parents,C6andC5. Total reachable frommain: 7 commits, one of them a 2-parent merge.
Compare that with the merge-vs-rebase preset. If instead of merging you had run git rebase main on feature, the 2 feature commits would be replayed on top of C6 as C4' and C5', giving a straight line of 6 commits with no merge node and no 2-parent commit at all.
Move the parameter that matters: divergence
The single most instructive number is how far the two branches have diverged. When your side is 0 commits ahead of the base, a merge fast-forwards and no merge commit appears. As soon as both sides have unique commits, you get a merge node or, if you rebase, that many rewritten hashes.
Common mistakes when reasoning about the graph
These are the errors that make git feel unpredictable. Each one dissolves once you track nodes and labels separately.
- Thinking a branch "contains" commits. A branch is one label pointing at one commit. The commits it "has" are simply the ones reachable by following parent links.
- Expecting rebase to keep hashes. Every replayed commit is new. If
featurehad 3 commits, you now have 3 different hashes even though the changes are identical. - Believing
reset --harddeletes commits. It moves a label. The old commits stay reachable through the reflog for the default 90 days before garbage collection. - Assuming every merge makes a merge commit. A fast-forward makes none. If your branch is 0 commits behind, git just slides the pointer.
- Confusing HEAD with a branch. HEAD normally points at a branch. Checking out a bare commit detaches it, and new commits there belong to no branch until you name one.
Related visual tools
If seeing the structure move helped here, the same approach applies to other data models. The commit graph is a directed acyclic graph, so the Data Structure Visualizer is a natural next step for trees and union-find. For other step-by-step system animations, try the Raft Consensus Visualizer to watch log replication (which builds on the same append-only history idea), the Cache Replacement Simulator, or the Consistent Hashing Ring.
Frequently asked questions
Why does rebase change the commit hashes?
A hash is computed from the commit's content, which includes its parent's hash. Rebase gives each commit a new parent, so the content differs and the hash differs. Identical changes, different identity.
When does git skip the merge commit?
When the branch you merge in is directly ahead of your current branch and your branch has 0 unique commits. Git fast-forwards by moving the label, creating no merge node. Merge a fresh feature branch back into an unchanged main to see it.
Did reset --hard delete my work?
No. It moved a branch label. The commits you moved away from are still in the graph and are listed by git reflog, which is how you recover them. Garbage collection only removes truly unreachable commits, by default after about 90 days.
What is a detached HEAD?
HEAD usually points at a branch. If you check out a specific commit instead, HEAD points at that commit directly. Any commits you make there belong to no branch, so create one before you check out something else.
Why does the sandbox ignore file contents and conflicts?
The confusing part of git is the graph, not the diffs. Removing file contents keeps every command down to node-and-label moves you can predict exactly.