← All runbooks in Git, CI/CD & GitOps
Runbook: Recover from a Bad `git reset`
1 · Prerequisites
Confirm every item is in place before any state change.
- Refs and the refs namespace — refs/heads, refs/tags, refs/remotes
- Reverting a merge commit — `git revert -m 1 <merge>` and the mainline parent
- Working tree in a state that can be examined (do not run
git gc --prune=nowbefore recovery) - Reviewer who knows what the discarded commits contained
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · Capture the current HEAD so a second mistake can be undone:
git rev-parse HEAD | tee /tmp/pre-reset-recovery.txt - · Confirm nothing has run
git gc --prune=nowsince the bad reset: reflog unreachable entries expire after 30 days by default, andgc --prune=nowforces expiry now - · Confirm the repository is not shallow:
test ! -f .git/shallow || echo shallow-clone-reflog-pruned - · Read the reflog of HEAD to identify the pre-reset SHA:
git reflog -n 30 --date=iso - · Capture the discarded commits as a bundle, even before attempting recovery, so a future prune cannot lose them:
git bundle create /tmp/lost-commits.bundle $(git reflog | awk "/reset/ {print \\$1}" | head -1)is unsafe; dogit bundle create /tmp/lost-commits.bundle HEAD --allinstead for safety - · Stop anyone else from pushing or rebasing the affected branch while you work
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Find the SHA before the bad reset in the reflog:
git reflog -n 30 --date=iso | grep -E "reset|checkout"and locate the lineHEAD@{<time>}: reset: moving to <ref>"— the SHA on the previous line is the discarded tip - 2Save the discarded SHA explicitly:
LOST_SHA=$(git reflog -n 30 --date=iso | awk "/reset: moving/ {print \\$1}" | head -1)(the most recent reset entry is the one that discarded the work) - 3Inspect what the discarded commits were before deciding the strategy:
git show --stat "$LOST_SHA"andgit log --oneline "HEAD..$LOST_SHA" | head -20to see the discard range - 4Confirm the discarded SHA is reachable:
git cat-file -t "$LOST_SHA"(expectcommit). If you getbad object, the reflog OID is gone — try the next reflog entry or escalate - 5Decide the recovery strategy: if the branch was local-only and nothing was pushed,
git reset --hard "$LOST_SHA"is the simplest; if the branch was pushed or has a shared base, cherry-pick is safer - 6For a local-only recovery:
git reset --hard "$LOST_SHA"and verifygit log --oneline -n 5matches the expected state - 7For a shared branch: create a recovery branch and cherry-pick the discard range onto the current HEAD one commit at a time so you can stop on conflicts:
git checkout -b recovery/"$LOST_SHA" && git cherry-pick "HEAD..$LOST_SHA" - 8If a cherry-pick stops on a conflict, the conflict is the work the bad reset actually discarded — that is the evidence; resolve intentionally, do not auto-resolve
- 9Verify the recovered branch tip matches the discarded tip content:
git diff "$LOST_SHA" "recovery/$LOST_SHA" --statshould be empty - 10If the branch was pushed, do not push the recovery over the old tip with
--force; merge or fast-forward only. If the old tip is genuinely wrong, push with--force-with-leaseafter notifying everyone who has pulled - 11Open or update the change ticket so the bad reset is recorded in the audit trail
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓
git cat-file -t "$LOST_SHA"returnscommit - ✓
git log --oneline "main..$LOST_SHA" -n 20lists the same commits that were lost - ✓
git diff "main..$LOST_SHA" --statmatches the pre-reset diff - ✓For shared branches:
git log --oneline "recovery/$LOST_SHA" -n 20contains every SHA fromgit log --oneline "HEAD..$LOST_SHA" - ✓No untracked or uncommitted changes from the bad reset remain in the working tree:
git status --porcelainis clean after recovery - ✓If the recovery was pushed:
git ls-remote origin "$BRANCH_NAME"shows the recovered SHA, and consumers can pull cleanly
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶If the recovery reset the branch to the wrong tip, undo it:
git reset --hard "$(cat /tmp/pre-reset-recovery.txt)" - ↶If cherry-picks produced a partial state with conflicts, abort the cherry-pick:
git cherry-pick --abortand restart with a different strategy - ↶If the recovery was pushed with
--force-with-leaseand a consumer already pulled the bad version, push the previous tip again and notify them to re-fetch and reset - ↶If
git cat-file -t "$LOST_SHA"returnsbad objectand no reflog entry is recoverable, the commits are gone from this clone — escalate to incident response, check CI workspaces and fork mirrors for backups
6 · Escalation
When the runbook isn't enough, contact:
- · Reflog has no entry older than the bad reset AND no other clone holds the discarded SHA: data is lost. Escalate immediately, do not delete the working tree or run
git gc - · The discarded commits contain secrets that were intended to be removed (e.g. an accidentally-pushed key): do not just recover them — the secret rotation runbook (
git-cicd-gitops-rb-07-respond-to-secret-committed) takes precedence - · Multiple agents are recovering simultaneously: serialize via a coordination channel, the second recovery will likely overwrite the first
- · The bad reset was followed by
git push --forceto a protected branch: this is a force-push incident, seegit-cicd-gitops-rb-06-respond-to-force-push-incident
A git reset --hard does not delete commits; it moves a ref and updates the
working tree. The discarded commits become unreachable but stay in the
object database until they are pruned. The reflog is the index from the
ref’s perspective, and an unreachable commit’s last entry in the reflog
survives up to gc.reflogExpireUnreachable (30 days default). After that
window — or after git gc --prune=now — the commits are gone for good.
The mistake is rarely the reset. It is git gc --prune=now immediately
after, which many “clean up” tutorials suggest. Do not run prune before
recovery.
1. Locate the discarded SHA
$ git reflog -n 30 --date=iso | head -30
echo '---'
git reflog -n 30 --date=iso | awk '/reset: moving/ {print $1}' | head -5
echo '---'
LOST_SHA=$(git reflog -n 30 --date=iso | awk '/reset: moving/ {print $1}' | head -1)
echo "discarded tip: $LOST_SHA"
git cat-file -t "$LOST_SHA" || echo 'not reachable - escalate'The most recent reset: moving entry in the reflog is the bad reset. The
SHA on the previous line is what the branch was at before the reset — that
is the work you want back.
2. Inspect the discard range
$ echo '--- what was discarded ---'
git log --oneline "HEAD..$LOST_SHA" -n 20
echo '--- diff vs current state ---'
git diff "HEAD..$LOST_SHA" --stat | tail -40
echo '--- commit subjects ---'
git log --format='%h %s%n%b%n---' "HEAD..$LOST_SHA" | head -60Read the discard range before deciding the recovery strategy. If the discard includes a commit you actually wanted to keep (e.g. a typo fix in the wrong commit), cherry-pick is the wrong tool — you need to surgically include just that one commit, not all of them.
3. Decide the strategy
$ echo '--- was the branch pushed? ---'
git config --get remote.origin.url
git fetch origin --prune
git ls-remote origin "$BRANCH_NAME" || echo 'no remote branch'
echo '--- do other clones hold the discarded SHA? ---'
git bundle verify /tmp/lost-commits.bundle 2>&1 || echo 'no bundle yet'If the branch was never pushed, the cleanest recovery is git reset --hard "$LOST_SHA". If it was pushed and other clones have pulled, cherry-pick onto
a recovery branch is safer — it does not require rewriting shared history.
4. Local-only recovery
$ git status --porcelain
git stash push -m "pre-recovery-snapshot" --include-untracked || true
git reset --hard "$LOST_SHA"
git log --oneline -n 10
echo '--- expected: tip matches LOST_SHA ---'
git rev-parse HEADThe stash is a safety net for any uncommitted work that was sitting on top of the bad reset.
5. Shared-branch recovery
$ git checkout -b "recovery/$LOST_SHA" "$LOST_SHA"
echo '--- the recovery branch points at the discarded tip ---'
git log --oneline -n 5
echo '--- cherry-pick onto the current branch one commit at a time ---'
git checkout "$BRANCH_NAME"
git cherry-pick -x "$(git log --format=%H "HEAD..recovery/$LOST_SHA" | tail -1)..recovery/$LOST_SHA"If a cherry-pick stops with a conflict, the conflict is not a bug — it is
the discarded work meeting the current state. Resolve by hand, git add
the resolved files, and git cherry-pick --continue.
6. Verify before pushing
$ echo '--- the discarded SHA is reachable from the recovery branch ---'
git merge-base --is-ancestor "$LOST_SHA" "recovery/$LOST_SHA" && echo OK || echo MISSING
echo '--- no commits lost in the discard range ---'
git log --format=%H "HEAD..recovery/$LOST_SHA" > /tmp/discarded-shas.txt
git log --format=%H "HEAD..$LOST_SHA" | sort > /tmp/pre-reset-shas.txt
diff /tmp/discarded-shas.txt /tmp/pre-reset-shas.txt && echo "no commits lost" || echo "investigate diff above"If the two files differ, the recovery did not bring back every discarded
commit. Either there were merge commits (use git cherry-pick -m 1) or one
of the cherry-picks silently resolved a conflict as a no-op.
Verification
git rev-parse "$BRANCH_NAME" (or recovery/$LOST_SHA) equals the recorded
$LOST_SHA. git log --oneline "main..$LOST_SHA" -n 20 lists every commit
that was lost. git diff "main..$LOST_SHA" --stat matches the pre-reset diff.
For a shared branch, every consumer can git pull without a forced merge.
The change ticket has the discard range, the recovery SHA, and the reviewer
who approved the recovery.
Rollback
If the recovery pointed at the wrong tip or silently dropped a commit, the
original HEAD is in /tmp/pre-reset-recovery.txt. git reset --hard "$(cat /tmp/pre-reset-recovery.txt)" returns to the state at the start of
the runbook, and the reflog still holds the discarded SHA — try the next
candidate.
If a consumer already pulled the recovery, push the original tip again with
--force-with-lease and notify them to re-fetch and reset. If the discarded
SHA cannot be reached at all, escalate — only CI workspace caches, fork
mirrors, or the upstream reflog may still hold it.