Skip to main content
RunBook Academy

← All runbooks in Git, CI/CD & GitOps

high riskdata loss risk~45 min

Runbook: Recover from a Bad `git rebase`

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 the second mistake can be undone: git rev-parse HEAD | tee /tmp/pre-rebase-recovery.txt
  • · Confirm nothing has pruned the reflog since the bad rebase: git reflog expire --dry-run --all (dry-run only; do not actually expire)
  • · Read the reflog to find the pre-rebase SHA: git reflog -n 30 --date=iso | grep -E "rebase|checkout" and locate HEAD@{<time>}: rebase finished: refs/heads/<branch> onto <newbase> — the previous entry is the pre-rebase tip
  • · Capture the dropped commits as a bundle before attempting recovery so a future prune cannot lose them: git bundle create /tmp/lost-on-bad-rebase.bundle HEAD --all
  • · Identify the intended base SHA (the SHA the bad rebase was meant to land on): ask the change author, do not guess — the recovery re-base is into a SHA you trust, not the SHA the bad rebase landed on
  • · Stop other agents 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. 1Save the pre-rebase tip and the bad-rebase tip: OLD_TIP=$(git reflog -n 30 --date=iso | awk "/rebase finished/ {print \\$1}" | head -1) and NEW_TIP=$(git rev-parse HEAD)
  2. 2Inspect the diff between the two tips to see what the bad rebase actually did: git diff "$OLD_TIP" "$NEW_TIP" --stat | tail -50 and git log --oneline "$NEW_TIP..$OLD_TIP" | head -20 (the commits the rebase lost)
  3. 3Identify the intended base: INTENDED_BASE_SHA="<sha from change author or change ticket>" (do NOT use $NEW_TIP; that is the result of the bad rebase)
  4. 4Verify the lost commits are reachable: git cat-file -t "$OLD_TIP" (expect commit) and git log --format=%h %s "$NEW_TIP..$OLD_TIP" -n 30
  5. 5If the bad rebase auto-resolved conflicts as drop or squash, the "lost" commits may be empty. Replay onto the intended base and inspect the result before resolving conflicts: git checkout -b "recovery/$OLD_TIP" && git rebase --onto "$INTENDED_BASE_SHA" "$OLD_TIP"^ "$OLD_TIP"
  6. 6If git rebase --onto is awkward because the lost range spans multiple branches, list the lost commits first and cherry-pick one at a time: git log --format=%H "$NEW_TIP..$OLD_TIP" | tac | while read SHA; do git cherry-pick -x "$SHA"; done
  7. 7For each conflict that the replay surfaces, decide intentionally: git diff --cached shows the auto-resolution; if it is wrong, git checkout --conflict=merge &lt;file&gt; reverts the file to the conflict state and resolve by hand
  8. 8Verify every lost SHA is present in the recovery branch: git log --format=%H "recovery/$OLD_TIP" > /tmp/post-replay.txt && git log --format=%H "$NEW_TIP..$OLD_TIP" | sort > /tmp/pre-replay.txt && diff /tmp/pre-replay.txt <(awk "/recovery|onto/ {next} {print}" /tmp/post-replay.txt) && echo OK || echo investigate
  9. 9Inspect the replay for empty commits (the bad rebase may have collapsed a real change into a no-op): git log --oneline --diff-filter=M "$NEW_TIP..recovery/$OLD_TIP" -n 30 and git log --format=%H "%H %s" --no-merges "$NEW_TIP..recovery/$OLD_TIP" | while read SHA SUBJ; do git show --stat "$SHA" | head -3; done
  10. 10Decide the merge strategy: if the branch was never pushed, git reset --hard "recovery/$OLD_TIP" is clean. If pushed, fast-forward or merge — never --force without --force-with-lease
  11. 11If the branch was protected (e.g. main), do not rewrite. Open a revert PR instead: git revert -m 1 &lt;merge-sha&gt; is for merge commits; for a rebase, git revert &lt;old-tip-sha&gt;..&lt;new-tip-sha&gt; records the inverse of the bad replay as a new commit

4 · Verification

