skip to content

In Git, why might a branch exist with no file for it under .git/refs/heads?

level: seniorimportance: nice to knowfreq 22%

answer

  1. one file per ref does not scale
  2. two places to look, one wins
  3. deletion is the dangerous direction
  4. stop globbing, start asking Git

basics

~10 s

Because refs can be stored two ways. Git compacts loose one-file-per-ref storage into a single .git/packed-refs file, so a branch may exist only as a line there. Loose files, when present, take precedence.

solid answer

~40 s

Refs have two storage forms. A **loose** ref is one small file per ref under `.git/refs/`, containing a hash. When refs accumulate, Git compacts them — during `git gc`, or on demand with `git pack-refs --all` — into a single `.git/packed-refs` file with one `<hash> <refname>` line per ref, and removes the loose files. Lookups check the loose file first and fall back to `packed-refs`, so a loose ref always wins if both exist; an update writes a fresh loose file rather than editing the packed one, and a delete must remove both. This is why reading refs by walking the filesystem is unreliable, and why you should use `git for-each-ref`, `git show-ref` or `git rev-parse` instead. Recent Git also offers an alternative reftable backend.

code

console · 7 lines
console
$ git pack-refs --all
$ ls .git/refs/heads/
$ cat .git/packed-refs
# pack-refs with: peeled fully-peeled sorted 
d2b5d5322e03b5733ebfa5debcd995cfc63f718f refs/heads/master
58ed515e4e450ae4bb355b4e9455f6e832161c72 refs/tags/v1.0
^d2b5d5322e03b5733ebfa5debcd995cfc63f718f

go deeper

for a junior

Know that a branch is a name holding a commit hash, and that Git — not you — decides how and where that name is stored on disk.

for a middle

Explain loose versus packed storage, that git gc and git pack-refs do the compaction, and that a loose ref shadows a packed entry of the same name.

for a senior

Show operational judgment: deletions must clear both stores, so use git update-ref -d rather than filesystem surgery, and enumerate with git for-each-ref so automation survives compaction.

for a principal

Own the interface boundary: treat the ref store as an API with multiple backends, and hold tooling to using Git's ref commands so a backend change is invisible to everything the organisation has built.

## Two storage forms for the same thing A ref is conceptually a name holding an object id, and Git can store it two ways. **Loose**: one file per ref, at the path matching the ref name — `refs/heads/main` is literally a file — containing the hash and a newline. Cheap to write, but a repository with thousands of refs pays a directory entry and a stat call per ref. **Packed**: a single `.git/packed-refs` file, sorted, with a header comment line and one `<hash> <refname>` line per ref. `git pack-refs --all` writes it explicitly, and `git gc` does it as part of routine maintenance. The corresponding loose files are then deleted, which is exactly why `.git/refs/heads/` can look empty in a healthy repository. ## Precedence and update rules Resolution checks the loose file first; if it exists, it wins outright and the packed entry for that name is ignored. Updating a packed ref therefore does not rewrite `packed-refs` — Git writes a new loose file that shadows it. Deleting a ref is the awkward case, because the name may exist in both places and both must go; that is the concrete reason to never delete a branch by removing a file by hand. Use `git update-ref -d <refname>`, or the porcelain `git branch -d`, and let Git handle both stores. ## Peeled tags `packed-refs` has one extra trick. An annotated tag's ref points at a tag object, not a commit, so anything wanting the commit must peel it. The packed file records the peeled result on a following line beginning with `^`, and the header advertises this with `peeled` and `fully-peeled`. That lets ref enumeration answer "which commit is this tag on" without opening a single object. ## Why this matters in practice The operational lesson is that the filesystem layout is an implementation detail, not the API. Scripts that glob `.git/refs/heads/*` to list branches silently miss every packed ref, and they break outright under alternative backends. The supported interfaces are `git for-each-ref` for enumeration, `git show-ref` for existence, `git rev-parse` for resolution, and `git update-ref` for mutation — the last of which also writes reflog entries and can take an expected old value, giving a compare-and-swap that a manual file write cannot. ## The reftable backend Recent Git versions add a second ref backend, selected at creation with `git init --ref-format=reftable` and recorded as `extensions.refStorage` in the config. It replaces both loose files and `packed-refs` with a binary, block-structured store designed for repositories with very large numbers of refs and for atomic multi-ref updates. Its existence is the strongest argument for the rule above: any code that reads refs through the filesystem is coupled to a backend that is no longer the only one.

  • Why is deleting a branch by removing its file under .git/refs/heads unsafe?
    The name may also exist in `packed-refs`, in which case the branch simply reappears once the loose file is gone. `git update-ref -d` (or `git branch -d`) removes the ref from both stores and records the change in the reflog, which a manual file deletion does not.
  • What does the `^` line under a tag in packed-refs mean?
    It records the peeled value: the commit that an annotated tag's tag object ultimately points at. Storing it lets Git answer "which commit is this tag on" while enumerating refs, without opening the tag object at all. The file's header advertises whether entries are peeled.

saying these in an interview costs you the question

  • Assumes every ref is always a file under .git/refs
  • Says a packed ref takes precedence over a loose one
  • Scripts branch listing by globbing the refs directory
  • Thinks packing refs rewrites or loses reflog history

context