Skip to main content
RunBook Academy

← All runbooks in Git, CI/CD & GitOps

critical riskdata loss risk~30 min

Runbook: Recover from a Bad `git reset`

1 · Prerequisites

Confirm every item is in place before any state change.

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=now since the bad reset: reflog unreachable entries expire after 30 days by default, and gc --prune=now forces 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; do git bundle create /tmp/lost-commits.bundle HEAD --all instead 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.

  1. 1Find the SHA before the bad reset in the reflog: git reflog -n 30 --date=iso | grep -E "reset|checkout" and locate the line HEAD@{<time>}: reset: moving to <ref>" — the SHA on the previous line is the discarded tip
  2. 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)
  3. 3Inspect what the discarded commits were before deciding the strategy: git show --stat "$LOST_SHA" and git log --oneline "HEAD..$LOST_SHA" | head -20 to see the discard range
  4. 4Confirm the discarded SHA is reachable: git cat-file -t "$LOST_SHA" (expect commit). If you get bad object, the reflog OID is gone — try the next reflog entry or escalate
  5. 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
  6. 6For a local-only recovery: git reset --hard "$LOST_SHA" and verify git log --oneline -n 5 matches the expected state
  7. 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"
  8. 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
  9. 9Verify the recovered branch tip matches the discarded tip content: git diff "$LOST_SHA" "recovery/$LOST_SHA" --stat should be empty
  10. 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-lease after notifying everyone who has pulled
  11. 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" returns commit
  • git log --oneline "main..$LOST_SHA" -n 20 lists the same commits that were lost
  • git diff "main..$LOST_SHA" --stat matches the pre-reset diff
  • For shared branches: git log --oneline "recovery/$LOST_SHA" -n 20 contains every SHA from git log --oneline "HEAD..$LOST_SHA"
  • No untracked or uncommitted changes from the bad reset remain in the working tree: git status --porcelain is 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 --abort and restart with a different strategy
  • If the recovery was pushed with --force-with-lease and 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" returns bad object and 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 --force to a protected branch: this is a force-push incident, see git-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

Read-only / Safe
$ 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

Read-only / Safe
$ 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 -60

Read 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

Read-only / Safe
$ 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

Read-only / Safe
$ 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 HEAD

The stash is a safety net for any uncommitted work that was sitting on top of the bad reset.

5. Shared-branch recovery

Read-only / Safe
$ 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

Read-only / Safe
$ 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.

References

  1. git-reflog(1)
  2. git-reset(1)
  3. git-cherry-pick(1)
  4. git-bundle(1) — packaging a history slice for offline recovery
  5. Pro Git §10.3 — recovering from upstream rebase or reset