Confirm the procedure actually fixed the problem.

  • git cat-file -t "$OLD_TIP" returns commit
  • git log --oneline "main..recovery/$OLD_TIP" -n 30 contains every SHA from git log --oneline "$NEW_TIP..$OLD_TIP"
  • git diff "main..recovery/$OLD_TIP" --stat matches the pre-rebase expected diff (the diff between the intended base and the old tip)
  • No replayed commit is empty unless the change author confirms it should be: git log --format=%H --no-merges "$NEW_TIP..recovery/$OLD_TIP" | while read SHA; do lines=$(git show "$SHA" --stat | wc -l); [ "$lines" -lt 3 ] && echo "empty? $SHA"; done
  • If pushed: git ls-remote origin "$BRANCH_NAME" shows the recovery SHA, and a fresh clone can git pull cleanly
  • The change ticket records the bad-rebase SHA, the recovery SHA, and the reviewer who approved

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • If the replay pointed at the wrong base, abort the rebase: git rebase --abort (only works if a rebase is in progress) or git reset --hard "$(cat /tmp/pre-rebase-recovery.txt)"
  • If the replay produced empty or duplicated commits, redo the cherry-pick excluding them: git log --format=%H --no-merges "$NEW_TIP..recovery/$OLD_TIP" | tac | while read SHA; do git show "$SHA" --stat | head -1 | grep -q "|" || git rebase --skip; done
  • If the recovery was pushed and a consumer pulled it, push the original tip again with --force-with-lease and notify them to re-fetch and reset
  • If $OLD_TIP is bad object and no other clone holds it, escalate to incident response — CI workspaces, fork mirrors, or upstream reflog are the only remaining sources

6 · Escalation

When the runbook isn't enough, contact:

  • · Multiple agents are rebasing the same branch simultaneously: serialize via a coordination channel; rebase collisions on a shared branch are the most common cause of rebase finished not matching rebase started
  • · The bad rebase was followed by git push --force to main or another protected branch: this is a force-push incident, see git-cicd-gitops-rb-06-respond-to-force-push-incident
  • · The replay surfaced conflicts that were auto-resolved by the original rebase: review the auto-resolution rules in ~/.gitconfig and gitattributes — the bad rebase may have followed a .gitattributes filter the team did not intend to apply
  • · No clone, CI workspace, or fork mirror holds the pre-rebase SHA: data is lost from the repository graph, escalate to security/IR if the lost commits may have contained sensitive data

A bad rebase is a rebase that did not land what it was supposed to. The typical symptoms are: dropped commits (squash/drop in interactive rebase selected by mistake), duplicated commits (replay onto the wrong base), conflicts auto-resolved by a gitattributes filter the author did not intend, or a git rebase --onto whose &lt;newbase&gt; argument pointed at the branch’s own tip instead of the upstream base. In every case the commits the rebase lost are still reachable from the reflog of the clone that ran the rebase, until the unreachable entry expires.

The recovery is “replay onto the base the bad rebase was supposed to use”, not “rewind to the pre-rebase tip”. The latter rewinds the user’s branch but it does not fix the bad rebase; it just walks it back.

1. Locate the pre-rebase and post-rebase tips

Read-only / Safe
$ git reflog -n 30 --date=iso | grep -E 'rebase|checkout' | head -20
echo '---'
OLD_TIP=$(git reflog -n 30 --date=iso | awk '/rebase finished/ {print $1}' | head -1)
NEW_TIP=$(git rev-parse HEAD)
echo "pre-rebase tip: $OLD_TIP"
echo "post-rebase tip: $NEW_TIP"
git cat-file -t "$OLD_TIP" || echo 'pre-rebase tip not reachable'

The pre-rebase tip is the SHA on the reflog line just before the rebase finished entry. The post-rebase tip is whatever HEAD points at now. If git cat-file -t returns bad object, the reflog has already been pruned past the bad rebase — see rollback for that case.

2. See what the bad rebase actually did

Read-only / Safe
$ echo '--- commits the rebase lost ---'
git log --oneline "$NEW_TIP..$OLD_TIP" -n 30
echo '--- file-level diff ---'
git diff "$NEW_TIP" "$OLD_TIP" --stat | tail -40
echo '--- empty commits the rebase produced (squash candidates) ---'
git log --oneline "$OLD_TIP..$NEW_TIP" --no-merges --diff-filter=M | head -20

The diff between $NEW_TIP and $OLD_TIP is what the bad rebase changed in your tree. The log of $NEW_TIP..$OLD_TIP is what it removed. If the lost commits are empty in git show, the bad rebase silently dropped the real work; do not “recover” by replaying empty commits.

3. Identify the intended base

Read-only / Safe
$ # Do NOT guess. Ask the change author or read the change ticket.
INTENDED_BASE_SHA="<from change author or ticket>"
git cat-file -t "$INTENDED_BASE_SHA"
echo '--- and confirm it is reachable from main ---'
git merge-base --is-ancestor "$INTENDED_BASE_SHA" origin/main && echo OK || echo "intended base not on main - escalate"

--onto takes any SHA; passing the wrong one is the most expensive way to discover a typo. Verify the intended base is actually an ancestor of the intended upstream before you replay onto it.

4. Replay the lost range with rebase --onto

