Git, CI/CD & GitOpsV · Branches, Refs and HEADBranches, Refs and HEAD
Packed refs and the reflog — when refs are packed, and how the reflog records every ref change
What you'll learn
- Explain when refs are packed into .git/packed-refs and why this matters for performance
- Read .git/packed-refs and understand its line format
- Explain how the reflog records every ref change and how to read it with git reflog
- Recognise the interaction between reflog expiration and garbage collection
- Configure gc.reflogExpire and gc.reflogExpireUnreachable to tune retention per clone
Prerequisites
Verified against Git 2.55.x teaching target; 2.40+ minimum · GitHub Actions continuous service; Aug 2026 documentation baseline · Argo CD v3.5.x teaching target; v3.0+ minimum · Flux v2.9.x · Sigstore Cosign v3.1.x · SLSA v1.2 · OCI Distribution Specification v1.1 · Git LFS v3.7.1 · Kubernetes (cross-course target) 1.36.x
A Git repository stores refs in two formats. The first is
loose: one file per ref under .git/refs/, just as the
prior lessons described. The second is packed: a single
file, .git/packed-refs, that contains every ref Git decided
to pack, with the format <oid> <refname> per line. Packing
is a performance optimisation: a repository with thousands of
refs reads and writes faster when the refs are in one file
than when they are in thousands of files. The reflog is a
separate system — a per-clone log of every ref change — that
provides the recovery path for commits that would otherwise be
lost.
Loose refs and packed refs
A loose ref is a file under .git/refs/heads/,
.git/refs/tags/, .git/refs/remotes/, or one of the other
namespaces. A packed ref is a line in .git/packed-refs. Git
consults the loose ref first (if it exists), then the packed
refs file.
# A loose ref
cat "$GIT_DIR/refs/heads/main"
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e
# A packed ref (the file .git/packed-refs)
cat "$GIT_DIR/packed-refs"
# # pack-refs with: peeled fully-peeled sorted
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e refs/heads/main
# 4d2c8e0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6f refs/tags/v1.0.0
# 9f3c1d7a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e refs/tags/v1.1.0
# 6f4e5a6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6c refs/tags/v2.0.0
# 9a8b7c6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6b refs/tags/v2.0.0^{}
The first line is a header comment recording the packing
options. The remaining lines are <oid> <refname> pairs. The
last line, refs/tags/v2.0.0^{}, is the “peeled” OID of the
annotated tag — the OID of the commit the tag ultimately
points at, after following the tag object. The peeled form
lets git rev-parse v2.0.0^{commit} answer in one lookup
instead of dereferencing the tag object.
flowchart LR
L["refs/heads/main\n(loose file)"] --> C["commit 8a3f9d2"]
P["packed-refs:\n8a3f9d2 refs/heads/main"] --> C
G["git rev-parse main"] --> L
G --> P
The diagram shows the resolution path. git rev-parse main
first checks the loose file; if it exists, that is the answer.
If the loose file does not exist, the lookup falls through to
the packed refs file. The two representations are equivalent
in answer; only the disk layout differs.
When refs are packed
Git packs refs automatically during repository maintenance
(typically git gc or git maintenance). The trigger is
heuristic: if the number of loose refs crosses a threshold (or
the user runs git pack-refs --all explicitly), Git moves
every loose ref into the packed file and deletes the loose
file.
# Pack all currently loose refs into .git/packed-refs
git pack-refs --all
# Pack only tags (not branches)
git pack-refs --no-all
# Only tags are packed; branches remain as loose files
The --all flag packs every ref; the default (--no-all)
packs only tags. In production, the typical state is: tags are
packed, branches are loose. The reason is that branches are
updated frequently (every commit), and packing them would
require rewriting the packed file on every commit. Tags are
updated rarely (only at release), and packing them is a
one-time cost.
# The typical packing state
ls "$GIT_DIR/refs/heads"
# feature/iam-rotation
# main
ls "$GIT_DIR/refs/tags"
# (empty — tags are in packed-refs)
grep ' refs/tags/' "$GIT_DIR/packed-refs"
# 4d2c8e0... refs/tags/v1.0.0
# 9f3c1d7... refs/tags/v1.1.0
The script reads the repository’s packing state and confirms the expected pattern: branches as loose files, tags as packed lines. This is a useful diagnostic: a repository where every ref is loose is a repository that has not been packed or that has accumulated many recently-moved branches.
The reflog records every ref change
The reflog is a per-clone log of every ref change. Every commit
on a branch advances the branch’s reflog entry; every checkout
that moves HEAD advances HEAD’s reflog entry; every git tag -f advances the tag’s reflog entry. The reflog is local to
each clone; it is not synced between clones and is not pushed
to remotes.
# Show HEAD's reflog
git reflog
# 9a8b7c6 HEAD@{0}: commit: bump terraform module to v1.4.0
# 4d2c8e0 HEAD@{1}: commit: update iam policy
# 8a3f9d2 HEAD@{2}: checkout: moving from main to feature/iam-rotation
# 6f4e5a6 HEAD@{3}: commit (initial): initial commit
Each line is <oid> <ref>@{<index>}: <command>: <subject>.
The HEAD@{0} is the most recent state; higher indices are
older. The subject is the commit subject or the command that
caused the change.
# Show a specific ref's reflog
git reflog refs/heads/main
# 9a8b7c6 refs/heads/main@{0}: commit: bump terraform module to v1.0.0
# 4d2c8e0 refs/heads/main@{1}: commit: update iam policy
# Show the reflog with a date range
git reflog --since='2 weeks ago'
# 9a8b7c6 HEAD@{0}: commit: bump terraform module to v1.4.0
The first command shows the reflog for the main branch
specifically. The reflog is per-ref: HEAD has its own reflog
under .git/logs/HEAD, and each branch has its own reflog
under .git/logs/refs/heads/<branch>.
flowchart LR
H["HEAD@{0} = 9a8b7c6"] --> C1["commit 9a8b7c6"]
H1["HEAD@{1} = 4d2c8e0"] --> C2["commit 4d2c8e0"]
H2["HEAD@{2} = 8a3f9d2"] --> C3["commit 8a3f9d2"]
H --> H1
H1 --> H2
The diagram shows the reflog as a chain of states. Each state points at a commit. The chain is the history of what HEAD pointed at, not the history of the commits themselves. A commit can be in the reflog at multiple indices (if HEAD visited it more than once) and can be in the reflog of multiple refs (if multiple refs visited it).
Reflog retention and expiration
The reflog is pruned by git reflog expire (typically run
automatically by git gc). The retention is controlled by
two configuration options:
# How long reachable reflog entries are kept
git config gc.reflogExpire
# 90.days
# How long unreachable reflog entries are kept
git config gc.reflogExpireUnreachable
# 30.days
A reachable reflog entry is one that points at a commit still reachable from some ref. An unreachable entry is one that points at a commit no ref points at — typically a commit on a now-deleted branch or a commit on a detached HEAD that was never promoted to a branch.
# Force prune the reflog now
git reflog expire --expire=now --all
# Prune only unreachable entries
git reflog expire --expire-unreachable=now --all
# Override the retention for a specific clone
git -C /path/to/audit-clone config gc.reflogExpire never
git -C /path/to/audit-clone config gc.reflogExpireUnreachable never
The last command is the discipline for an audit clone: set
both expire options to never, and the reflog will retain
every entry indefinitely. The cost is disk space (the reflog
grows over time) and the risk of running out of disk if the
clone is left unattended. For a forensic clone, the trade-off
is worth it.
The reflog and garbage collection
Garbage collection (git gc) runs git reflog expire first
and then prunes the object store of any objects not reachable
from a ref or from a reflog entry. The choreography is:
# 1. Expire reflog entries older than the configured window
git reflog expire --all
# 2. Prune loose objects no ref or reflog entry points at
git prune
# 3. Pack remaining loose objects into packfiles
git pack-redundant --all # (handled internally by gc)
The result is a smaller repository: pruned commits are gone, pruned objects are gone, and the remaining objects are packed for efficient storage. The reflog’s role is to keep recently- lost commits reachable for the duration of the retention window.
# The cycle: a commit on a detached HEAD, then deletes, then gc
git checkout 8a3f9d2
git commit -m "scratch"
# scratch commit 9f3c1d7 is on detached HEAD
git checkout main
# scratch commit 9f3c1d7 is reachable only via HEAD@{1} in the reflog
git branch -D scratch
# scratch commit 9f3c1d7 is reachable only via the reflog
# 30 days later, gc.reflogExpireUnreachable triggers
git gc
# reflog entry pruned, scratch commit 9f3c1d7 is no longer reachable
# next gc --prune=now removes the commit from the object store
The example walks the lifecycle. The commit is recoverable during the reflog window; after the window, the reflog forgets it; after the next gc —prune, the object store forgets it. The window is the recovery path.
Anatomy of the reflog file
The reflog is stored as a plain-text file under .git/logs/:
ls "$GIT_DIR/logs"
# HEAD
# refs/heads/main
# refs/heads/feature/iam-rotation
# refs/tags/v1.0.0
The file format is one line per reflog entry:
<old-oid> <new-oid> <ref-name> <unix-timestamp> <timezone> <command> <subject>
tail -3 "$GIT_DIR/logs/HEAD"
# 4d2c8e0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6f 9a8b7c6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6b refs/heads/main 1730000000 +0000 commit: bump terraform module to v1.4.0
# 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e 4d2c8e0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6f refs/heads/main 1729000000 +0000 commit: update iam policy
# 6f4e5a6a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6c 8a3f9d2a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e refs/heads/main 1728000000 +0000 checkout: moving from main to feature/iam-rotation
Each line records the old and new OIDs, the ref name, the timestamp, and the action. The file is append-only between garbage collections; pruning removes lines from the top (oldest) as the retention window expires.
Production discipline
- Treat the reflog as a recovery tool, not a permanent record. Copy any SHA you care about into a tracking system or commit message before the reflog expires.
- Set
gc.reflogExpiretoneveron audit clones. The disk cost is small; the forensic benefit is large. - Run
git pack-refs --allperiodically in CI mirrors. Packed refs are faster to read on a cold start; a CI mirror that serves many concurrent fetches benefits from the one-file layout. - Audit the reflog for unexpected activity. A nightly
git reflog --allon a mirror reveals unexpected checkouts, deletions, and restorations. Diff the output against yesterday’s to detect anomalies.
Cross-course references
- Linux for Production Sysadmins - Part XX (BackupRecovery) uses the reflog as the analogue of a filesystem’s redo log: both are local-only recovery mechanisms that bridge the gap between the moment of a change and the moment of a deliberate commit (or a deliberate backup).
- Ansible for Production Sysadmins - Part XXXVIII (Review) describes the reflog as the answer to “what did the developer do before the merge?” — the reflog is the per-developer audit trail that supplements the shared git history.
- GitOps with Argo CD - Part IV (SyncPhases) sets the
reflog retention to
neveron the GitOps controller’s clone so that the controller has a record of every commit it has ever synced to, regardless of how long ago the sync ran.
Quiz
Knowledge check · 4 questions
Q1. What is the relationship between packed refs and loose refs?
Q2. The reflog is shared between clones and pushed to remotes as part of `git push`.
Q3. What are the two reflog retention settings, and what is the recommended value for an audit clone?
Q4. Diagnose a recovery scenario and recommend the discipline for forensic clones.
A junior engineer force-pushed a feature branch, overwriting the team's three-month work on the branch. The team realises 40 days later. The original commits were never on any other branch (the feature was never merged). The team's policy is to recover from the reflog if a reflog entry exists.
Passing score: 75%. Answers are checked in this browser.