Branches are 41 bytes
In the first post of this series we opened .git and found a small database: every file becomes a blob, every directory a tree, every commit a few lines of text, and everything is named by the SHA-1 of its own bytes. Near the end, almost in passing, we met refs/heads/main: a 41 byte text file holding a commit ID. That was our branch.
This post picks up exactly there. Part one was about the database. This one is about the pointers into it: branches, tags, HEAD, and the log that remembers every place those pointers have ever been. It's also about the arrow we never followed: part one only turned files into objects, but git checkout runs the machine in reverse and turns objects back into files.
Same setup as before: every figure is generated by a real, tiny git running in your browser, continuing the exact repository from part one. Two commits, 76309f4 and e268ce4, author and dates pinned so every hash reproduces. Nothing below is hardcoded; if a sha is on the screen, the engine computed it.
01 · git branch, a 41 byte write
Let's create a branch and catch git in the act again. After git branch feature, exactly two files appear in .git, and zero objects:
The interesting one is refs/heads/feature: 40 hex characters and a newline, 41 bytes, and its content is exactly the content of refs/heads/main. That's the whole operation. git branch creates a new branch head pointing to the current commit, which in practice means: copy 41 bytes into a new file. The Pro Git book says it plainly:
"That's basically what a branch in Git is: a simple pointer or reference to the head of a line of work."
The other file, logs/refs/heads/feature, is the new branch's reflog. We'll get there in section 05.
Two details, both easy to miss. First, git branch creates the branch but does not move you onto it: HEAD still says ref: refs/heads/main. Follow the arrows in the figure: HEAD points at a ref, and the ref points at a commit. HEAD is a pointer to a pointer, which the glossary calls a symbolic ref. Second, notice what did not happen: no objects were copied, no files were scanned, nothing proportional to the size of your repository. Creating a branch on a ten million file monorepo writes the same 41 bytes.
02 · Tags, and the fourth object type
Part one's table of object types listed blob, tree, commit, and a fourth one we never opened: the tag. Time to pay that debt. Git has two kinds of tags, and they're wonderfully unequal:
A lightweight tag (git tag v0.1 76309f4) is a branch that doesn't move: the same 41 bytes, just under refs/tags/ instead of refs/heads/. When you commit, git advances the current branch's file; nobody ever advances a tag. That's the entire difference between the two folders. Click the two tag pills in the figure and compare where each one lands.
An annotated tag (git tag -a v1.0 -m 'first release') is the interesting one: it creates a real object in the database, wrapped in the same $type $size\0$content envelope as everything else. Open 771c4e2 in the inspector above: an object line pointing at the tagged commit, a type, the tag name, a tagger with a timestamp, and a message. It's shaped almost exactly like a commit, except it points at a commit instead of a tree, and it has no parent. And look at refs/tags/v1.0 in the tree: it stores the ID of the tag object, not the commit. One more hop of indirection, in exchange for knowing who tagged what, when, and why, all checksummed and signable.
03 · Checkout, the reverse arrow
Everything we've done across two posts moves in one direction: add turns files into blobs, commit turns the index into trees and a commit. The working directory goes in; objects come out. git checkout points the other way: give it a commit and it walks the tree, finds every blob, and writes the bytes back onto disk as ordinary files.
Let's give it something to do. We move onto the branch with git checkout feature and commit a new file there, feature.txt. Now feature points at 492e983 while main stayed at e268ce4: the two branches finally disagree. Switch back and forth and watch both ends of the machine, the pointers on the left and the actual files on the right:
Three things happen on every switch, and you can see all of them:
.git/HEADis rewritten, fromref: refs/heads/maintoref: refs/heads/featureand back. That's checkout's entire bookkeeping: one line in one file.- The index is rebuilt from the target commit's tree, so the staging area agrees with what you're standing on. Watch the blob IDs under
.git/indexflip; click one to open the blob it names. - The working directory is materialized:
feature.txtappears and disappears, blobs become files, trees become directories. The reverse arrow.
Note which pointer moved when we committed on feature: the branch file. That's the dynamic part one never showed: git commit doesn't just create objects, it also writes the new commit's ID into whatever ref HEAD points at. The branch follows you. Tags don't, because nothing ever writes to them. (Modern git also ships git switch, which is just the branch-switching half of checkout with a friendlier interface. Same files change underneath.)
04 · Detached HEAD, the scariest harmless warning
HEAD points at a ref, and the ref points at a commit. But what if you check out a commit directly?
Try it above: git checkout 76309f4 and .git/HEAD no longer contains a ref: line. It contains the raw commit ID, 41 bytes, the same format as a branch file. HEAD has stopped being a pointer to a pointer and became a plain pointer. Real git prints a warning at this point that has scared a generation of developers:
"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."
Read it again without fear: it's a feature description. Nothing is broken, nothing was lost. Your working directory now matches an old commit (watch the right panel above: test.txt went back to version 1, and the other files are gone, because that's what the tree of 76309f4 contains). The only real caveat is that if you commit while detached, the new commit's ID gets written into HEAD itself and no branch remembers it. It becomes reachable only through the topic of the next section, which is exactly why the warning suggests git switch -c <new-branch-name>: creating a branch is how you give a commit a permanent name. Tools that need to build an exact commit, like CI runners, live in this state all day.
05 · The reflog, your safety net
Time to open the folder we've been stepping around: .git/logs. Every time HEAD or a branch moves, git appends one line to the matching file under logs/. This is the data behind git reflog:
Hover the rows: each raw line is old-sha new-sha identity timestamp timezone, a tab, and then action: message. Click a row and the commit it records opens below, straight from the object database. Our whole session is in there: two commits from part one, the switch to feature, the commit on it, the return to main, and the detached HEAD round trip. Note the detour entries: when we detached, git logged moving from main to 76309f4 using exactly what we typed, and on the way back it wrote the full 40 character sha, because there was no branch name to use.
Now let's earn the phrase "safety net" instead of just saying it. Deleting feature with git branch -d fails: git answers error: the branch 'feature' is not fully merged, because 492e983 isn't merged into the branch we're standing on and would be left behind. Forcing it with -D deletes the 41 byte file, and with it the last reference to our commit. Even the branch's own reflog dies with it: git removes logs/refs/heads/feature too. The commit is now unreachable. As far as git log and every branch listing can tell, it's gone.
But logs/HEAD belongs to HEAD, not to the branch, and HEAD was standing on 492e983 when we committed. The object is still sitting in the database, content addressed and immutable, waiting for someone who knows its name. Do it yourself:
The rescue is what the whole post has been building to: git branch feature 492e983 writes 41 bytes into a file, and the "lost" commit, its tree, and its blobs are all back, byte for byte identical, because they never left. This is why "I lost my commit" is almost never true. Two honest limits: the reflog is local to your repository, it never gets pushed, so it can't save work that only existed on a laptop that died. And entries expire: 90 days by default, 30 for unreachable ones, after which git gc may really delete the objects. A safety net, not an archive.
06 · The directory git cannot store
One last quirk, as a goodbye gift. Part one showed that a tree object is a list of entries: names, modes, and pointers to blobs and subtrees. The index that trees get built from is a list of files. Follow those two definitions to their conclusion: there is no way to record a directory with nothing in it. An empty directory never enters the index, so no tree ever mentions it, so no commit can contain it. The git wiki's FAQ confirms it: you can't add empty directories.
The workaround you've seen in a hundred repositories is to make the directory not empty: drop a file in it, conventionally called .gitkeep, and commit that. The name has zero special meaning to git; it's an empty blob like any other (the famous e69de29, the SHA-1 of zero bytes), and .keep or README would work identically. Toggle the figure above and watch the tree object: logs/ simply cannot appear on the right side until it contains a blob.
07 · What's next
The mental model after two posts, in three lines: objects are immutable and named by their content. Refs are 41 byte files pointing into the object soup, HEAD is one more tiny file pointing at one of them, and every git "location" concept (branches, tags, even your last positions) is one of those files, or a log of one. And checkout is the pump that moves content from the database back onto your disk.
Next stop: packfiles, where git stops being adorable loose files and starts being a real storage engine: thousands of objects delta-compressed into a single file, which is what git gc is actually doing when your objects/ folder shrinks. Even the refs get packed eventually. If you're just arriving, part one is the right place to start, and py-git, my Python implementation of git's plumbing, covers both posts in runnable form.