Refs, HEAD, and the Commit DAG

If commits are content-addressed and immutable, how does anything ever move forward? The answer is refs: tiny mutable files, most of them containing nothing but a 40-character hash. A branch is not a copy of your work or a container for commits — it is a single sticky note pointing at one commit. Understanding that branches are pointers, and history is a graph, dissolves most of the confusion around Git.

The previous post left us with a pile of immutable objects named by their hashes. That’s a database, not a version-control system. What turns it into one is a thin layer of mutable pointers — refs — laid over the immutable object graph. This post is about that layer: branches, tags, HEAD, and the shape of history itself.

A ref is just a name for a commit

Object hashes are perfect for machines and miserable for humans. Nobody wants to type 4b7a1f0e2c... to check out yesterday’s work. A ref (reference) solves this: it is a human-readable name that points to a commit hash. That’s the whole idea.

Refs live as plain files under .git/refs/. A branch named main is literally the file .git/refs/heads/main, and its entire contents are one line — a hash:

$ cat .git/refs/heads/main
4b7a1f0e2c3d5a6b7c8d9e0f1a2b3c4d5e6f7a8b

That’s it. A branch is a 41-byte file (40 hex characters plus a newline). It is not a folder that holds commits, not a copy of your files, not a timeline. It is a single pointer to one commit. This is the most important sentence in this whole series, so it bears repeating: a branch is a movable pointer to a commit.

Tags live under .git/refs/tags/. A lightweight tag is exactly like a branch file — a name pointing at a commit — except Git never moves it automatically. An annotated tag points instead at a tag object (from the previous post), which in turn points at the commit. Branches move as you work; tags stay put. That difference in mobility is most of what distinguishes them.

Why branching is instant

Because a branch is one small file, creating a branch means writing 41 bytes. That’s why git branch feature is instantaneous even in a repository with a million commits — it copies a hash into a new file. Nothing is duplicated, nothing is scanned. Deleting a branch deletes that file (the commits it pointed to may become unreachable, but they aren’t erased — more on that when we reach the reflog).

Compare this to systems where a “branch” is a directory copy on the server. Git’s branches are cheap precisely because they are pointers, not containers. The commits exist once in the object store; branches are just different fingers pointing into the same graph.

HEAD: the pointer to your current branch

If a branch points to a commit, what points to your current branch? That’s HEAD. The file .git/HEAD almost always contains not a hash but a symbolic reference — a pointer to a pointer:

$ cat .git/HEAD
ref: refs/heads/main

HEAD says “you are currently on main.” When you commit, Git creates the commit object, then updates whatever HEAD points to — here, main — to the new commit’s hash. So the sequence is: HEAD → current branch → current commit. Committing advances the branch under HEAD; HEAD comes along for the ride because it points at the branch, not at the commit directly.

Detached HEAD is simply the case where .git/HEAD contains a raw hash instead of ref: refs/heads/.... You’ve checked out a commit directly, so HEAD points at a commit with no branch in between. It’s not an error state — it’s a perfectly valid configuration. The only risk is that commits you make there are pointed to by nothing but HEAD; move HEAD elsewhere and they can become unreachable. Create a branch before you leave, and they’re anchored.

History is a directed acyclic graph

Now zoom out. Each commit points to its parent(s). Follow those parent links and you trace history backward. This structure — nodes (commits) connected by parent edges — is a directed acyclic graph (DAG):

        A---B---C   (main)
             \
              D---E   (feature)

Here C and D both have B as parent — the branch point. main points at C, feature at E. There is no third structure recording “the branches”; the branches are those two pointers, and the shape is implied entirely by the parent edges. Merging feature into main will create a new commit with two parents (C and E), stitching the fork back together — a convergence node in the DAG.

Reachability is the organizing idea

Once you see history as a DAG with refs pointing into it, a single concept explains an enormous amount of Git’s behavior: reachability. A commit is “reachable” if you can get to it by starting at some ref and walking parent edges. Reachable commits are your history; they’re safe. Unreachable commits are candidates for garbage collection.

This reframes almost every Git operation as “move a pointer and change what’s reachable.” git commit extends a branch to a new tip. git reset moves a branch pointer to a different commit. git merge and git rebase create new commits and repoint a branch at them. branch and tag add new entry points into the graph. Nothing ever edits a commit in place — it can’t; commits are immutable and content-addressed. Instead, Git moves the cheap, mutable refs and lets reachability do the rest.

Key takeaways

Further reading

Sources & References

Refs, HEAD, and the commit graph