Skip to content

Lab: Find a Regression with git bisect

Lesson 5 of 6Intermediate4 min readHands-On Git & GitHub Labs · Git Recovery Labs
Time15 minutes
LevelIntermediate
You needLocal Git only — no GitHub account needed

A test that passed last week fails today. Twelve commits have landed since, most of them documentation. Someone suggests reading each diff. Someone else suggests reverting everything.

There is a third option: tell Git which commit was good, which is bad, and let it run the test at the midpoint until it finds the culprit. Twelve commits is at most four steps.

Use git bisect run with a script to find the first bad commit automatically, understand what the bisect log records, and fix the regression with a targeted revert.

  • Git installed
  • A test that exits 0 on pass and non-zero on fail — the whole technique depends on that

A tiny calculator, a test, and a history where a “performance” commit quietly broke negative numbers:

Terminal window
mkdir -p /tmp/lab-bisect && cd /tmp/lab-bisect
git init -q -b main .
git config user.email "lab@example.com"
git config user.name "Lab User"
cat > calc.sh <<'EOF'
#!/bin/sh
# prints the sum of its arguments
total=0; for n in "$@"; do total=$((total + n)); done; echo "$total"
EOF
cat > test.sh <<'EOF'
#!/bin/sh
[ "$(./calc.sh 2 3)" = "5" ] && [ "$(./calc.sh 10 -4)" = "6" ]
EOF
chmod +x calc.sh test.sh
git add . && git commit -q -m "Add calc and test"
for i in 1 2 3 4 5 6; do echo "# note $i" >> NOTES.md; git add . && git commit -q -m "Docs: note $i"; done
sed -i 's/total=\$((total + n))/total=$((total + ${n#-}))/' calc.sh
git commit -qam "Perf: simplify addition loop"
for i in 7 8 9 10; do echo "# note $i" >> NOTES.md; git add . && git commit -q -m "Docs: note $i"; done
./test.sh && echo PASS || echo FAIL

FAIL. Twelve commits, one of them guilty.

  1. Confirm the endpoints. The test fails at HEAD. Check it passes at the first commit:

    Terminal window
    FIRST=$(git rev-list --max-parents=0 HEAD)
    git checkout -q "$FIRST" && ./test.sh && echo PASS; git checkout -q main
  2. Start the bisection with a bad and a good commit:

    Terminal window
    git bisect start HEAD "$FIRST"

    Git checks out the midpoint. Read what it printed.

  3. Let Git drive. Instead of testing by hand and typing good/bad each time, hand it the test script:

    Terminal window
    git bisect run ./test.sh

    Read the output. How many steps did it take?

  4. Read the log. Everything bisect decided is recorded:

    Terminal window
    git bisect log | tail -3
  5. Return to main — bisect leaves you detached:

    Terminal window
    git bisect reset
  6. Fix it with a revert of exactly the bad commit, and prove the fix:

    Terminal window
    BAD=$(git log --format=%h --grep="Perf:")
    git revert --no-edit "$BAD"
    ./test.sh && echo PASS

Step 3 names Perf: simplify addition loop as the first bad commit in three steps. After step 6, ./test.sh prints PASS and git log --oneline -1 shows the revert.

Step 2. Bisect needs one known-good and one known-bad point. “The first commit” is a safe good point when you know nothing else; a tag or release is better when you have one.

Step 3. bisect run treats exit 0 as good, 1–127 as bad (except 125, which means “cannot test this one, skip it”). Your test script’s exit code is the whole interface.

Step 4. The log is replayable: git bisect replay <file> reruns a saved session, which is how you share a bisection with a colleague.

Step 2:

Bisecting: 5 revisions left to test after this (roughly 3 steps)
[fd408cd203f119fd92f75d2dffe287c01791dc4d] Docs: note 5

Step 3:

Bisecting: 2 revisions left to test after this (roughly 2 steps)
Bisecting: 0 revisions left to test after this (roughly 1 step)
[b775e63c86ac0a7375c6e5341d6b9ad19e1adabc] Perf: simplify addition loop
Bisecting: 0 revisions left to test after this (roughly 0 steps)
b775e63c86ac0a7375c6e5341d6b9ad19e1adabc is the first bad commit
Perf: simplify addition loop
bisect found first bad commit

Step 4:

# good: [8e7bf880e3b86c2b1c419e298e30c4c76900dcc5] Docs: note 6
git bisect good 8e7bf880e3b86c2b1c419e298e30c4c76900dcc5
# first bad commit: [b775e63c86ac0a7375c6e5341d6b9ad19e1adabc] Perf: simplify addition loop

Step 6:

[main d9804de] Revert "Perf: simplify addition loop"
PASS

Your SHAs will differ.

Bisect is binary search over commits. With twelve commits between good and bad, at most four tests are needed; with a thousand, ten. Reading diffs is linear; this is logarithmic. That is the whole argument for it.

run removes the human from the loop. Manual bisection is error-prone — one wrong good sends the search the wrong way. A script cannot mistype. Any command that exits non-zero on the bug works: a unit test, a grep for an error in a build log, a script that starts the server and curls it.

The bad commit is the fix’s target, not the whole history. git revert of one commit removes exactly the regression and keeps the ten documentation commits. Reverting “everything since last week” would have thrown away good work to remove one bad line.

The regression here was plausible. ${n#-} strips a leading minus — someone “simplifying” a loop removed negative-number handling and the tests did not run in CI. The bisect finds it in three steps regardless of how subtle the diff is.

Bisect reports the wrong commit. The endpoints were wrong — “good” was actually already broken. Verify both ends by hand first, as in step 1.

bisect run stops with “bisect run failed”. The script exited with a code above 127, or could not run at a commit (missing file). Make the script robust at every commit in the range, or return 125 for the ones it cannot judge.

You are left on a detached HEAD. That is bisect’s working state. git bisect reset returns you to where you started.

Terminal window
cd /tmp && rm -rf lab-bisect

Recover from a force push — a colleague’s commit vanished from the remote; get it back from a reflog that is not yours.

Choose a learning pathA sequenced route through the curriculum for wherever you are now.