bisect-driver — find the commit that introduced a change
The engine is bisect-driver.js (portable Node, zero deps). You hand it a commit
where the behavior was still correct (--good), a commit where it is broken
(--bad, default HEAD), and a repro command; it drives git bisect run over
the range, parses the culprit, prints its sha + subject + author date, and
always runs git bisect reset so the repo ends where it started. This is the
catalog's regression-blame — the same operation under a different name.
Commands
node bisect-driver.js --good <ref> [--bad <ref>] --cmd "<repro command>" [--dir <repo>]
node bisect-driver.js --canary
node bisect-driver.js --help
- --good
<ref> a commit known to still behave correctly (required).
- --bad
<ref> a commit where the behavior is broken (default HEAD).
- --cmd
"<...>" the repro, run per candidate commit via git bisect run sh -c.
- --dir
<repo> repo to bisect (default: current directory).
The repro command's exit code IS the verdict
git bisect run classifies each commit by the repro's exit status:
| exit code |
meaning |
0 |
commit is good |
1–124 |
commit is bad (avoid 126/127 — POSIX-shell reserved) |
125 |
skip — source can't be tested at this commit |
>=128 |
abort the bisect |
So write the repro to exit 0 while the behavior is still correct and non-zero
once it is broken. Examples: a test runner that already exits non-zero on
failure works as-is; to blame an output change, run.py ... && diff -q got.txt want.txt (0 when it still matches). Wrap a build that must pass first as
make || exit 125; ./repro so uncompilable commits are skipped, not blamed.
Examples
- A frozen regression started drifting (a report pinned to hold byte-exact):
find the good commit before the drift, then
node bisect-driver.js --good <sha-before> --cmd "python run_backtest.py --report && diff -q report.txt frozen/report.txt" --dir /path/to/repo.
The culprit is the commit that first broke the pin.
- A frozen regression test started failing: point
--cmd straight
at the test (python -m pytest tests/test_frozen.py -q) — pytest exits
non-zero on failure, which is exactly "bad".
- An API endpoint changed behavior:
node bisect-driver.js --good <last-known-good> --cmd "pytest tests/test_api.py::test_signup -q" --dir /path/to/backend.
Safety
- Refuses (exit 2) on a dirty working tree. Bisect checks out historic
commits during the run and would clobber uncommitted work — commit, stash, or
clean first. "Dirty" includes untracked files.
- Refuses (exit 2) on an in-progress bisect — run
git bisect reset first.
- Always resets. The bisect state is torn down in a
finally, and best-effort
on Ctrl-C (SIGINT), so an interrupted run still restores your HEAD. The tool
verifies HEAD is back where it started and warns if not.
- SIGTERM on Windows is the exception, and it cannot be fixed in code.
Windows delivers it via
TerminateProcess, which gives no JS handler a
chance to run, so kill -TERM can leave bisect state active and HEAD
detached. The next run refuses on that state (exit 2) and tells you to run
git bisect reset. Use Ctrl-C, not kill -TERM.
- Re-tests its own answer before reporting it (exit 1 if it does not hold).
git bisect never re-checks the classifications it made, so ONE bad
classification anywhere in the search produces a confident, wrong culprit —
and git bisect run still exits 0 with no warning. Measured 2026-08-20: a
Ctrl-C mid-run killed one repro invocation, that commit was misclassified,
and the driver reported a commit two places past the real defect with no
hedge. The driver now re-runs the repro on the reported culprit and its
parent: the culprit must fail, the parent must pass. If either half does not
hold, no culprit is reported. This also catches a flaky or nondeterministic
repro, and a commit where the build itself was broken. Cost: two extra repro
runs per bisect.
- Read-only toward the world: it only mutates the target repo's transient bisect
state, which it restores.
- Refuses (exit 2) when the repro misclassifies the endpoints.
git bisect
trusts the marked --good / --bad refs and never re-tests them — it only
tests commits between them. So a repro that never actually flips to "bad" on
historic checkouts (wrong path, missing script, an always-exit 0 command, or
the opposite — an always-non-zero command) would otherwise yield a confident
but false culprit. Guard: before starting, the tool checks out each endpoint
and runs the repro itself — the --good ref must exit 0 and the --bad ref
must exit 1–124. If not, it refuses with an actionable message instead of
handing back a silent false positive. (This costs two extra repro runs up
front; on a slow repro that is the price of not trusting a broken one.)
Windows notes
- The repro is executed as
git bisect run sh -c "<cmd>"; Git for Windows ships
the sh it needs (bisect has always depended on it), so this works under both
PowerShell 5.1 and Git Bash.
- PowerShell 5.1: wrap the whole
--cmd value in double quotes —
--cmd "pytest -q && diff -q a b". PS 5.1 has no && at the pipeline level,
but here && lives inside the quoted string and is interpreted by sh, so it
is fine.
- Do not pass
node -e "<quoted code>" as the repro on this machine — PS 5.1
mangles quoted -e and leaves 0-byte junk files. Point the repro at a real
script file instead.
Exit codes
0 culprit found · 1 no culprit parsed / bisect error · 2 usage error or
preflight refusal (dirty tree, in-progress bisect, unresolvable ref, or a repro
that misclassifies the --good/--bad endpoints).
Verification (the done-check)
node bisect-driver.js --canary
Builds a throwaway git repo of ~8 commits with a behavior change planted at a
known middle commit, then proves both directions: the culprit is identified
as exactly the planted commit and the repo is restored + bisect-state clean
(good direction), AND a dirty tree, an in-progress bisect, an unresolvable ref,
an always-good repro (bad endpoint passes), and an always-bad repro (good
endpoint fails) are each refused with exit 2 (bad direction). Cleans up after
itself. MUST print CANARY PASS 16/16 before you trust a result. Never bisects
a real repo.
1---2name: bisect-driver3description: Automates git bisect to find the commit that introduced a behavior change: give a known-good ref, a bad ref (default HEAD), and a repro command; drives `git bisect run`, parses the first-bad commit, ALWAYS resets bisect state. Refuses up front on a dirty tree or in-progress bisect. Use when: "bisect this", "which commit broke X", "when did this test start failing", "regression blame". Zero deps.4---56# bisect-driver — find the commit that introduced a change78The engine is `bisect-driver.js` (portable Node, zero deps). You hand it a commit9where the behavior was still correct (`--good`), a commit where it is broken10(`--bad`, default `HEAD`), and a repro command; it drives `git bisect run` over11the range, parses the culprit, prints its sha + subject + author date, and12**always** runs `git bisect reset` so the repo ends where it started. This is the13catalog's **regression-blame** — the same operation under a different name.1415## Commands1617```18node bisect-driver.js --good <ref> [--bad <ref>] --cmd "<repro command>" [--dir <repo>]19node bisect-driver.js --canary20node bisect-driver.js --help21```2223- **--good `<ref>`** a commit known to still behave correctly (required).24- **--bad `<ref>`** a commit where the behavior is broken (default `HEAD`).25- **--cmd `"<...>"`** the repro, run per candidate commit via `git bisect run sh -c`.26- **--dir `<repo>`** repo to bisect (default: current directory).2728### The repro command's exit code IS the verdict2930`git bisect run` classifies each commit by the repro's exit status:3132| exit code | meaning |33|-----------|---------|34| `0` | commit is **good** |35| `1`–`124` | commit is **bad** (avoid `126`/`127` — POSIX-shell reserved) |36| `125` | **skip** — source can't be tested at this commit |37| `>=128` | **abort** the bisect |3839So write the repro to **exit 0 while the behavior is still correct and non-zero40once it is broken**. Examples: a test runner that already exits non-zero on41failure works as-is; to blame an output change, `run.py ... && diff -q got.txt42want.txt` (0 when it still matches). Wrap a build that must pass first as43`make || exit 125; ./repro` so uncompilable commits are skipped, not blamed.4445### Examples4647- **A frozen regression started drifting** (a report pinned to hold byte-exact):48 find the good commit before the drift, then49 `node bisect-driver.js --good <sha-before> --cmd "python run_backtest.py --report && diff -q report.txt frozen/report.txt" --dir /path/to/repo`.50 The culprit is the commit that first broke the pin.51- **A frozen regression test started failing:** point `--cmd` straight52 at the test (`python -m pytest tests/test_frozen.py -q`) — pytest exits53 non-zero on failure, which is exactly "bad".54- **An API endpoint changed behavior:**55 `node bisect-driver.js --good <last-known-good> --cmd "pytest tests/test_api.py::test_signup -q" --dir /path/to/backend`.5657## Safety5859- **Refuses (exit 2) on a dirty working tree.** Bisect checks out historic60 commits during the run and would clobber uncommitted work — commit, stash, or61 clean first. "Dirty" includes untracked files.62- **Refuses (exit 2) on an in-progress bisect** — run `git bisect reset` first.63- **Always resets.** The bisect state is torn down in a `finally`, and best-effort64 on Ctrl-C (SIGINT), so an interrupted run still restores your HEAD. The tool65 verifies HEAD is back where it started and warns if not.66 - **SIGTERM on Windows is the exception, and it cannot be fixed in code.**67 Windows delivers it via `TerminateProcess`, which gives no JS handler a68 chance to run, so `kill -TERM` can leave bisect state active and HEAD69 detached. The next run refuses on that state (exit 2) and tells you to run70 `git bisect reset`. Use Ctrl-C, not `kill -TERM`.71- **Re-tests its own answer before reporting it (exit 1 if it does not hold).**72 `git bisect` never re-checks the classifications it made, so ONE bad73 classification anywhere in the search produces a confident, wrong culprit —74 and `git bisect run` still exits 0 with no warning. Measured 2026-08-20: a75 Ctrl-C mid-run killed one repro invocation, that commit was misclassified,76 and the driver reported a commit **two places past** the real defect with no77 hedge. The driver now re-runs the repro on the reported culprit and its78 parent: the culprit must fail, the parent must pass. If either half does not79 hold, no culprit is reported. This also catches a flaky or nondeterministic80 repro, and a commit where the build itself was broken. Cost: two extra repro81 runs per bisect.82- Read-only toward the world: it only mutates the target repo's transient bisect83 state, which it restores.84- **Refuses (exit 2) when the repro misclassifies the endpoints.** `git bisect`85 trusts the marked `--good` / `--bad` refs and **never re-tests them** — it only86 tests commits *between* them. So a repro that never actually flips to "bad" on87 historic checkouts (wrong path, missing script, an always-`exit 0` command, or88 the opposite — an always-non-zero command) would otherwise yield a confident89 but false culprit. Guard: before starting, the tool checks out each endpoint90 and runs the repro itself — the `--good` ref must exit `0` and the `--bad` ref91 must exit `1`–`124`. If not, it refuses with an actionable message instead of92 handing back a silent false positive. (This costs two extra repro runs up93 front; on a slow repro that is the price of not trusting a broken one.)9495## Windows notes9697- The repro is executed as `git bisect run sh -c "<cmd>"`; Git for Windows ships98 the `sh` it needs (bisect has always depended on it), so this works under both99 PowerShell 5.1 and Git Bash.100- **PowerShell 5.1:** wrap the whole `--cmd` value in double quotes —101 `--cmd "pytest -q && diff -q a b"`. PS 5.1 has no `&&` at the *pipeline* level,102 but here `&&` lives inside the quoted string and is interpreted by `sh`, so it103 is fine.104- Do not pass `node -e "<quoted code>"` as the repro on this machine — PS 5.1105 mangles quoted `-e` and leaves 0-byte junk files. Point the repro at a real106 script file instead.107108## Exit codes109110`0` culprit found · `1` no culprit parsed / bisect error · `2` usage error or111preflight refusal (dirty tree, in-progress bisect, unresolvable ref, or a repro112that misclassifies the `--good`/`--bad` endpoints).113114## Verification (the done-check)115116```117node bisect-driver.js --canary118```119120Builds a throwaway git repo of ~8 commits with a behavior change planted at a121known middle commit, then proves **both directions**: the culprit is identified122as exactly the planted commit and the repo is restored + bisect-state clean123(good direction), AND a dirty tree, an in-progress bisect, an unresolvable ref,124an always-good repro (bad endpoint passes), and an always-bad repro (good125endpoint fails) are each refused with exit 2 (bad direction). Cleans up after126itself. MUST print `CANARY PASS 16/16` before you trust a result. Never bisects127a real repo.