Git Internals
Topic 22

References and the Ref Database

Internals

A ref is a human-readable name that points at a SHA. Branches, tags, and HEAD are all refs — the friendly handles that save you from typing 40-character hashes. Underneath, a ref is either a loose file under .git/refs/ containing a SHA, or an entry compacted into .git/packed-refs, and a special kind called a symbolic ref points at another ref rather than at a commit.

Understanding the ref database is what lets you reason about HEAD, remote-tracking branches, and detached states without confusion. It is also why the right answer to "what does HEAD point at?" is a plumbing command, not opening a file in your editor.

HEAD is a symbolic ref that points at a branch, which points at a commit SHA
HEAD (symbolic)
ref: refs/heads/main
refs/heads/main
one 40-char SHA
commit SHA
a1b2c3d4… in the object database
refs/remotes/origin/main is a local snapshot, updated only on fetch/push

Loose Refs and packed-refs

Every ref starts life as a loose file: .git/refs/heads/main is literally a text file holding one 40-character SHA. On a repo with thousands of tags and branches, that is a lot of tiny files, so git pack-refs collapses them into a single .git/packed-refs file for faster lookup. When the same ref exists in both forms, the loose file wins — it is the more recent write, and packed-refs is treated as the older snapshot.

This precedence rule matters when you go poking by hand: deleting a loose ref file without also clearing its packed-refs entry leaves a stale pointer that reappears unexpectedly.

Symbolic Refs

HEAD is usually not a SHA at all. It is a symbolic ref whose content is a line like ref: refs/heads/main — it points at a branch, which in turn points at a commit. git symbolic-ref HEAD reads that pointer. When you check out a raw commit instead of a branch, HEAD holds a bare SHA directly: that is the detached HEAD state, and it is exactly why a script that assumes HEAD always contains a branch name breaks the moment someone detaches it.

Ref Namespaces

The path of a ref determines how Git treats it. refs/heads/ holds branches, refs/tags/ holds tags, refs/remotes/ holds remote-tracking refs like origin/main, and there are also refs/notes/ and refs/stash. The namespace is not cosmetic — it drives behavior, so a tag ref and a branch ref are not interchangeable even when they point at the same commit.

The remote-tracking namespace is a common trap. refs/remotes/origin/main is a local snapshot of where the remote's branch was at your last fetch, not the live branch on the server. It only moves when you fetch or push.

Resolving and Updating Refs

Two plumbing commands do the work that branch and checkout call underneath. git rev-parse <ref> resolves any name to its SHA, and git update-ref refs/heads/x <sha> writes a ref safely, recording the change in the reflog. git symbolic-ref reads and writes the symbolic kind. Going through these commands is correct; hand-editing files under .git/ is how you corrupt a repo.

$ git symbolic-ref --short HEAD
main
$ git rev-parse main
a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0
$ git show-ref
a1b2c3...  refs/heads/main
9f8e7d...  refs/remotes/origin/main
3c2b1a...  refs/tags/v1.0
Common Mistakes
  • Hand-editing .git/packed-refs to fix a ref and corrupting its format, when git update-ref and git pack-refs would have done it safely.
  • Writing a script that reads .git/HEAD as if it always held a SHA, then breaking the instant HEAD is symbolic and contains ref: refs/heads/....
  • Deleting a branch's loose ref file by hand and leaving its stale entry in packed-refs, so the branch appears to come back.
  • Treating refs/remotes/origin/main as the live server branch and acting on stale data, when it only updates on fetch or push.
  • Using a tag ref and a branch ref interchangeably, ignoring that their namespaces make tools treat them differently.
Best Practices
  • Read what HEAD points at with git symbolic-ref --short HEAD rather than parsing files under .git/.
  • Resolve any ref to its SHA with git rev-parse <ref> in scripts.
  • Update refs through git update-ref refs/heads/x <sha> so the change is recorded in the reflog.
  • Run git pack-refs --all on repos that have accumulated very large numbers of refs.
  • List every ref and its target with git show-ref or git for-each-ref instead of browsing the filesystem.
Comparable toolsMercurial bookmarks as the branch analog, no symbolic-ref equivalentSubversion no client-side refs; branches are server directory copiesFossil branches tracked as tags inside its SQLite store

Knowledge Check

When a ref exists both as a loose file and in packed-refs, which one wins?

  • The loose file; packed-refs is the older snapshot and the loose write takes precedence
  • The packed-refs entry, because its consolidated single-file layout is more compact on disk and faster to scan
  • Whichever of the two points at the lexicographically larger commit SHA
  • Git errors out on the conflict until you delete one of them

What is in HEAD normally, and what changes when it is detached?

  • Normally a symbolic ref like ref: refs/heads/main; detached, it holds a raw commit SHA
  • Normally a raw commit SHA; once detached, it instead holds the current branch name
  • It always holds a raw commit SHA; detachment only changes the shell prompt, never the file's contents
  • It points into the packed-refs file until you detach it

What does refs/remotes/origin/main actually represent?

  • A local snapshot of where the remote's branch was at your last fetch or push
  • A live, always-current view of the branch as it stands on the server right now
  • A second mirrored copy of your own local main branch
  • The remote's HEAD symbolic ref pointer

Why should scripts use git rev-parse and git symbolic-ref instead of reading .git/ files?

  • Refs may be loose or packed and HEAD symbolic or detached; the plumbing handles every form correctly
  • Reading the files directly is measurably slower than spawning a subprocess
  • The .git/ directory is encrypted and cannot be read directly
  • The plumbing commands quietly modify and repack the refs in passing, which keeps them fresh and consistent across reads

You got correct