Git, CI/CD & GitOpsXVII · ReflogRecovery
Recovering from a hard reset — the precise reflog recipe and the boundaries of recovery
What you'll learn
- Apply the four-step reflog recipe to recover the pre-reset state of a branch
- Identify what survives a hard reset (reflog entries, orphan commits) and what does not (the branch pointer, the relationship to upstream)
- Recognise the 90-day retention boundary and the conditions under which the recovery recipe fails
- Distinguish recovery from the reflog (local, time-limited) from recovery from a backup (durable)
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 mistaken git reset --hard is the canonical recovery
scenario for the reflog. The reset rewinds HEAD, resets the
index, and overwrites the working tree. From the engineer’s
perspective, the work is gone. From Git’s perspective, the
work is orphaned — the commits are still in the object
store, reachable only via reflog entries on the clones that
saw them. The recovery is mechanical: read the reflog, find
the pre-reset OID, reset back to it.
The four-step recipe
# Step 1: locate the pre-reset OID
git reflog -10
# a1b2c3d HEAD@{0}: reset: moving to HEAD~3 <-- the bad reset
# 7e8f9a0 HEAD@{1}: commit: terraform apply plan v3
# 1f2e3d4 HEAD@{2}: commit: bump ansible collection version
# Step 2: copy the OID somewhere durable (comment, ticket, chat)
PREVIOUS_OID=7e8f9a0
# Step 3: restore the branch tip and working tree
git reset --hard $PREVIOUS_OID
# HEAD is now at 7e8f9a0; working tree matches the pre-reset state
# Step 4: if the branch was pushed, push with --force-with-lease
git push --force-with-lease origin feature/iam-rotation
Step 1 is the diagnostic: the reflog lists every recent HEAD
update in reverse chronological order. The most recent entry
(HEAD@{0}) is the moment of the bad reset; the entry
immediately before it (HEAD@{1}) is the pre-reset tip. The
OID printed there is the recovery target.
Step 2 is the durability discipline: copy the OID into a place that survives the reflog’s retention window. The OID alone is sufficient — if the reflog expires before the engineer returns to verify the recovery, the OID still names the commit.
Step 3 is the recovery: git reset --hard <oid> rewinds
HEAD, resets the index, and overwrites the working tree to
match the commit at <oid>. The commit was unreachable after
the bad reset; it is reachable again after step 3.
Step 4 is the propagation: if the branch was pushed, the
remote still points at the post-reset OID. --force-with-lease
is the safe force-push (it verifies the remote has not been
updated since the local fetch); plain --force is the unsafe
form (it overwrites any concurrent update).
What survives and what does not
flowchart LR
A["previous tip (8a3f9d2)"] --> B["intermediate commits"]
B --> C["target commit (--hard target)"]
A -. "after --hard" .-> D["reflog entry: HEAD@{1}"]
B -. "after --hard" .-> E["orphan in object store"]
C --> F["current HEAD (rewound)"]
After --hard <target>:
- Survives: the reflog entry recording the pre-reset tip
(at
HEAD@{1}if no other operations have happened since). - Survives: the orphan commit objects in the object store
(
.git/objects/), reachable only via the reflog. - Survives: the working tree state at
<target>, which is the new “everything is fine” view. - Does not survive: the branch pointer at the pre-reset tip
(it has been rewritten to
<target>). - Does not survive: any commits between
<target>and the pre-reset tip from the branch’s perspective (they are no longer reachable from the branch). - Does not survive: uncommitted working tree edits that
were overwritten by
--hard.
The reflog entry is the bridge. As long as it exists, the recovery recipe is mechanical. Once it expires, the orphan commits become candidates for garbage collection and the recovery window closes.
The 90-day retention boundary
The recovery recipe works only while the reflog entry exists. The retention is configured by two settings:
git config gc.reflogExpire
# default: 90.days (reachable entries)
git config gc.reflogExpireUnreachable
# default: 30.days (unreachable entries)
After a --hard reset, the pre-reset tip is reachable (it
is named by the reflog entry) until the reflog entry expires,
then unreachable once the reflog forgets it. The 90-day
default applies to the reachable period; the 30-day default
applies if the reflog has already expired and the entry is
considered unreachable.
Past the retention window, the orphan commits may be pruned
by git gc --prune=now. The recovery recipe fails because
the reflog has no entry to read and the object store has no
object to resolve.
# Diagnostic: how much time is left on the reflog entry?
git reflog --expire=never HEAD@{1}
# if this errors, the entry has already expired
Recovery when the reflog has expired
If the reflog entry has expired, the recovery path is:
- Other clones. Any clone that fetched the branch before
the reset may still have the OID in its reflog. The
recovery is a fresh clone or a
git fetchfollowed by reflog inspection. - Object store scan.
git fsck --unreachable --no-reflogslists objects that have no path from any ref or reflog. Any commit the fsck reports is recoverable:git branch recovered <oid>makes it reachable again. - CI artefacts and remote refs. The commit may be referenced by an older CI artifact, a release branch, a backup tag, or a pull-request ref on the remote. Search the remote for any ref that points at or near the OID.
- Backup. If the working tree before the reset was committed to a backup (CI artifact store, container image, configuration backup), the bytes are recoverable from the backup regardless of Git’s state.
The order of preference is 1, 2, 3, 4 — reflog first because it is the most local and the fastest; backup last because it is the slowest and the most expensive.
Production discipline
- Verify the working tree is clean before
--hard. A clean working tree means--hardwill discard nothing important. An unclean working tree means--hardwill destroy uncommitted edits; the right move is to commit or stash first. - Run
git reflogimmediately after any--hardreset. Copy the pre-reset OID into a ticket or commit message so the recovery is durable even if the reflog expires. - Prefer
--force-with-leaseover--forcewhen propagating the recovery. The lease check prevents overwriting a concurrent push from a collaborator. - Document the recovery in the team’s runbook. The four-step recipe should be a one-page runbook entry that any engineer can follow without re-deriving it.
Cross-course references
- Git, CI/CD & GitOps — Part XV (Reset) — the lesson on
--hard(xv-04) introduced the recovery path via the reflog. This lesson is the advanced follow-up: the precise recipe and the boundaries of recovery. - Git, CI/CD & GitOps — Part XIV (Revert) — the additive
alternative on shared branches. A
--hardreset on a shared branch should have been agit revertto begin with; the recovery recipe here is the fix, not the recommended workflow. - GitOps with Argo CD — Part VI (MergeStrategies) — the
GitOps controller’s recovery from a bad sync uses the same
four-step pattern against the controller’s clone, which is
configured with
gc.reflogExpire never.
Quiz
Knowledge check · 4 questions
Q1. An engineer has just run `git reset --hard HEAD~3` and realised it was the wrong reset. The branch has been pushed to a shared remote. What is the precise sequence of commands to recover?
Q2. After a `git reset --hard <target>` on a clone, the pre-reset tip OID is recoverable from the clone's reflog for the 90-day default retention window even if the branch was pushed to a remote.
Q3. What four sources can be consulted to recover a pre-reset OID after the reflog has expired, in order of preference?
Q4. Walk the four-step reflog recovery for an engineer who has just run a mistaken `--hard` reset, including the push step and the verification.
An engineer on `feature/iam-rotation` ran `git reset --hard HEAD~2` thinking the most recent commit was the one to undo, but the actual undo target was a different commit earlier in the branch. The branch was pushed yesterday and is the source of an open pull request. The engineer needs to recover the two undone commits.
Passing score: 75%. Answers are checked in this browser.