Tracing a regression through a squashed history Jump to heading
Squash merging gives a clean default branch and takes away bisection granularity. When a search identifies a squash commit containing four weeks of work across sixty files, the answer is technically correct and practically useless — you know the branch, not the change. The good news is that the original commits usually still exist somewhere, and where they do not there are ways to narrow within the squash. This recipe covers both, within bisect and history forensics.
When to use this approach Jump to heading
- A bisect identified a squash commit spanning many changes.
- The default branch is a series of squash merges with no intermediate detail.
- You need to know which specific change caused a regression, not which branch.
- The squashed branch was merged recently enough that traces may survive.
- If the squash contains three small commits, read the diff — this procedure is for the large ones.
Step 1 — Establish what the squash actually contains Jump to heading
squash=$(git rev-parse HEAD)
git show --stat "$squash" | head -20
git show --format='%B' -s "$squash" # The pull request number is usually in the subject, which is the thread to pull
git log -1 --format='%s' "$squash" | grep -oE '#[0-9]+' # Verification: how much is in here?
git show --numstat --format='' "$squash" | awk '{a+=$1; d+=$2; n++} END {
printf "%d files, %d insertions, %d deletions\n", n, a, d}' Step 2 — Recover the original commits from the forge Jump to heading
The branch’s commits are usually still reachable through refs the forge keeps.
# Pull request refs survive branch deletion on most forges
git fetch origin '+refs/pull/812/head:refs/remotes/origin/pr/812'
git log --oneline origin/pr/812 | head -10 # The merge base tells you where the branch started
base=$(git merge-base origin/pr/812 "$squash^")
git rev-list --count "$base"..origin/pr/812 # Verification: the branch's tip tree should match the squash's tree
git rev-parse 'origin/pr/812^{tree}' "$squash^{tree}" Identical trees confirm the recovered branch is exactly what was squashed, which makes bisecting it equivalent to bisecting the original work.
Step 3 — Bisect inside the recovered branch Jump to heading
git worktree add --detach ../app-bisect origin/pr/812 && cd ../app-bisect
git bisect start origin/pr/812 "$base"
git bisect run sh ../app/bisect-test.sh # The result is now a specific step, with its own message
git show --stat "$(git rev-parse HEAD)"
git log -1 --format='%s%n%n%b' "$(git rev-parse HEAD)" # Verification: confirm at the commit and its parent
sh ../app/bisect-test.sh || echo "bad, as expected"
git switch --detach HEAD~1 && sh ../app/bisect-test.sh && echo "parent good"
git bisect reset; cd ../app && git worktree remove ../app-bisect Step 4 — When nothing survives, narrow by path Jump to heading
If the branch and its refs are gone, the squash is all there is — but it can still be split.
# Which paths in the squash could plausibly affect the symptom?
git show --name-only --format='' "$squash" | grep -E 'payments|refund' # Apply the squash to its parent one path at a time, testing each
git switch --detach "$squash^"
for path in $(git show --name-only --format='' "$squash"); do
git checkout "$squash" -- "$path"
if ! sh bisect-test.sh >/dev/null 2>&1; then
echo "regression appears after applying: $path"
break
fi
done # Verification: reverting that path alone should restore correct behaviour
git checkout "$squash^" -- "$path" && sh bisect-test.sh && echo "confirmed: $path" This is a linear search rather than a binary one, but over sixty paths it is still fast — and it can be made binary by applying half the paths at a time when the count is large.
Step 5 — Narrow further inside a single file Jump to heading
# Interactively revert hunks until the symptom disappears
git checkout "$squash" -- src/payments/refund.ts
git checkout -p "$squash^" -- src/payments/refund.ts # revert hunks selectively
sh bisect-test.sh && echo "the reverted hunk is the cause" # Or inspect how one function changed in the squash
git log -L :clampRefundWindow:src/payments/refund.ts --oneline -n 3 # Verification: restore the working tree when finished
git checkout "$squash" -- . && git switch - SAFETY WARNING — this procedure leaves the working tree in a mixture of two commits’ content, which is fine for investigation and dangerous to commit. Work in a detached worktree created for the purpose, and remove it afterwards. A partial checkout accidentally committed to a branch produces a state that never existed and that nobody can reason about later.
The local reflog route is worth remembering: whoever merged the branch has origin/feature@{n} entries pointing at the pre-squash commits for as long as reflog expiry allows, usually ninety days — the recovery techniques are in recovering lost commits with git reflog.
Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Should we stop squash merging because of this? Jump to heading
It is a genuine trade rather than a mistake. Squashing gives a readable default branch where every commit is a complete change, which makes most bisections easier, not harder — the cost appears only when the squash is large. The better response is to keep squashed changes small, which is worth doing for review reasons anyway.
How long do pull request refs survive? Jump to heading
Indefinitely on most forges, including after the branch is deleted, which makes them the most reliable recovery route. They are not fetched by default, which is why the explicit refspec in Step 2 is needed — adding it permanently to the clone’s configuration costs nothing.
What about a rebase-merge rather than a squash? Jump to heading
Rebase merging preserves the individual commits on the default branch, so bisection works normally and none of this is needed. That is its main advantage over squashing, and the trade-off against a noisier history is laid out in squash vs merge vs rebase decision matrix.
Related Jump to heading
- Bisect & History Forensics — the parent topic and the limits of bisection.
- Automating git bisect With a Test Script — the search this recipe recovers granularity for.
- Recovering Lost Commits With git reflog — the other route to pre-squash commits.