Finding the Change That Broke It
The step-driven test passes at the commit where Kai's waitlist first landed and fails today, about 60 commits later. A binary search over those commits finds the one that brought the race in after about six tests. An agent can run every step of that search, provided the test, not the agent, decides what counts as good and what counts as bad.
Knowing which commit introduced a bug is worth more than it sounds. It tells you what the code looked like when it was right, what the change was trying to do, and which other changes travelled with it. For the double offer it also answers a question nobody on the team could: the waitlist did not have this bug from the start. Something in its second week broke a guarantee the first version had.
Bisect in One Paragraph
You mark one commit good and one bad. Git checks out the commit halfway between them. You test it and mark it good or bad, and the range of suspects halves. Repeat until one commit is left. Because each test halves the range, 60 commits take about six tests: the base-2 logarithm of 60 is about 5.9. The full command set, including marking by hand and replaying a session, is taught in Git, GitHub & GitHub Actions, Topic 19. This page applies it to agent work, where the interesting question is who decides "good".
The Test Must Exist at Every Commit
Bisect checks out old commits, and at those commits today's test file does not exist. The test was written this week; the commits are from August. So the test, and a script that runs it, live outside the repository and are copied in at each step. Git's own documentation recommends exactly this placement: it is safer, it says, if the test scripts are "outside the repository to prevent interactions between the bisect, make and test processes and the scripts".
The second requirement is subtler and is why the start of this chapter was so particular. The test must use only interfaces that existed at those old commits. The step-driven test calls the two public paths and replaces the storage interface, and both have been there since the waitlist's first commit. A test that called today's internal helpers would fail to build on every old commit, the script would report every one of them as untestable, and bisect would have nothing left to search.
The script is short, and its only real job is to translate the test runner's result into the exit code bisect reads. Here it is for each language; it copies the test in, runs it, removes it again, and maps the result.
#!/bin/sh # Lives outside the repository, so every checked-out commit runs the same check. cp "$HOME/bisect/test_double_offer.py" src/ python -m pytest -q src/test_double_offer.py status=$? rm src/test_double_offer.py case $status in 0) exit 0 ;; # one offer per freed place: good 1) exit 1 ;; # the assertion failed: bad *) exit 125 ;; # the test could not run at this commit: skip it esac
#!/bin/sh # Lives outside the repository, so every checked-out commit runs the same check. cp "$HOME/bisect/doubleOffer.test.ts" src/ if npx tsc --noEmit; then npx vitest run src/doubleOffer.test.ts status=$? # 0: one offer per freed place, 1: the assertion failed else status=125 # does not compile at this commit: skip it fi rm src/doubleOffer.test.ts exit $status
#!/bin/sh # Lives outside the repository, so every checked-out commit runs the same check. cp "$HOME/bisect/DoubleOfferTest.java" src/test/java/ if gradle -q compileTestJava; then gradle -q test --tests DoubleOfferTest status=$? # 0: one offer per freed place, 1: the assertion failed else status=125 # does not compile at this commit: skip it fi rm src/test/java/DoubleOfferTest.java exit $status
Then the search itself, which is identical whatever language the service is in. The test runner's own output between the steps is cut below; what remains is git choosing a commit, the script judging it, and git narrowing the range.
$ git bisect start
status: waiting for both good and bad commits
$ git bisect bad
status: waiting for good commit(s), bad commit known
$ git bisect good b884c1e
Bisecting: 29 revisions left to test after this (roughly 5 steps)
[9accfe245dd52259b34338ade83889120e3ce5a0] Document the release steps
$ git bisect run ~/bisect/check-repro.sh
Bisecting: 14 revisions left to test after this (roughly 4 steps)
[cdbe5b9cc4bf4b40b1bd6fd0039f1de98cce6b47] waitlist
Bisecting: 7 revisions left to test after this (roughly 3 steps)
[931e48c4b7fab995fb7deaec865c30b4510cb6ef] schedule: show the gym's local time on the timetable
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[40f6bd6984e0f70d454096adb917930c370d0369] Rename staff role to coach
Bisecting: 1 revision left to test after this (roughly 1 step)
[9c78251a1104c0b5121a79888b83585dbddb47bf] Add capacity to the class admin form
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[fe2e896b4fd2bdfaa4d096df6fa6616b75b29557] Fix flaky timetable snapshot
cdbe5b9cc4bf4b40b1bd6fd0039f1de98cce6b47 is the first bad commit
commit cdbe5b9cc4bf4b40b1bd6fd0039f1de98cce6b47
Author: Kai <kai@cragmoor.example>
Date: Sat Aug 15 13:12:00 2026 -0700
waitlist
bisect found first bad commit
$ git bisect reset
Six commits were tested: the release-steps commit that bisect chose before the run started, and five more during it. Git's count of revisions left to test went 29, 14, 7, 3, 1 and 0, roughly halving each time. Nobody read a line of code during the search.
The Exit Code Decides
git bisect run reads one thing from the script: its exit code. The documentation is precise. The script "should exit with code 0 if the current source code is good/old, and exit with a code between 1 and 127 (inclusive), except 125, if the current source code is bad/new". And: "The special exit code 125 should be used when the current source code cannot be tested." Any other code aborts the bisect. That is why each script above maps the runner's result explicitly. A test runner that fails to collect a test, or a type check that fails because a file at that commit is broken, is neither good nor bad; it is untestable, and 125 tells bisect to skip that commit and choose a neighbour.
The agent fits this arrangement well. It can write the script, check it against one commit known to be good and one known to be bad, start the run and report the result. What it must not do is judge commits itself, by reading each one and deciding whether it "looks like it introduced the race". A reading is an opinion, and Chapter 6 showed what an explanation is worth as evidence. The exit code of a deterministic test is evidence. Six opinions in a row, each of which can send the search into the wrong half with no warning, are not a search.
The Repro Must Be Deterministic
Bisect trusts every answer it is given. Each step discards half the remaining commits on the strength of one result, and nothing downstream ever re-checks it. Feed it the hopeful two-thread test from the start of the chapter, the one that fails 3 runs in 100, and most bad commits come back good. At the first such step, bisect discards the half that contains the culprit and keeps narrowing the wrong half with complete confidence. It still names a "first bad commit" at the end. It is simply the wrong one.
Running a flaky test several times per step does not repair this. At 3%, even ten runs of a bad commit all pass about 74% of the time. Every step has to be right, and only a test that fails on every run of a bad commit makes it so. The step-driven test was built for this moment as much as for judging the fix.
Reading the Guilty Commit
Bisect names a commit from the waitlist's second week, from the session in which Kai asked for the rule that skips members whose membership has lapsed. Like every commit Kai made on the waitlist, it is titled only "waitlist". Its message says neither what changed nor why, which is the failure Chapter 9 was written to prevent. And it mixed changes: besides the lapsed-member rule, it took the offer write out of the step that read the free count. Earlier, one storage call checked the place and recorded the offer together. After this commit, one call read the state and a second call wrote the offer. The guarantee that kept two paths apart was removed as a side effect of a different request, with nothing in the message to say so.
Read it the way Chapter 6 taught before writing any fix: tests first, then signatures and deletions, then the body. The deletion is the finding here, a storage method that did two things in one step, replaced by two methods that each do one. It also tells you what the fix on the previous pages really is. It does not add a new guarantee; it restores the one this commit removed.
When History Is Too Coarse
Bisect ends at one commit, and the precision of the answer is the size of that commit. A commit of several hundred lines, touching three features, ends the search at "somewhere in here", and you are back to reading a large diff with a vague question. Kai's week-two commit was large, as all his waitlist commits were, so bisect could only point at it as a whole; finding the deletion inside it still took a careful read. The habits from Chapter 9, one task per branch and messages that say why, are what make the next bisect land on something you can read in five minutes. History is a debugging tool, and its resolution is set months before you need it.
- "Bisect needs a test that existed in the old commits." It needs a test that runs on them. A script outside the repository copies today's test into yesterday's code at every step.
- "The agent can tell good from bad by reading each commit." That is six opinions in a row, and one wrong answer sends the search into the wrong half with no warning. The exit code of a deterministic test is the only judge that does not get tired or persuaded.
- "A flaky test is fine for bisect if I run it a few times." Each step needs a right answer. At a 3% failure rate, ten runs of a bad commit all pass about 74% of the time.
- "Bisect finds the bug." It finds the commit that introduced it. Understanding that commit, and fixing what it broke, is still the work of the previous pages.
- "Commits that do not build break bisect." Exit code 125 tells it to skip them and test a neighbour instead; only codes outside the documented ranges abort the run.
- Make the repro deterministic before bisecting. Bisect acts on every answer without re-checking it, so every answer has to be right.
- Keep the test and its script outside the repository, so every checked-out commit runs exactly the same check.
- Let the agent write the script and drive the run, and let the exit code judge each commit. Check the script once against a known-good and a known-bad commit first.
- Read the guilty commit with Chapter 6's reading order before writing any fix. The deletion in it is often the cause, and the fix is often to restore it.
Knowledge Check
About how many tests does bisect need to find the first bad commit among roughly 60?
- About 30, because on average it has to try half of the commits before it reaches the bad one
- About 60, because every commit in the range has to be built and tested once to be certain
- About six, because each test halves the range and the base-2 logarithm of 60 is about 5.9
- About two, because git can rule out most commits by their subjects before running any test
Why does the bisect test script live outside the repository?
- Bisect checks out commits where the new test does not exist, so it is copied in from outside at each step
- Git refuses to run a bisect script that is tracked in the repository it is bisecting through
- Files inside the repository are read-only while a bisect session is in progress
- Keeping the script outside makes git skip building the commits, which speeds up the search
You bisect with the original two-thread test, which fails about 3 runs in 100. What is the likely outcome?
- Bisect notices the inconsistent results and stops with a warning that the test is unreliable
- The search takes longer, but it still converges on the right commit after a few extra steps
- Most bad commits test good, the search keeps the wrong half, and it names the wrong commit
- Bisect skips the commits where the test passes, as if each of them had returned exit code 125
One commit in the range does not compile because of an unrelated typo. What should the script return for it?
- 1, so the commit is marked bad and the search can continue past it
- 0, so the commit is marked good and does not distract the search
- 128, so the bisect session stops and you can fix the typo by hand
- 125, which tells bisect the commit cannot be tested and to skip it
Why should the agent not decide good or bad by reading each commit's diff during the search?
- Reading diffs uses too much of the context window to finish a bisect of 60 commits
- A reading is an opinion, and one wrong opinion sends the search into the wrong half for good
- Git hides the commit's diff from any process other than the script that bisect is running
- The agent cannot run git commands at all while a bisect session is active in the repository
You got correct