Git Bisect
A regression is behaviour that used to work and now fails. If dozens of commits landed between a working release and today's broken code, reading every diff in order is slow. git bisect narrows down the first bad commit by testing selected commits between one known good point and one known bad point.
The Mental Model
Git does not know whether your application works or inspect the code to find the bug. You choose one behaviour to investigate, such as "The invoice total is correct" or "The PHPUnit test passes". Good means that behaviour works at the current commit; bad means it is broken there. Good does not certify the entire commit or application, and bad does not mean everything in the commit is broken.
Before starting, you need two points you have tested:
known good commit
↓
... commit history ...
↑
known bad commit
Git temporarily checks out a candidate between those points. Run the reproduction at that commit, then tell Git what happened:
git bisect good # the chosen behaviour works here
# or
git bisect bad # the chosen behaviour is broken here
Git uses your answer to rule out part of the history and checks out another candidate. Repeat until it identifies the first commit where that behaviour changes from good to bad. For example:
v1.4.0 HEAD
GOOD BAD
│ │
▼ ▼
[A]──[B]──[C]──[D]──[E]──[F]──[G]──[H]──[I]──[J]
▲
Git checks here first
If E is good, commits through E can be ruled out for this regression, so Git continues later in the history:
[A]──[B]──[C]──[D]──[E] [F]──[G]──[H]──[I]──[J]
ruled out ↑
search continues
Git is finding where a behaviour changed by asking you whether it works at selected commits, not by reading your code. During the bisect, it moves your working directory through existing commits without rewriting or deleting history. Start with a clean working tree. When finished, git bisect reset restores the checkout that was active before the bisect.
Why The Search Is Fast
A good commit passes the same reproduction that fails at a bad commit. Git checks out a commit roughly halfway through the remaining history. If it is good, the cause is later; if bad, the cause is earlier or at that commit. Each result removes about half the candidates. In a straight line of 64 candidate commits, about six tests can narrow the search to one, rather than testing all 64. Git accounts for the commit graph when history branches, so the exact count can vary.
First make the failure reproducible and choose endpoints you have actually checked. A release tag, deployment record, or earlier test result can help identify the good point. Ensure the test means the same thing at both ends. Work in a clean checkout: bisect repeatedly checks out other commits. A separate worktree is useful if you need to leave your normal task open.
Manual PHP Regression Hunt
Suppose v1.4.0 prints the correct invoice total but current HEAD prints a wrong total. In this project, php bin/invoice-total.php prints £12.00 for the fixed fixture. You have checked that the command exists and produces that output at v1.4.0, while it fails at HEAD.
git status --short
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
php bin/invoice-total.php
After the endpoint commands, Git checks out a candidate commit. Compare the output with £12.00. If correct, run git bisect good; otherwise run git bisect bad. Git then chooses another candidate. Repeat the PHP command and classification until it prints the first bad commit.
git bisect good # use only when the current candidate is correct
# or
git bisect bad # use only when the current candidate reproduces the bug
Those alternatives are not commands to run one after the other at the same commit. If a candidate cannot be tested, use git bisect skip rather than calling it bad. Too many skipped commits can leave Git with a range of possible first bad commits.
Git eventually reports output like this (the hash and commit details are illustrative):
a1b2c3d is the first bad commit
commit a1b2c3d
Author: Developer <developer@example.com>
Date: ...
Change invoice discount calculation
Inspect the reported commit:
git show --stat a1b2c3d
git show a1b2c3d
"First bad commit" means the first commit in the tested history where this reproduction changes from good to bad. It does not prove that every change in the commit caused the bug. Inspect the diff, compare the commit with its parent, rerun the reproduction, then investigate why the behaviour changed.
If you realise you marked a candidate incorrectly, git bisect log shows the classifications made in the current session:
git bisect log
If you are unsure of the resulting state, run git bisect reset and restart with verified good and bad classifications. When finished, run:
git bisect reset
This exits bisect mode and restores the checkout that was active before the bisect started.
Automate A PHPUnit Regression
Once a command can reliably return 0 for good behaviour and a non-zero status for bad behaviour, git bisect run can call it at every candidate. The focused PHPUnit test could be run like this:
git bisect start HEAD v1.4.0
git bisect run ./vendor/bin/phpunit \
--filter testDiscountIsAppliedOnce \
tests/InvoiceTotalTest.php
git bisect reset
This simple form is safe only if the test and PHPUnit exist throughout the range, dependencies work at every candidate, and the command's exit status reliably represents the invoice regression. Historical commits may be untestable because dependencies cannot install, the test did not exist yet, or the required environment is incompatible. They should be skipped with 125, not classified as bad.
For a more reliable check, use a wrapper that handles setup failures before PHPUnit runs. Suppose tests/InvoiceTotalTest.php contains the focused method testDiscountIsAppliedOnce, which passes at v1.4.0 and reproduces the invoice regression at HEAD. Create this script outside the repository so checkout changes cannot replace it:
cat > ../check-invoice-regression.sh <<'SH'
#!/bin/sh
if [ ! -f tests/InvoiceTotalTest.php ]; then
exit 125
fi
if ! composer install --no-interaction --prefer-dist --quiet; then
exit 125
fi
if [ ! -x vendor/bin/phpunit ]; then
exit 125
fi
vendor/bin/phpunit --no-progress \
--filter testDiscountIsAppliedOnce \
tests/InvoiceTotalTest.php
result=$?
case "$result" in
0) exit 0 ;;
1|2) exit 1 ;;
*) exit 125 ;;
esac
SH
git bisect start HEAD v1.4.0
git bisect run sh ../check-invoice-regression.sh
git bisect reset
The question at each commit is whether the chosen behaviour works:
good The invoice behaviour works.
bad The invoice behaviour is broken.
untestable We cannot reliably determine good or bad at this commit.
Git maps the wrapper's exit status to that answer:
0 good
1–127 bad, except 125
125 skip / untestable
other abort the automated bisect
125 does not mean "the test failed". It means "I cannot reliably classify this commit."
PHPUnit uses 0 when all tests pass, 1 when at least one test fails, and 2 when at least one test errors. The wrapper's 1|2 mapping is a policy for this deliberately focused example: it assumes either a failure or an error means the invoice behaviour is bad. That is not a universal meaning for PHPUnit's 2. In a real project, an error could instead come from an incompatible environment, broken fixture, missing extension, database failure, or another unrelated problem. The wrapper must distinguish those cases and return 125 when the commit cannot be tested reliably. The automation must answer the actual bisect question.
Shell statuses 126 and 127 can mean a command could not run; handle such setup failures before they are mistaken for the regression. Run the script manually at both endpoints before trusting automation.
Read The Result Carefully
Bisect is only as trustworthy as the test used to classify each commit. It assumes the tested behaviour divides history into working and broken regions. A flaky test, changing external API, moving fixture, incompatible PHP or dependency version, or a bug fixed and later reintroduced can violate that assumption. Merge histories may make the first bad commit a merge, and skipping many unbuildable commits may prevent a unique answer. Pin the environment where possible, use the smallest reliable reproduction, and record the good commit, bad commit, test command, skipped commits, and result.
After git bisect reset, inspect the first bad commit alongside its parent and confirm the failure with the same environment. Then investigate the actual cause.
See the Git bisect manual for skip, run, and commit-selection details.
Practice
Find The Commit That Broke Checkout Tax Calculation
tests/CheckoutTaxTest.php
Its test method is:
testTaxIsCalculatedOnce
You have already verified:
v2.3.0 test passes
HEAD test fails
There are many commits between those two points.
Use Git Bisect to identify the first commit where this behaviour becomes broken:
- Confirm the working tree is clean.
- Start a bisect with
HEADas bad andv2.3.0as good. - Run the focused PHPUnit test at each candidate commit.
- Mark a candidate
goodwhen the test passes. - Mark a candidate
badwhen the regression reproduces. - Use
git bisect skipif a candidate genuinely cannot be tested. - Continue until Git identifies the first bad commit.
- Inspect that commit.
- Reset the bisect session.
- Consider how the same reproduction could be automated safely.
Automate It
As an optional extension, create a small script outside the repository that returns 0 when the tax behaviour works, a bad status when the regression reproduces, and 125 when the candidate cannot reliably be tested. Run it with:
git bisect start HEAD v2.3.0
git bisect run sh ../check-checkout-tax.sh
git bisect reset
Show solution
Start with a clean working tree. git status --short should print nothing; resolve any changes before bisecting.
git status --short
git bisect start
git bisect bad HEAD
git bisect good v2.3.0
At each candidate Git checks out, run:
./vendor/bin/phpunit \
--filter testTaxIsCalculatedOnce \
tests/CheckoutTaxTest.php
If the tax behaviour is correct, mark this candidate good:
git bisect good
If the targeted regression reproduces, mark it bad:
git bisect bad
If the current commit genuinely cannot be classified, skip it rather than treating a setup failure as the regression:
git bisect skip
Repeat the test and one appropriate classification until Git reports the first bad commit. Inspect the reported commit, using its actual hash in place of this placeholder:
git show --stat <first-bad-commit>
git show <first-bad-commit>
Rerun the reproduction to confirm the result, then finish:
git bisect reset
This exits bisect mode and restores the checkout that was active before the bisect started.
Optional Automation Solution
For the optional extension, put ../check-checkout-tax.sh outside the repository so bisect does not replace it. Have it check that the test, dependencies, and PHPUnit can run before interpreting the focused test result. Return 0 for a passing test, a bad status when the checkout tax regression reproduces, and 125 only when the commit cannot be reliably classified.
git bisect start HEAD v2.3.0
git bisect run sh ../check-checkout-tax.sh
git bisect reset