Developer Tools · Version Control Internals
Refs, Symbolic HEAD, and the Reflog as an Append-Only Log
How branch names are stored as text files and packed lists, how HEAD follows indirection, and what the reflog records that refs do not.
A branch with ten thousand commits is stored in forty-one bytes: forty hexadecimal characters naming the tip commit and a newline. The commit itself is in the object store; the branch is a name for it, and the name lives in a file whose path is the branch name. Everything that makes branches cheap follows from this separation. Creating a branch is writing a file. Deleting one is unlinking it. Switching branches is writing a different forty characters into a second file.
The filesystem is the database
References live under .git/refs/, divided into namespaces: heads/ for local branches, tags/ for tags, remotes/ for the remote-tracking copies, notes/, bisect/, and replace/. A reference named refs/heads/topic/parser is the file .git/refs/heads/topic/parser, and intermediate directories exist only because the filesystem needs them.
The name is constrained because it becomes a path and because it is parsed by humans and machines alike. A reference name may not contain a space, a tilde, a caret, a colon, a question mark, an asterisk, an open bracket, a backslash, or the two-character sequences .. and @{. It may not begin with a dot, may not end with a slash or with the suffix .lock, may not contain consecutive slashes or a component beginning with a dot, and may not be the single character @. The restrictions rule out names that would be ambiguous in revision syntax, unsafe as filenames, or confusable with the lock protocol.
The lock protocol is how a reference is changed. An update creates refs/heads/topic.lock with an exclusive create, verifies that the old value still matches what the caller expected, writes the new identifier and a newline into the lock file, and renames it over the target. Readers therefore see either the old file or the new one, never a partial write, and two concurrent updates cannot both succeed. On systems where renaming over an existing file is not atomic, the implementation retries and reports a failure rather than pretending the update was safe.
Because references are paths, two names that would collide as a file and a directory cannot coexist: refs/heads/foo and refs/heads/foo/bar are mutually exclusive. This is a real constraint on naming, not a bug, and it is one of the reasons a separate reference backend exists.
HEAD is a reference to a reference
.git/HEAD normally contains the line ref: refs/heads/main. That is a symbolic reference: its value is another reference’s name, resolved at read time. The indirection is what makes HEAD mean “the current branch” rather than “this commit”. A commit updates the branch file, and HEAD needs no change; a checkout rewrites HEAD and leaves the branch alone.
When HEAD contains a raw identifier instead, the repository is in detached state. Commits still work, but they extend no branch, so nothing names them once HEAD moves. Symbolic references are also used for refs/remotes/origin/HEAD, which names the default branch of a remote, and resolution is bounded so that a cycle cannot hang a reader.
A small set of names are not ordinary references and do not live under refs/: MERGE_HEAD, CHERRY_PICK_HEAD, REVERT_HEAD, ORIG_HEAD, FETCH_HEAD. These record the state of an operation in progress, are read by the command that follows, and are removed when the operation finishes or is aborted. They are the reason an interrupted merge can be continued in a later process, and the reason git commit refuses to run while MERGE_HEAD exists unless it is told the merge is intended.
Packed refs collapse the directory
A repository with fifty thousand tags has fifty thousand small files, which costs inodes, directory lookups, and backup time. git pack-refs writes them all into a single .git/packed-refs file and removes the loose files, keeping a file on disk only for references that change frequently.
The file begins with a header line recording which packing features were used, for example # pack-refs with: peeled fully-peeled sorted. Each subsequent line is an identifier and a reference name. Immediately after a line for an annotated tag, a line beginning with a caret records the commit the tag peels to, so that a reader need not load and parse the tag object to find its target. The sorted marker tells readers that binary search is valid.
Resolution checks the loose path first and falls back to the packed file, so a loose reference shadows a packed one with the same name. Deleting a packed reference requires rewriting the file, since there is no per-entry tombstone; that rewrite is why deleting many tags at once is cheaper than deleting them one at a time, and why a deletion that races with a pack can leave the reference resurrected on the next read.
The reflog records movement, not state
A reference says where a branch is. The reflog says where it has been. For each reference that is logged, a line is appended to .git/logs/<refname> on every update: the old identifier, the new identifier, the identity and timestamp of the writer, and a message describing the operation. HEAD has its own log, which records checkouts and is the only place a detached head position is preserved.
The format is line-oriented and append-only, which makes it resistant to corruption: a crash can lose the last line but cannot damage earlier ones. Reflog entries are what make git reset --hard recoverable, what lets git reflog show the sequence of branch movements, and what keeps a commit alive after the branch that pointed at it has moved away.
Expiry is where the reflog meets garbage collection. Reachable entries are kept for ninety days and entries pointing at unreachable commits for thirty days by default, both configurable, and git reflog expire applies the policy. Those defaults are the reason a commit orphaned by a reset survives for weeks: it is not referenced by any branch, but a reflog entry still names it, and reflog entries count as roots when reachability is computed.
Reflogs are local. They are not transferred by fetch or push, they are not part of a clone, and a bare repository does not maintain them for its branches unless configured to. A repository that has been cloned fresh has no memory of anything that happened before the clone, which is why recovery advice that depends on the reflog fails on a machine that never had the original history.
Transactions and the second backend
Updating several references together is sometimes required to be all-or-nothing: a push that updates two branches, a rebase that moves a branch and its upstream marker, a fetch that advances many remote-tracking refs. The reference transaction interface accepts a sequence of updates with expected old values, prepares them, and commits them as a unit, aborting everything if any single update fails. The files backend implements this by taking all the locks first, then renaming them in sequence; the window between renames is not atomic to a reader, but no partial failure can be left behind.
# All-or-nothing update: both refs move, or neither does.
git update-ref --stdin <<'EOF'
start
update refs/heads/main 3f2a1c0b9d8e7f6a5b4c3d2e1f0a9b8c7d6e5f40
update refs/heads/topic 8c7d6e5f403f2a1c0b9d8e7f6a5b4c3d2e1f0a9b
prepare
commit
EOF
The newer reftable backend stores all references in a stack of sorted blocks with prefix compression and a merged index, which removes the file/directory conflict, makes multi-reference updates atomic by construction, and makes listing a namespace a scan rather than a directory walk. Repositories choose a backend at creation time; the reference namespace as seen by commands is identical either way.
Failure modes that surprise
Reference names are case-sensitive in Git and often case-insensitive in the filesystem beneath, so refs/heads/Topic and refs/heads/topic can both exist in the index of a repository that cannot check both out. A push with --mirror deletes remote references that do not exist locally, which is a data-loss mechanism rather than a synchronisation. A stale .lock file from a killed process blocks every subsequent update of that reference until it is removed, and the error message names the file rather than the cause.
The reflog is also the reason that object deletion is deferred: the reachability computation that decides what may be pruned treats every reflog entry as a root, so an object stays alive as long as any logged position names it. Removing the log first, then pruning, is the only way to shorten that window deliberately.
What references point at is a commit object, and what a merge reads to decide ancestry is the same graph those commits form. The reference layer is deliberately thin: it names entry points and records their history, and every question about content is answered somewhere else.
References
- git-check-ref-format manual page — Git project
- git-update-ref manual page — Git project
- Git source: refs/files-backend.c — Git project