Skip to main content
RunBook Academy

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

Intermediate⏱ ~19 mingit

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

Not yet marked complete on this device.

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

  1. 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.
  2. Set gc.reflogExpire to never on audit clones. The disk cost is small; the forensic benefit is large.
  3. Run git pack-refs --all periodically 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.
  4. Audit the reflog for unexpected activity. A nightly git reflog --all on 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 never on 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

  1. Q1. What is the relationship between packed refs and loose refs?

  2. Q2. The reflog is shared between clones and pushed to remotes as part of `git push`.

  3. Q3. What are the two reflog retention settings, and what is the recommended value for an audit clone?

  4. 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.