Skip to main content
RunBook Academy

Git, CI/CD & GitOpsXVII · ReflogExpiry

Reflog expiry and gc — git reflog expire, gc.reflogExpire, and the choreography with garbage collection

Advanced⏱ ~22 mingit

What you'll learn

  • Explain how git reflog expire prunes entries by age and reachability
  • Configure gc.reflogExpire and gc.reflogExpireUnreachable to tune retention per clone
  • Recognise the choreography between reflog expiry, git gc, and git gc --prune=<time>
  • Configure gc.pruneWorktrees for clones with multiple worktrees
  • Apply the audit-clone discipline (gc.reflogExpire never, core.logAllRefUpdates true)

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.

Reflog expiry is the silent killer of recovery attempts. The commit was reachable only via a reflog entry, the entry has expired, and the orphan is now a candidate for garbage collection. By the time the engineer returns to recover, the recovery path is closed. The discipline is to understand the expiry mechanics — git reflog expire, gc.reflogExpire, gc.reflogExpireUnreachable — and to configure them deliberately on clones that need long retention.

git reflog expire

git reflog expire prunes reflog entries according to a policy. The default policy is taken from the clone’s configuration (gc.reflogExpire for reachable entries, gc.reflogExpireUnreachable for unreachable entries), but the command accepts explicit overrides.

# Prune entries using the configured policy (run by git gc automatically)
git reflog expire --all

# Prune all entries immediately
git reflog expire --expire=now --all

# Prune only unreachable entries immediately
git reflog expire --expire-unreachable=now --all

# Override retention to 7 days for this run only
git reflog expire --expire=7.days --all

# Expire entries older than a specific date
git reflog expire --expire=2026-08-01.00:00:00 --all

# Expire a specific ref's entries
git reflog expire --expire=now refs/heads/feature/x

The --all flag applies the expiry to every ref’s reflog. Without it, only the entries that are already considered unreachable are pruned (the more conservative default). git gc always passes --all when it runs the expire step.

The two retention knobs

The retention policy has two knobs because reachability has two tiers:

  • Reachable entries name a commit that is reachable from some current ref. The default retention is 90 days (gc.reflogExpire=90.days).
  • Unreachable entries name a commit that is not reachable from any current ref — typically a commit on a deleted branch, a commit on a detached HEAD that was never promoted, or a commit orphaned by --hard reset or rebase. The default retention is 30 days (gc.reflogExpireUnreachable=30.days).
# Inspect the current policy
git config gc.reflogExpire
# 90.days
git config gc.reflogExpireUnreachable
# 30.days

# Tune the policy per clone
git config gc.reflogExpire 180.days
git config gc.reflogExpireUnreachable 60.days

# Disable expiry entirely (audit-clone discipline)
git -C /path/to/audit-clone config gc.reflogExpire never
git -C /path/to/audit-clone config gc.reflogExpireUnreachable never

The never value is a literal token Git recognises: it disables expiry for the affected scope. The cost is disk space (reflog entries accumulate indefinitely), and the benefit is the forensic answer to “what was every ref pointing at on date X?” for any date in the clone’s lifetime.

flowchart LR
    A["reflog entry"] --> B{"reachable?"}
    B -- yes --> C["retention: gc.reflogExpire\n(default 90 days)"]
    B -- no --> D["retention: gc.reflogExpireUnreachable\n(default 30 days)"]
    C --> E["expired -> prune candidate"]
    D --> E

The choreography with git gc

git gc runs three operations in sequence: reflog expire, prune, repack. The reflog expire step is what removes reflog entries; the prune step is what removes the commit objects those entries were the only path to.

git gc
# 1. git reflog expire --all (prunes expired reflog entries)
# 2. git prune --expire=<now> (removes objects with no path)
# 3. git repack (packs remaining loose objects into packfiles)

The choreography matters because each step has its own expiry policy. A typical chain:

  1. A commit X is on feature/x. The branch reflog has feature/x@{0}: commit: X.
  2. git branch -D feature/x deletes the branch. X is now reachable only via the branch reflog entry.
  3. git gc runs. The reflog expire step sees X as reachable (the reflog still names it) and applies the reachable retention (90 days). After 90 days, the entry expires and X becomes unreachable.
  4. The next git gc runs. The reflog expire step sees X as unreachable and applies the unreachable retention (30 days). After 30 more days, the entry expires again.
  5. The prune step removes X from the object store.

The total retention is 90 days (reachable) + 30 days (unreachable) = 120 days from the branch deletion. The audit-clone discipline (gc.reflogExpireUnreachable never) short-circuits this and retains the entry indefinitely.

git gc —prune=<time>

