Finding Bugs with git bisect

Git · Lesson 23 of 28 7 min read Updated

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:

JavaScript
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:

Terminal
node check.js
Output
Senior sleeper fare for 100 km: 120

Priya knows the fare was right in version v1.0, eight commits ago. Here is the history:

Terminal
git log --oneline
Output
50d9a54 (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:

Terminal
git bisect start
git bisect bad
git bisect good v1.0
Output
status: 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:

Terminal
node check.js
git bisect good
Output
Senior 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:

Terminal
node check.js
git bisect bad
Output
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(-)

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.

Terminal
git show f225a21 -- fare.js
Diff
@@ -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:

Terminal
git bisect reset
Output
Previous HEAD position was f225a21 Move senior discount into a constant
Switched to branch 'main'

Useful bisect commands

CommandUse it when
git bisect start BAD GOODStarting and marking both ends in one line, for example git bisect start HEAD v1.0
git bisect skipThe current commit cannot be tested (it does not build); Git picks a nearby one
git bisect logSeeing every decision you made so far
git bisect visualizeShowing the commits still left to test
git bisect resetEnding 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(...).

Terminal
git bisect start HEAD v1.0
git bisect run node check.js
Output
Bisecting: 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' commit

The 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.js here, 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 run trusts the exit code. Make sure your script exits non-zero when the bug is present.

Key points

  • git bisect binary-searches history to find the first bad commit; the range halves with every answer.
  • Start with git bisect start, mark git bisect bad and git bisect good <commit>, then test each commit Git checks out.
  • git bisect skip skips untestable commits; git bisect reset always 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-found to git 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
Terminal
git bisect start HEAD HEAD~10
git bisect run sh -c 'test "$(head -n 1 total.txt)" = "100"'
git bisect reset

test 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?

2. What exit code tells git bisect run to skip the current commit?

3. Why must you run git bisect reset at the end?

4. A commit in the range does not even build, so you cannot test it. What should you do?

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