References and the Ref Database
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.
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
- Hand-editing
.git/packed-refsto fix a ref and corrupting its format, whengit update-refandgit pack-refswould have done it safely. - Writing a script that reads
.git/HEADas if it always held a SHA, then breaking the instant HEAD is symbolic and containsref: 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/mainas 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.
- Read what HEAD points at with
git symbolic-ref --short HEADrather 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 --allon repos that have accumulated very large numbers of refs. - List every ref and its target with
git show-reforgit for-each-refinstead of browsing the filesystem.
Knowledge Check
When a ref exists both as a loose file and in packed-refs, which one wins?
- The loose file;
packed-refsis the older snapshot and the loose write takes precedence - The
packed-refsentry, 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-refsfile 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
mainbranch - The remote's
HEADsymbolic 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