Bisect & History Forensics Jump to heading

When something used to work and now does not, the question β€œwhich change caused this?” has an exact answer that Git can find mechanically. Most teams instead reason about it β€” reading recent commits, forming theories, testing them β€” which works badly once the range is more than a handful of commits and fails entirely when the culprit is three weeks old and unrelated to the symptom. Bisection turns the search into a logarithmic sweep, and the surrounding forensic tools answer the follow-up questions about provenance. This part of Conflict Resolution & Safe Merge Operations covers both.

Prerequisites Jump to heading

Bisection halves the candidate range with each test. A thousand commits take ten tests, ten thousand take fourteen. The cost is dominated by how long one test takes, which is why the first useful move is almost always to make the reproduction faster rather than to narrow the range by intuition.

Tests needed to find one commit in a rangeManual bisection by intuition scales linearly with the range and stops being feasible past a few dozen commits. Binary search scales with the logarithm, so a range of ten thousand commits costs only four more tests than a range of a thousand.tests needed to locate the culprit100 commits, linear50100 commits, bisect71000 commits, bisect1010000 commits, bisect14the cost is the test, not the range β€” which is where optimisation belongs

Step 1 β€” Establish the Endpoints Precisely Jump to heading

A wrong endpoint invalidates the whole search, and the usual error is assuming a release tag was good without checking.

# Verify the reproduction fails now
npm test -- --testNamePattern 'refund window' 2>&1 | tail -3; echo "exit: $?"
# And that it passes at the candidate good commit
git switch --detach v2.6.0
npm ci --silent && npm test -- --testNamePattern 'refund window' >/dev/null 2>&1 \
  && echo "good endpoint confirmed" || echo "NOT good β€” go further back"
# Verification: how large is the range?
git rev-list --count v2.6.0..HEAD

Step 2 β€” Automate the Test Jump to heading

A manual bisect over fourteen steps is fourteen opportunities to mark a commit wrongly. A script removes that.

#!/usr/bin/env sh
# bisect-test.sh β€” exit 0 for good, 1 for bad, 125 to skip this commit.
set -eu
npm ci --silent >/dev/null 2>&1 || exit 125      # cannot build: skip, do not judge
npm test -- --testNamePattern 'refund window' >/dev/null 2>&1
git bisect start HEAD v2.6.0
git bisect run sh bisect-test.sh
# Verification: the result names one commit
git bisect log | tail -3
git bisect reset

Exit code 125 is the one people miss: it tells Git this commit cannot be judged β€” a broken build, a missing dependency β€” so it is skipped rather than being treated as bad. Without it, an unrelated build failure sends the search down the wrong half. The full treatment is in automating git bisect with a test script.

Step 3 β€” Bisect Without Disturbing Your Working Copy Jump to heading

Bisection moves HEAD repeatedly, which is disruptive if you are in the middle of something.

git worktree add --detach ../app-bisect HEAD
cd ../app-bisect
git bisect start HEAD v2.6.0
git bisect run sh ../app/bisect-test.sh
# Verification: your original worktree is untouched
git -C ../app status --short | head -3
# Clean up afterwards
git bisect reset && cd ../app && git worktree remove ../app-bisect
A bisect run, end to endConfirm both endpoints, write a script that distinguishes good, bad and untestable, run the search in a spare worktree, and finish with a single commit and a reproduction. Each step removes a class of error from the result.Endpointsgood and badboth verifiedTest script0 / 1 / 125Spare worktreeHEAD moves freelyOne commitnamed, reproduciblean unverified endpoint is the most common reason a bisect result is wrong

Step 4 β€” Read Provenance Once You Have the Commit Jump to heading

Finding the commit is half the answer. The other half is what it was for, which is where blame and log options earn their place.

# Follow a line through renames and moves
git log --follow -p -- src/payments/refund.ts | head -40

# Who last changed a specific line range, ignoring whitespace-only commits
git blame -w -L 40,60 -- src/payments/refund.ts
# Find when a string was introduced or removed, anywhere in history
git log -S'REFUND_WINDOW_DAYS' --oneline --all

# And how a function's body changed over time
git log -L :clampRefundWindow:src/payments/refund.ts --oneline
# Verification: the commit found by bisect should appear in these results
git log -S'REFUND_WINDOW_DAYS' --format='%H' | grep -c "$(git rev-parse --short HEAD)"

-S searches for commits that changed the number of occurrences of a string, which finds an introduction or a deletion; -G matches commits whose diff contains a pattern, which finds every touch. The distinction matters more than it looks when hunting for where a constant came from.

Step 5 β€” Audit What the Repository Is Carrying Jump to heading

Forensics is not only about regressions. The same tools answer what is in the history that should not be.

# The largest objects ever committed, whether or not they are still present
git rev-list --objects --all \
  | git cat-file --batch-check='%(objecttype) %(objectsize) %(rest)' \
  | awk '$1=="blob" {print $2, $3}' | sort -rn | head -10
# Files that exist only in history, having been deleted
git log --diff-filter=D --name-only --format='' | sort -u | head -20

