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
The Shape of the Search 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.
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 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.
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 option | Effect | When to use |
|---|---|---|
git bisect run <cmd> | Automated search, using exit codes | Any reproduction that can be scripted |
| exit code 125 | Skip this commit as untestable | Broken builds within the range |
git bisect skip <rev> | Exclude a commit manually | A known-bad build unrelated to the bug |
git bisect log / replay | Record and re-run a search | Sharing or resuming an investigation |
git blame -w | Ignore whitespace-only changes | Any file that has been reformatted |
blame.ignoreRevsFile | Skip listed commits in blame | After a sweep or renormalisation |
git log -S<string> | Commits changing occurrence count | Finding where a value was introduced |
git log -L :fn:file | History of one function | Tracing a behaviour change |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Bisect blames an unrelated commit | An endpoint was wrong, or the test is flaky | Re-verify both endpoints; repeat the test |
| Every commit in the middle fails to build | Dependency or tooling changed mid-range | Return 125 from the script for build failures |
| Bisect finds a squash of a monthβs work | History was squashed at merge | Bisect the original branch if it still exists |
| Blame shows only the formatter sweep | No ignore-revs file configured | Add the sweep to .git-blame-ignore-revs |
git bisect refuses to start | Shallow clone | Fetch full history first |
| The result is not reproducible afterwards | External factor, not a commit | Check 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.
Related Jump to heading
- Automating git bisect With a Test Script β the script, the exit codes and the traps.
- Bisecting a Flaky Failure β searching when the test is not deterministic.
- Tracing a Regression Through a Squashed History β when one commit holds a month of work.
- Finding Who Changed a Line With log and blame β provenance through renames, moves and sweeps.
- Auditing a Repository for Large Blobs β what the history is carrying that nobody needs.