The --prune flag sets the prune step’s expiry to a specific time. Objects last referenced before that time and not reachable from any ref or reflog entry are removed.

# Aggressive: prune everything not currently reachable
git gc --prune=now

# Conservative: only prune objects unreachable for >14 days
git gc --prune=14.days

# Inspect what would be pruned without actually pruning
git gc --prune=now --dry-run

The default prune retention is 2 weeks (gc.pruneExpire, default 2.weeks.ago). The 2-week grace period gives a window in which an object that has been orphaned but is still in the prune-step grace period is recoverable via git fsck --unreachable. After the grace period, the prune step removes it.

gc.pruneWorktrees

gc.pruneWorktrees (default true) controls whether git gc removes worktree metadata for worktrees that no longer exist on disk. A worktree is the linked-checkout feature: git worktree add &lt;path&gt; &lt;branch&gt; creates a second working tree sharing the same .git directory.

git config gc.pruneWorktrees true
# git gc removes .git/worktrees/<name> for worktrees whose
# working directory no longer exists

The setting matters for clones with many short-lived worktrees (CI runners that create and destroy worktrees per build). With pruneWorktrees=true, the metadata is cleaned up automatically; with false, the metadata accumulates and the reflog of the orphaned worktree’s branch may survive longer than expected.

The audit-clone discipline

A clone intended for forensic work — an audit trail, a post-incident review, a regulatory archive — should be configured with indefinite retention:

git clone $REPO /path/to/audit-clone
cd /path/to/audit-clone

# Reflog retention
git config gc.reflogExpire never
git config gc.reflogExpireUnreachable never

# Tag reflog
git config core.logAllRefUpdates true

# Repack to consolidate (optional)
git gc --prune=now

The cost is disk space: the reflog grows over time, and the object store retains every commit ever made (because every commit is reachable from some reflog entry indefinitely). The benefit is the answer to “what was the state of HEAD on date X, and what was the OID of commit Y on that date?” for any date in the clone’s lifetime.

flowchart LR
    A["audit-clone"] --> B["gc.reflogExpire = never"]
    A --> C["gc.reflogExpireUnreachable = never"]
    A --> D["core.logAllRefUpdates = true"]
    A --> E["daily git reflog --all -> immutable log"]

The diagram shows the configuration of an audit clone. The immutable log (git reflog --all output, captured daily and archived) is the artefact that survives even if the clone itself is lost.

Production discipline

  1. Know your clone’s retention. git config gc.reflogExpire and gc.reflogExpireUnreachable reveal the policy.
  2. For audit clones, set both to never. The disk cost is small relative to the forensic benefit.
  3. Avoid git gc --prune=now on production clones. The command is useful for testing and for clones with no forensic value; it is destructive on clones whose reflog is the recovery path for past mistakes.
  4. Schedule git gc to run regularly but not aggressively. The default gc.auto (6700 loose objects) is a reasonable threshold; CI runners may want a more frequent schedule.
  5. Document the retention policy in the team runbook. A junior engineer who does not know the clone’s retention may push commits that are recoverable for 90 days but expects them to be recoverable forever.

Cross-course references

  • Git, CI/CD & GitOps — Part V (Branches, Refs and HEAD) — Part V lesson 6 introduced the relationship between reflog expiry and garbage collection at the foundations level. This lesson is the advanced follow-up on the choreography between git reflog expire, git gc, and git gc --prune=&lt;time&gt;.
  • Git, CI/CD & GitOps — Part XV (Reset) — the recovery window for a --hard reset is bounded by gc.reflogExpire and gc.reflogExpireUnreachable.
  • GitOps with Argo CD — Part IV (SyncPhases) — the GitOps controller configures gc.reflogExpire never on its clone so that every commit it has ever synced remains reachable by reflog entry, and runs git gc on a schedule that does not prune reflog entries.

Quiz

Knowledge check · 4 questions

  1. Q1. Which sequence does `git gc` run internally?

  2. Q2. Setting `gc.reflogExpireUnreachable never` on a clone prevents `git gc` from ever pruning unreachable reflog entries, which makes the commit objects those entries reference indefinitely recoverable.

  3. Q3. What is the audit-clone discipline, and which three configuration options does it set?

  4. Q4. Diagnose why a recovery attempt has failed and recommend the configuration changes that would have made it succeed.

    An engineer ran `git reset --hard HEAD~2` 60 days ago. Today, the engineer runs `git reflog` to recover the pre-reset OID and finds that the entry is no longer present. The clone's `gc.reflogExpire` is at the default value. The recovery recipe fails because the OID is gone from the reflog and the commit object is no longer reachable.

Passing score: 75%. Answers are checked in this browser.