SAFETY WARNING β€” searching history for secrets is a detection step, not a remediation. A credential found in an old commit is compromised whether or not you rewrite history, because the repository has been cloned, forked, cached and possibly mirrored. Rotate first, then decide whether a rewrite is warranted; the procedure is in removing a leaked secret from git history.

Four questions, four toolsWhich commit broke it is a bisect question. Who changed this line is a blame question. When did this string appear is a pickaxe search. What is the repository carrying is an object walk.Which commit?git bisect runlogarithmicWho wrote this line?git blame -wignore-revsWhen did X appear?git log -SpickaxeWhat is in here?rev-list --objectssize auditreaching for blame when the question is 'which commit' is the usual wrong turn

When Bisection Does Not Apply Jump to heading

Three situations defeat a straightforward bisect, and recognising them early saves an afternoon.

The first is a non-deterministic failure. If the test fails one run in four, a single run per commit will mark good commits as bad and the search converges on a commit that has nothing to do with the problem. The fix is to make the test script run the reproduction several times and treat any failure as bad, which trades wall-clock time for a correct answer β€” covered in bisecting a flaky failure.

The second is a squashed history. If a month of work landed as one commit, bisection can identify that commit and no more, and the range it represents may contain hundreds of changes. The original commits usually still exist on the branch that was squashed, or in the forge’s records, and bisecting there is possible β€” the approach is in tracing a regression through a squashed history.

The third is a problem that is not in the code at all. A dependency that published a new version, an expired certificate, a change in an upstream service β€” none of these correspond to a commit, and bisection will either find nothing or blame whichever commit happened to be tested after the external change. The tell is that the same commit tests good and bad at different times, which is worth checking explicitly before assuming the search is broken.

Finally, bisection needs history to search. A shallow clone has none, and a repository whose default branch is a series of squash merges has coarse granularity by construction. Both are choices with benefits, and both raise the cost of this particular investigation β€” worth knowing when the choice is being made rather than when an incident is under way.

Configuration Reference Jump to heading

Command or optionEffectWhen to use
git bisect run <cmd>Automated search, using exit codesAny reproduction that can be scripted
exit code 125Skip this commit as untestableBroken builds within the range
git bisect skip <rev>Exclude a commit manuallyA known-bad build unrelated to the bug
git bisect log / replayRecord and re-run a searchSharing or resuming an investigation
git blame -wIgnore whitespace-only changesAny file that has been reformatted
blame.ignoreRevsFileSkip listed commits in blameAfter a sweep or renormalisation
git log -S<string>Commits changing occurrence countFinding where a value was introduced
git log -L :fn:fileHistory of one functionTracing a behaviour change

Troubleshooting Jump to heading

SymptomLikely causeFix
Bisect blames an unrelated commitAn endpoint was wrong, or the test is flakyRe-verify both endpoints; repeat the test
Every commit in the middle fails to buildDependency or tooling changed mid-rangeReturn 125 from the script for build failures
Bisect finds a squash of a month’s workHistory was squashed at mergeBisect the original branch if it still exists
Blame shows only the formatter sweepNo ignore-revs file configuredAdd the sweep to .git-blame-ignore-revs
git bisect refuses to startShallow cloneFetch full history first
The result is not reproducible afterwardsExternal factor, not a commitCheck whether the same commit now tests good

Frequently Asked Questions Jump to heading

How do I bisect when the fix, not the break, is what I want? Jump to heading

Invert the labels with git bisect start --term-new=fixed --term-old=broken, or simply mark the working commit as bad and the broken one as good. Git does not care about the semantics; it searches for the transition, and naming the terms honestly keeps the log readable afterwards.

Can bisection be run in CI? Jump to heading

It can, and it is a good use of a runner: a scripted reproduction plus a range is all it needs, and the runner has no working copy to disturb. The main constraint is fetch depth β€” the job must clone enough history, which means the shallow default has to be overridden.

What if the range spans a dependency upgrade? Jump to heading

Have the script install dependencies from the lockfile at each commit, which is what npm ci does. Skipping that step means every commit is tested against today’s dependency tree, and a regression caused by an upgrade will be attributed to whichever commit happened to be nearby.

Does bisect work across merges? Jump to heading

Yes β€” it walks the whole ancestry graph, including both sides of merges, and will identify a commit on a merged branch. --first-parent restricts it to the main line, which is faster and identifies the merge rather than the specific commit inside it; useful when the branch is someone else’s to investigate.

Is it worth bisecting when someone already suspects the cause? Jump to heading

Usually yes, and the reason is that a suspicion is cheap to test and expensive to be wrong about. Confirming a hypothesis takes one build and one run; a bisect over a thousand commits takes ten. If the suspicion is right, you have saved nine steps. If it is wrong β€” which is more often than anyone expects, because the symptom and the cause are frequently in different subsystems β€” you have spent one step learning that, and the search still has to happen.

The failure mode worth naming is the extended hypothesis phase: three people reasoning about which change could plausibly cause the symptom, for an hour, while a scripted search would have finished in fifteen minutes. Reasoning is valuable for deciding what to do about the commit once it is found; it is a poor substitute for the search itself, because history is large and intuition is anchored on what people remember changing.