Skip to content
Refs and HEAD
step 1/5

Reading — step 1 of 5

Read

~3 min readRefs & History

Refs and HEAD

Here's the number that reframes git: a branch is 41 bytes. Forty hex characters of SHA plus a newline, in a plain text file. Everything that feels heavy about branches — creating, deleting, switching, "moving" — is writing 41 bytes to a file. This lesson is that file system.

Refs: names for hashes

Hashes are perfect identifiers and terrible names. A ref maps a human name to a commit hash, stored exactly where you'd hope:

.git/refs/heads/main        ← contains: "a1b2c3…\n"   (a branch)
.git/refs/heads/feature     ← another branch
.git/refs/tags/v1.0         ← a tag

Try it in any repo: cat .git/refs/heads/main — there's your branch. Creating a branch is creating this file (git branch x = write current hash to refs/heads/x); deleting is deleting it. The 10,000 commits the branch "contains" are reachable through that one hash via last lesson's graph walk — the branch itself is just the entry point. (Real git also compresses old refs into a single .git/packed-refs file — same mapping, different storage; your implementation can ignore it, but now the file won't surprise you.)

The difference between branch and tag isn't storage — identical files! — it's who moves them: commit on a branch and git advances that branch's ref to the new commit; tags are never touched. A branch is a moving bookmark, a tag is a sticker.

HEAD: where you are

One question the refs can't answer: which branch am I on? That's HEAD — a single file at .git/HEAD, and its usual content is the twist of the lesson:

ref: refs/heads/main

HEAD doesn't (normally) hold a hash — it holds the name of a ref. A symbolic ref: a pointer to a pointer. That indirection is exactly how committing works:

  1. You commit. Git builds the commit object.
  2. Git reads HEAD → "refs/heads/main".
  3. Git writes the new commit's hash into refs/heads/main.

HEAD itself never changed — but "where you are" advanced, because HEAD points at a name and the name moved. Switching branches is rewriting one line in HEAD.

Detached HEAD, demystified

Check out a raw commit (git checkout a1b2c3) and HEAD contains a hash instead of a ref name. That's the whole phenomenon: the pointer-to-a-pointer temporarily holds a value directly. Git's scary warning just means: commits you make now advance no branch; they're reachable only through HEAD, and when HEAD moves away they're orphaned. Two-level indirection, level skipped, consequences explained. Every "git is confusing" story about detached HEAD is this paragraph.

Resolving a name

Your implementation needs one function used everywhere: given "main" or "HEAD" or a hash, produce the hash. The chain: if it's HEAD, read .git/HEAD — symbolic? follow the named ref; raw hash? done. If it's a name, try refs/heads/<name>, then refs/tags/<name>. If it looks like a hash, it is one. That resolver is what makes git log main, git log HEAD, and git log a1b2c3 the same command.

Your exercise: Implement Ref Operations

Create/read/update/delete refs, resolve names through HEAD's indirection, handle both symbolic and detached states. It's file I/O plus one recursive resolve — deliberately. When the test that checks "commit advances the current branch, not HEAD itself" passes, you understand the two-level design better than most daily git users, because you've implemented the level most people don't know exists.

Discussion

Ask a question, share an insight, or help someone who’s stuck.

Sign in to post a comment or reply.

Loading…