Finding Bugs with git bisect
Something worked last month and is broken today, and fifty commits happened in between. Reading every commit would take hours. git bisect finds the guilty commit for you with a binary search: it keeps checking out the commit halfway between a known good and a known bad version, you say whether that commit is good or bad, and the range halves each time.
Fifty commits need about six checks; a thousand commits need about ten. In this lesson you will find a real bug in Priya's train-fare project, first by hand and then fully automatically.
The bug
train-fare calculates railway fares. Senior citizens should pay 60% of the fare, so a 100 km sleeper ticket (₹300 normally) should cost ₹180 for a senior. A small check script prints the fare and exits with code 0 if it is right and 1 if it is wrong:
import { fare } from "./fare.js";
const got = fare(100, "sleeper", true);
console.log("Senior sleeper fare for 100 km:", got);
process.exit(got === 180 ? 0 : 1);On today's code the check fails:
node check.jsSenior sleeper fare for 100 km: 120
Priya knows the fare was right in version v1.0, eight commits ago. Here is the history:
git log --oneline50d9a54 (HEAD -> main) Invite contributions 0f77741 Add AC first class a2bddcc Document currency f225a21 Move senior discount into a constant f8ffdd9 Add child fare 9218a2e Add AC 2-tier class ff21254 Explain how to run the checks 6f41ea3 Format amounts in rupees abec27d (tag: v1.0) Add classes and senior citizen discount 091e463 Add README d6ed6ee Add basic fare calculator
check.js is not committed, so it stays in the folder while bisect checks out older commits. That is handy: the same test works at every step.
Bisecting by hand
Start a session, mark the current commit as bad and v1.0 as good:
git bisect start
git bisect bad
git bisect good v1.0status: waiting for both 'good' and 'bad' commits status: waiting for 'good' commit(s), 'bad' commit known Bisecting: 3 revisions left to test after this (roughly 2 steps) [f8ffdd96bc5bc5318374f2d001cd2064cf719684] Add child fare
Git checked out f8ffdd9, in the middle of the range, and estimates two more steps. Priya runs the check there:
node check.js
git bisect goodSenior sleeper fare for 100 km: 180 Bisecting: 1 revision left to test after this (roughly 1 step) [a2bddcce4ce0e5b225aee5a7304c3f37c689e7fc] Document currency
The fare is correct, so the bug came later. Git jumps forward. At a2bddcc the check prints 120, so she types git bisect bad, and Git checks out f225a21. That one is also wrong:
node check.js
git bisect badSenior sleeper fare for 100 km: 120
f225a21d2c61f0d46eada2f54bd2c96a3c7453c6 is the first 'bad' commit
commit f225a21d2c61f0d46eada2f54bd2c96a3c7453c6
Author: Priya Sharma <priya@example.com>
Date: Fri Oct 30 15:12:34 2026 +0530
Move senior discount into a constant
fare.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)Found it in three checks. Looking at that commit's diff (the commit header is left out below) shows the mistake at once: the discount (40%) was used where the amount seniors pay (60%) was needed.
git show f225a21 -- fare.js@@ -1,8 +1,9 @@
const RATE = { general: 1, sleeper: 1.5, ac3: 3.5, ac2: 5 };
+const SENIOR_DISCOUNT = 0.4;
export function fare(km, cls = "general", senior = false) {
const amount = km * 2 * RATE[cls];
- return senior ? amount * 0.6 : amount;
+ return senior ? amount * SENIOR_DISCOUNT : amount;
}Finally, end the session. This is important: bisect leaves you on an old commit in detached HEAD, and reset takes you back to the branch you started from:
git bisect resetPrevious HEAD position was f225a21 Move senior discount into a constant Switched to branch 'main'
Useful bisect commands
| Command | Use it when |
|---|---|
git bisect start BAD GOOD | Starting and marking both ends in one line, for example git bisect start HEAD v1.0 |
git bisect skip | The current commit cannot be tested (it does not build); Git picks a nearby one |
git bisect log | Seeing every decision you made so far |
git bisect visualize | Showing the commits still left to test |
git bisect reset | Ending the session and returning to your branch |
If "good" and "bad" sound odd for your problem (for example finding when something got faster), start with git bisect start --term-old=slow --term-new=fast and use those words instead.
Automating with git bisect run
Typing good and bad is fine for three steps, but a script can do it all. git bisect run runs a command at each step and reads its exit code: 0 means good, 1 to 127 (except 125) means bad, and 125 means "skip this commit". That is why check.js ends with process.exit(...).
git bisect start HEAD v1.0
git bisect run node check.jsBisecting: 3 revisions left to test after this (roughly 2 steps)
[f8ffdd96bc5bc5318374f2d001cd2064cf719684] Add child fare
running 'node' 'check.js'
Senior sleeper fare for 100 km: 180
Bisecting: 1 revision left to test after this (roughly 1 step)
[a2bddcce4ce0e5b225aee5a7304c3f37c689e7fc] Document currency
running 'node' 'check.js'
Senior sleeper fare for 100 km: 120
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[f225a21d2c61f0d46eada2f54bd2c96a3c7453c6] Move senior discount into a constant
running 'node' 'check.js'
Senior sleeper fare for 100 km: 120
f225a21d2c61f0d46eada2f54bd2c96a3c7453c6 is the first 'bad' commit
commit f225a21d2c61f0d46eada2f54bd2c96a3c7453c6
Author: Priya Sharma <priya@example.com>
Date: Fri Oct 30 15:12:34 2026 +0530
Move senior discount into a constant
fare.js | 3 ++-
1 file changed, 2 insertions(+), 1 deletion(-)
bisect found first 'bad' commitThe same answer, with no typing. Your test command can be anything that exits with the right code: npm test, python -m pytest tests/test_fares.py, or a shell script. Remember to run git bisect reset afterwards.
Tips for a smooth bisect
- Write the test first. A small script that reproduces the bug turns bisect into a coffee break.
- Keep the test outside the history. An untracked file, like
check.jshere, exists at every step. A test that only exists in new commits cannot run on old ones. - Commit small, working steps. Bisect points to one commit; if that commit changes 40 files, you still have digging to do. Small commits give precise answers.
- Start with a clean working tree. Commit or stash your changes, because bisect checks out other commits.
Common mistakes
- Swapping good and bad. Bisect then finds the commit that fixed something. Check before you answer.
- Forgetting
git bisect reset. You stay in detached HEAD on an old commit and may commit there by accident. - A test that exits 0 on failure.
bisect runtrusts the exit code. Make sure your script exits non-zero when the bug is present.
Key points
git bisectbinary-searches history to find the first bad commit; the range halves with every answer.- Start with
git bisect start, markgit bisect badandgit bisect good <commit>, then test each commit Git checks out. git bisect skipskips untestable commits;git bisect resetalways ends the session.git bisect run <command>automates the search using exit codes: 0 good, 125 skip, other non-zero bad.- Git 2.56 adds
--reset-when-foundtogit bisect run.
Exercise
Create a repository with a file total.txt containing 100. Make ten commits, each appending a line, and in one of the middle commits change the first line to 99. Then use git bisect run with a one-line shell command that checks whether the first line is still 100 to find the commit that changed it.
Show solution
git bisect start HEAD HEAD~10
git bisect run sh -c 'test "$(head -n 1 total.txt)" = "100"'
git bisect resettest exits with 0 when the first line is 100 (good) and 1 otherwise (bad), so bisect can read the result directly. HEAD~10 is the first commit, which you know is good. If your repository has fewer commits, use the hash of the first commit instead.
Quick Quiz
1. About how many steps does bisect need to search 1,000 commits?
Each answer halves the range, and 2 to the power 10 is 1,024.
2. What exit code tells git bisect run to skip the current commit?
0 means good, 125 means skip, and other values from 1 to 127 mean bad.
3. Why must you run git bisect reset at the end?
Bisect checks out old commits in detached HEAD; reset ends the session.
4. A commit in the range does not even build, so you cannot test it. What should you do?
Skipping lets Git choose a nearby commit to test instead.
Learn Git with a mentor
Free lessons get you started. Our Full Stack Web Development training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