Read-only / Safe
$ git checkout -b "recovery/$OLD_TIP" "$OLD_TIP"
git rebase --onto "$INTENDED_BASE_SHA" "$OLD_TIP"^ "$OLD_TIP"
echo '--- replay produced these commits ---'
git log --oneline "$INTENDED_BASE_SHA..recovery/$OLD_TIP" -n 30

git rebase --onto &lt;newbase&gt; &lt;oldbase&gt; &lt;branch&gt; replays the commits in &lt;oldbase&gt;..&lt;branch&gt; (excluding &lt;oldbase&gt;, including &lt;branch&gt;) onto &lt;newbase&gt;. This is the canonical recovery when the bad rebase landed on the wrong base. If a conflict stops the rebase, resolve intentionally — do not auto-resolve.

5. If --onto is awkward, cherry-pick by hand

Read-only / Safe
$ git log --format=%H "$NEW_TIP..$OLD_TIP" | tac > /tmp/lost-shas.txt
wc -l /tmp/lost-shas.txt
git checkout "$BRANCH_NAME"
while read SHA; do
if ! git cherry-pick -x "$SHA"; then
  echo "conflict on $SHA - resolve by hand, then git cherry-pick --continue"
  break
fi
done < /tmp/lost-shas.txt
git log --oneline -n $(($(wc -l < /tmp/lost-shas.txt) + 1))

TAC (tac) ensures oldest-first replay, which matches the original order on the branch. cherry-pick -x records (cherry picked from commit ...) in the message so the audit trail can trace the recovery back to the discarded SHA.

6. Verify nothing was lost

Read-only / Safe
$ echo '--- pre-rebase SHAs that should now be in the replayed branch ---'
git log --format=%H "$NEW_TIP..$OLD_TIP" | sort > /tmp/pre-rebase-shas.txt
echo '--- SHAs actually in the replayed branch ---'
git log --format=%H "recovery/$OLD_TIP" | sort > /tmp/post-replay-shas.txt
echo '--- diff ---'
comm -23 /tmp/pre-rebase-shas.txt /tmp/post-replay-shas.txt
echo '--- replayed commits that are empty (lost content?) ---'
git log --format=%H --no-merges "$NEW_TIP..recovery/$OLD_TIP" | while read SHA; do
changes=$(git show --stat "$SHA" | tail -1 | grep -oE '[0-9]+ files? changed' | grep -oE '[0-9]+')
[ "${changes:-0}" = "0" ] && echo "empty: $SHA $(git log -1 --format=%s "$SHA")"
done

comm -23 lists SHAs that were in the pre-rebase range but not in the recovery. If the list is non-empty, those commits were not replayed — go back to step 5 and cherry-pick them explicitly. The “empty” scan catches commits where the conflict resolution silently produced a no-op.

7. Land the recovery

Read-only / Safe
$ echo '--- if branch was never pushed ---'
git reset --hard "recovery/$OLD_TIP"
echo '--- if branch was pushed and others have pulled ---'
git merge --ff-only "recovery/$OLD_TIP" || echo "non-FF - merge, do not force"
echo '--- if branch was protected ---'
# Open a revert PR with the inverse of the bad replay
git revert --no-commit "$OLD_TIP"
git revert --continue || git commit -m "revert: undo bad rebase $NEW_TIP onto $INTENDED_BASE_SHA"

Verification

git log --oneline "main..recovery/$OLD_TIP" -n 30 contains every SHA from git log --oneline "$NEW_TIP..$OLD_TIP". The pre-rebase diff is preserved: git diff "main..$OLD_TIP" --stat matches git diff "main..recovery/$OLD_TIP" --stat. No replayed commit is empty unless the change author confirms it should be. If the recovery was pushed, a fresh clone pulls cleanly and the branch’s last-deployed SHA in CI matches the recovery SHA.

Rollback

If the replay pointed at the wrong base, git reset --hard "$(cat /tmp/pre-rebase-recovery.txt)" returns the branch to where the bad rebase landed it. The reflog still holds $OLD_TIP, so retry with a different &lt;newbase&gt;. If cherry-picks produced duplicated or empty commits, redo step 5 with --skip and document each skipped SHA in the change ticket.

If $OLD_TIP is bad object and no other clone holds it, the pre-rebase commits are gone from this clone. CI workspaces (/home/runner/work/&lt;repo&gt; on GitHub Actions, $CUSTOM_CI_WORKSPACE on self-hosted) and fork mirrors on the same forge still hold the SHAs only if they had the branch checked out before the rebase. Escalate before any further action.

References

  1. git-rebase(1) — including `--onto`
  2. git-reflog(1)
  3. git-cherry-pick(1)
  4. gitattributes(5) — rebase.autoSquash, rebase.autoStash, conflict filters
  5. Pro Git §10.3 — recovering from upstream rebase