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}'
What squash merging removesThe branch had eight commits, each a step with its own message and diff. The squash replaces them with one commit on the default branch, so bisection can identify the merge but not which of the eight steps introduced the problem.eight steps become one commitfeature branchC1C2C3C8mainSthe eight commits still exist until the branch is deleted and the objects are collected

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
Recovering granularity after a squashThe squash names the pull request. The pull request's ref still holds the original commits. Confirming the tree matches proves the recovered branch is what was squashed, and bisecting inside it gives the specific step.Squash commitnames the PRFetch pr reforiginal commitsTrees matchsame contentBisect insideone step identifiedthe third box is what makes the fourth trustworthy

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.

Choosing the recovery routeIf the pull request ref survives, bisect the original commits and get an exact answer. If only the local reflog has the branch, use that. If nothing survives, narrow within the squash by path and then by hunk.What still exists besides the squash?the pull request refBisect the branchexact, fasta local reflog entryBisect from the reflogone machine onlynothingNarrow by pathlinear, still effectivethe middle option depends on whoever ran the merge still having their clone

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.