← All runbooks in Git, CI/CD & GitOps
Runbook: Recover from a Bad `git rebase`
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 clone with the pre-rebase reflog still present
- Reviewer who can confirm the intended new base for the replayed commits
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 locateHEAD@{<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.
- 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)andNEW_TIP=$(git rev-parse HEAD) - 2Inspect the diff between the two tips to see what the bad rebase actually did:
git diff "$OLD_TIP" "$NEW_TIP" --stat | tail -50andgit log --oneline "$NEW_TIP..$OLD_TIP" | head -20(the commits the rebase lost) - 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) - 4Verify the lost commits are reachable:
git cat-file -t "$OLD_TIP"(expectcommit) andgit log --format=%h %s "$NEW_TIP..$OLD_TIP" -n 30 - 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" - 6If
git rebase --ontois 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 - 7For each conflict that the replay surfaces, decide intentionally:
git diff --cachedshows the auto-resolution; if it is wrong,git checkout --conflict=merge <file>reverts the file to the conflict state and resolve by hand - 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 - 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 30andgit log --format=%H "%H %s" --no-merges "$NEW_TIP..recovery/$OLD_TIP" | while read SHA SUBJ; do git show --stat "$SHA" | head -3; done - 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--forcewithout--force-with-lease - 11If the branch was protected (e.g.
main), do not rewrite. Open a revert PR instead:git revert -m 1 <merge-sha>is for merge commits; for a rebase,git revert <old-tip-sha>..<new-tip-sha>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"returnscommit - ✓
git log --oneline "main..recovery/$OLD_TIP" -n 30contains every SHA fromgit log --oneline "$NEW_TIP..$OLD_TIP" - ✓
git diff "main..recovery/$OLD_TIP" --statmatches 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 cangit pullcleanly - ✓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) orgit 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-leaseand notify them to re-fetch and reset - ↶If
$OLD_TIPisbad objectand 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 finishednot matchingrebase started - · The bad rebase was followed by
git push --forcetomainor another protected branch: this is a force-push incident, seegit-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
~/.gitconfigandgitattributes— the bad rebase may have followed a.gitattributesfilter 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 <newbase> 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
$ 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
$ 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 -20The 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
$ # 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
$ 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 30git rebase --onto <newbase> <oldbase> <branch> replays the commits in
<oldbase>..<branch> (excluding <oldbase>, including <branch>) onto
<newbase>. 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
$ 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
$ 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")"
donecomm -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
$ 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 <newbase>. 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/<repo>
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.