Carla opens an issue with an uncomfortable title: "The delete task button does not delete anything". She reproduces it in three browsers. You click the bin, the task flickers and it is still there.

The baffling part is that it used to work. In version v1.0.0, which the team tagged two months ago (lesson 05-05), deleting worked perfectly: there is a recorded demo that proves it. Between that tag and main there are 214 commits.

The obvious options are bad ones. Reading through 214 diffs is a day's work and a guarantee of seeing nothing. Searching by keyword with git log -S "delete" (lesson 02-06) gives twenty-odd candidates, and none of them looks guilty. And asking in the team chat only produces theories.

But there is a property of the problem that changes everything: the history is ordered, and at some point in that sequence the behaviour went from "works" to "does not work". That is exactly the scenario for a binary search, and Git ships a built-in implementation: git bisect. With it, those 214 commits come down to eight tests.

Contents

  1. The idea: binary search over the history
  2. How many steps are really needed
  3. A complete manual session
  4. git bisect skip: commits that cannot be tested
  5. git bisect log and replay: not losing the work
  6. Automating with git bisect run
  7. The test script for task-manager
  8. The exit codes, in detail
  9. --first-parent: skipping the inside of merges
  10. git bisect terms: when it is not "good/bad"
  11. What makes a history bisectable

  1. The idea: binary search over the history

bisect has one single premise:

There is one specific commit from which the behaviour changed. Before it, fine; from it onwards, broken.

If that holds, there is no need to test the commits one by one. It is enough to test the middle one:

  • If the middle one is bad, the culprit is in the first half. The second half is discarded entirely.
  • If the middle one is good, the culprit is in the second half. The first is discarded entirely.

And you repeat over the half that remains. Every test eliminates half the candidates.

flowchart TD
    A["214 candidates<br/>v1.0.0 (good) … main (bad)"] --> B["Test the middle commit"]
    B -- "works → good" --> C["107 candidates<br/>(upper half)"]
    B -- "does not work → bad" --> D["107 candidates<br/>(lower half)"]
    C --> E["Test the new middle"]
    D --> E
    E --> F["54 → 27 → 13 → 7 → 3 → 2 → 1"]
    F --> G["Guilty commit identified"]

Git does all the logistical work: it works out which commit to test, does the checkout for you (in detached HEAD, lesson 03-02), keeps count of what has been discarded and at the end tells you the exact hash. All you do is answer one binary question at each step: does it work or not?

  1. How many steps are really needed

The number of tests is the base-2 logarithm of the number of candidates, rounded up. The difference with a linear search is brutal and deserves seeing in a table:

Commits between good and bad Tests with bisect (log₂) Tests one by one (worst case)
8 3 8
32 5 32
100 7 100
214 8 214
1,000 10 1,000
10,000 14 10,000
1,000,000 20 1,000,000

That is the headline: doubling the size of the history adds one single test. A repository with a million commits is bisected in twenty steps. It is the reason bisect remains useful however large the project gets.

Git itself tells you this when you start:

Bisecting: 106 revisions left to test after this (roughly 7 steps)

"106 revisions left to test, roughly 7 steps". It is not an optimistic estimate: it is mathematics.

  1. A complete manual session

Carla begins. First of all, leave the working tree clean: bisect is going to do a lot of checkouts, and it cannot if there are uncommitted changes. If she has any, git stash (lesson 05-04).

cd ~/projects/task-manager
git status --short          # must be empty
git switch main
git pull

Step 1: start the session.

git bisect start

It answers nothing. Git has entered bisection mode: it has created internal references in .git/refs/bisect/ and a .git/BISECT_LOG file.

Step 2: mark the two ends.

git bisect bad                 # the current commit (main) is bad
git bisect good v1.0.0         # the v1.0.0 tag was good
Bisecting: 106 revisions left to test after this (roughly 7 steps)
[9f3c7a1e5b2d8c4f6a0e3b7d9c1f5a8e2b4d6c9f] Extract form validation into its own function

Note what has happened: Git has checked out the middle commit. Carla's working tree is now that of commit 9f3c7a1, and HEAD is detached:

git status
HEAD detached at 9f3c7a1
You are currently bisecting, started from main.
  (use "git bisect reset" to get back to the original branch)

nothing to commit, working tree clean

Step 3: the loop. Carla opens index.html in the browser, creates a task, clicks the bin:

python3 -m http.server 8000    # serve the project
# … test in the browser …

It works. She marks it:

git bisect good
Bisecting: 53 revisions left to test after this (roughly 6 steps)
[4e8b2c9d7f1a3e5c6b0d8f2a4c7e9b1d3f5a7c8e] Add the filter by task status

From 106 to 53 in one go. And so on:

# Test at 4e8b2c9: does NOT work
git bisect bad
Bisecting: 26 revisions left to test after this (roughly 5 steps)
[7a1f5c3e9b2d6a8c4f0e7b3d5a9c1e6f2b8d4a7c] Rewrite the list rendering
# Test: does NOT work
git bisect bad
Bisecting: 12 revisions left to test after this (roughly 4 steps)
[2c6e9a4f8b1d7c3e5a0f9b2d6c8e4a1f7b3d5c9e] Extract the list styles into styles.css

Carla carries on: good, bad, good, bad… Five tests later:

git bisect bad
8d4f2a7c1e6b9d3f5a8c0e2b4d7f9a1c3e5b8d6f is the first bad commit
commit 8d4f2a7c1e6b9d3f5a8c0e2b4d7f9a1c3e5b8d6f
Author: Bruno Salas <bruno.salas@example.com>
Date:   Mon Jun 22 11:14:38 2026 +0200

    Delegate the list events to the container

 app.js | 22 ++++++++++------------
 1 file changed, 10 insertions(+), 12 deletions(-)

Eight tests. 214 commits. A culprit with a name, a date and a diff.

Step 4: look at the diff.

git show 8d4f2a7
diff --git a/app.js b/app.js
--- a/app.js
+++ b/app.js
@@ -78,18 +78,16 @@
-  document.querySelectorAll('.delete').forEach((button) => {
-    button.addEventListener('click', (e) => {
-      deleteTask(e.target.dataset.id);
-    });
-  });
+  list.addEventListener('click', (e) => {
+    if (e.target.classList.contains('delete')) {
+      deleteTask(e.target.dataset.id);
+    }
+  });

There it is. Bruno swapped the individual listeners for event delegation on the container, which is a legitimate improvement. But the delete button contains an <svg> with the bin icon, so when you click, e.target is the <svg>, not the button: classList.contains('delete') gives false and nothing happens. With e.target.closest('.delete') it would work.

Without bisect, finding that would have taken hours. With it, eight tests and a twelve-line diff.

Step 5: exit. And this step is not optional:

git bisect reset
Previous HEAD position was 8d4f2a7 Delegate the list events to the container
Switched to branch 'main'

reset cleans up the bisection state and returns HEAD to where it was when you started. If you forget, you are left in detached HEAD on an old commit, and the next git status will be an unpleasant surprise.

Summary of the session's commands:

Command What it does
git bisect start Starts the session
git bisect bad [<sha>] Marks a commit as faulty (by default, HEAD)
git bisect good [<sha>] Marks a commit as correct
git bisect start <bad> <good> Shortcut: starts and marks both ends at once
git bisect skip "I cannot test this one" (section 4)
git bisect log Shows the session so far
git bisect replay <file> Replays a saved session
git bisect visualize (or view) Opens gitk/log with the remaining candidates
git bisect run <command> Automates the whole session (section 6)
git bisect reset [<ref>] Finishes and returns to the starting point

The starting shortcut saves two commands and is the one used in practice:

git bisect start main v1.0.0     # <bad> first, <good> afterwards

  1. git bisect skip: commits that cannot be tested

On the sixth test, Carla runs into this:

python3 -m http.server 8000
# The page comes up completely blank. The browser console says:
# Uncaught SyntaxError: Unexpected token '}' in app.js:96

That commit is broken for another reason. It is not that deleting does not work: it is that the whole application does not start. Marking it bad would be lying to bisect and could lead it down the wrong path; marking it good, worse still.

The correct answer is:

git bisect skip
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[5b8e2d4a9c7f1e3b6d0a8c2f4e7b9d1a3c5f8e2b] Fix the stray brace in app.js

skip tells Git: "this commit gives me no information". Git picks another nearby candidate and carries on. If you end up skipping too many and it cannot isolate a single one, it will tell you:

There are only 'skip'ped commits left to test.
The first bad commit could be any of:
 3e7a1c5 ...
 8d4f2a7 ...
 b2c9e4f ...
We cannot bisect more!

In that case it gives you a small set of suspects, which is already infinitely better than 214.

You can also skip whole ranges, if you know that an entire zone does not build:

git bisect skip a1b2c3d..e4f5a6b

Usual reasons for skip: the commit does not build, dependencies are missing that were not yet in the package.json, or the commit is a massive reformatting change that makes it impossible to run anything. skip is your friend; lying to bisect is not.

  1. git bisect log and replay: not losing the work

Halfway through a long bisection anything can happen: you mark something wrongly, the terminal closes, or you have to deal with something else. git bisect log saves the session:

git bisect log
git bisect start
# status: waiting for both good and bad commits
# bad: [c8f2a1e...] Add the pending task counter
git bisect bad c8f2a1e...
# status: waiting for good commit(s), bad commit known
# good: [1a5c9f3...] Version 1.0.0
git bisect good 1a5c9f3...
# good: [9f3c7a1...] Extract form validation into its own function
git bisect good 9f3c7a1...
# bad: [4e8b2c9...] Add the filter by task status
git bisect bad 4e8b2c9...

Saving it and picking it up later:

git bisect log > /tmp/delete-session.txt
git bisect reset                       # call it a day

# Tomorrow, or on another machine:
git bisect replay /tmp/delete-session.txt

replay rebuilds the session up to where you got to and leaves you on the next commit to test.

And here is its other use, the most valuable one: correcting a marking mistake. If you realise you said good where you should have said bad, there is no need to start from scratch:

git bisect log > /tmp/session.txt
# Edit /tmp/session.txt: remove the wrong line and the ones after it
git bisect reset
git bisect replay /tmp/session.txt

  1. Automating with git bisect run

The manual session works, but eight rounds of "open the browser, create a task, click, mark" are eight opportunities to get it wrong and twenty minutes of Carla's life.

If the check can be written as a command that returns 0 when things are fine and something other than 0 when they are not, git bisect run does the whole thing by itself:

git bisect start main v1.0.0
git bisect run <command>

Git tests, runs the command, interprets the exit code, marks, moves on and repeats until it finds the culprit. With no human intervention.

With a test suite, it is literally this:

git bisect start main v1.0.0
git bisect run npm test

And for a single case, a one-line command may be enough:

# Does the deleteTask function still exist in app.js?
git bisect run grep -q "function deleteTask" app.js

grep -q returns 0 if it finds something, 1 if not. It is exactly the contract bisect run expects.

  1. The test script for task-manager

Since Carla's fault is a behaviour in the browser, a somewhat more elaborate script is needed. The team has an automated test with Node:

#!/usr/bin/env bash
#
# tools/test-delete.sh
# Returns 0 if task deletion works, 1 if it does not.
# Designed for 'git bisect run'.

set -uo pipefail        # NOTE: no -e, we want to control the codes by hand

# 1. Are the files we need there? If not, this commit is not testable.
if [ ! -f app.js ] || [ ! -f index.html ]; then
  echo "→ Project files missing: commit not testable"
  exit 125
fi

# 2. Dependencies: installed quietly; if it fails, it is not the code's fault
if [ -f package.json ]; then
  npm ci --silent >/dev/null 2>&1 || {
    echo "→ npm ci has failed: commit not testable"
    exit 125
  }
fi

# 3. Is the JavaScript syntactically valid? If not, it is not testable either
if ! node --check app.js >/dev/null 2>&1; then
  echo "→ app.js is not syntactically valid: commit not testable"
  exit 125
fi

# 4. The real test
if node tools/delete-test.mjs >/dev/null 2>&1; then
  echo "→ Deleting WORKS"
  exit 0
else
  echo "→ Deleting does NOT work"
  exit 1
fi

And the test itself, which simulates the minimum DOM needed:

// tools/delete-test.mjs
// Minimal test: create a task, delete it and check that it disappears.
import { JSDOM } from 'jsdom';
import { readFileSync } from 'node:fs';

const html = readFileSync('index.html', 'utf8');
const dom = new JSDOM(html, { runScripts: 'outside-only' });
const { document } = dom.window;

// Expose the browser environment that app.js expects
globalThis.window = dom.window;
globalThis.document = document;
globalThis.localStorage = dom.window.localStorage;

// Load the application
dom.window.eval(readFileSync('app.js', 'utf8'));

// 1. Create a task
const field = document.querySelector('#new-task');
const form = document.querySelector('#task-form');
field.value = 'Bisect test task';
form.dispatchEvent(new dom.window.Event('submit'));

if (document.querySelectorAll('.task').length !== 1) {
  console.error('The task was not created: the test is inconclusive');
  process.exit(125);           // not testable, not the fault we are after
}

// 2. Click the bin icon (the <svg> inside the button)
const icon = document.querySelector('.task .delete svg')
          ?? document.querySelector('.task .delete');
icon.dispatchEvent(new dom.window.MouseEvent('click', { bubbles: true }));

// 3. Has it disappeared?
const remaining = document.querySelectorAll('.task').length;
process.exit(remaining === 0 ? 0 : 1);

The three points that make this script work well with bisect:

  1. It tests one single thing. Only deletion. If the test also failed for other reasons, bisect would find the first commit that breaks anything, which is not what we are after.
  2. It distinguishes "does not work" from "cannot be evaluated". That is the role of 125, and it is what we shall see now.
  3. It is silent and deterministic. No interactive output, no dependence on the network, no intermittent failures. A test that fails sometimes poisons the entire bisection.

Running it:

git bisect start main v1.0.0
git bisect run ./tools/test-delete.sh
running './tools/test-delete.sh'
→ Deleting WORKS
Bisecting: 53 revisions left to test after this (roughly 6 steps)
[4e8b2c9] Add the filter by task status
running './tools/test-delete.sh'
→ Deleting does NOT work
Bisecting: 26 revisions left to test after this (roughly 5 steps)
...
8d4f2a7c1e6b9d3f5a8c0e2b4d7f9a1c3e5b8d6f is the first bad commit
commit 8d4f2a7c1e6b9d3f5a8c0e2b4d7f9a1c3e5b8d6f
Author: Bruno Salas <bruno.salas@example.com>
Date:   Mon Jun 22 11:14:38 2026 +0200

    Delegate the list events to the container

 app.js | 22 ++++++++++------------
bisect run success

Forty seconds, without touching the keyboard. And a warning about the #!: the script must be executable (chmod +x) and be outside the working tree or present in every commit. If it lives inside the repository and did not exist in the old commits, bisect will not find it when it checks those commits out. The usual solution is to copy it to /tmp and run it from there:

cp tools/test-delete.sh /tmp/test.sh
git bisect run /tmp/test.sh

  1. The exit codes, in detail

This is the complete contract of git bisect run, and knowing it is the difference between an automation that works and one that gives false results:

Exit code Meaning for bisect When to use it
0 The commit is good The check passes
1–124 The commit is bad The check fails
125 Skip this commit (skip) It cannot be evaluated: does not build, dependencies missing
126 Bad (the command is not executable) Permissions error: check it
127 Bad (command not found) Path error: check it
128–255 Aborts the bisection Serious error; Git stops and tells you

Three practical consequences:

  • 125 is the key piece. It is the automatic equivalent of git bisect skip. Without it, a commit that does not build would be marked "bad" and bisect would point you at the wrong culprit.
  • 126 and 127 are deadly traps. If the script is not executable or the path is wrong, bisect will mark every commit as bad and will "find" the first one in the range as the culprit. If the result looks absurd to you, first check that the script runs by hand: ./my-script.sh; echo $?.
  • Avoid exit 128 and above. A kill -9 of a process gives 137, for example. If your script can end that way, wrap it to normalise the exit code.

A mandatory preliminary check, always, before launching a bisect run:

# On a commit you know to be GOOD
git switch --detach v1.0.0
./tools/test-delete.sh; echo "code: $?"     # must be 0

# On a commit you know to be BAD
git switch --detach main
./tools/test-delete.sh; echo "code: $?"     # must be 1

Thirty seconds that stop you bisecting for ten minutes towards an invented answer.

  1. --first-parent: skipping the inside of merges

task-manager's history has merges (lesson 03-03), and by default bisect explores all reachable commits, including the internal ones of each merged branch. That has two drawbacks:

  • The intermediate commits of a branch under development are usually half-finished: they may not even build, and you end up doing a lot of skips.
  • As far as main is concerned, what matters is which merge the problem came in through, not which of the branch's six commits introduced it.
git bisect start --first-parent main v1.0.0

With --first-parent, Git only considers the mainline: main's direct commits and the merge commits, without going inside the branches. It is exactly the same --first-parent concept that we shall see in git log in lesson 06-04, and the same "first parent" that -m 1 was choosing when reverting a merge (lesson 05-06).

gitGraph
   commit id: "v1.0.0"
   commit id: "a1"
   branch feature/events
   commit id: "f1"
   commit id: "f2"
   commit id: "f3"
   checkout main
   commit id: "a2"
   merge feature/events id: "M1"
   commit id: "a3"

Without --first-parent, the candidates are a1, f1, f2, f3, a2, M1, a3. With --first-parent, only a1, a2, M1, a3.

When to use each:

Situation Mode
The team merges branches and main is always healthy --first-parent: faster, fewer skips
You want the exact commit inside the guilty branch Without --first-parent
The history is linear (rebase before integrating) It makes no difference: there are no merges
Many broken intermediate commits --first-parent, all but mandatory

The two-phase tactic gets the best of both: first --first-parent to identify the merge, then a second bisection inside that branch:

git bisect start --first-parent main v1.0.0
git bisect run /tmp/test.sh
# → it turns out to be merge M1

git bisect reset
git bisect start M1^2 M1^1        # inside the branch: bad=tip, good=base
git bisect run /tmp/test.sh

M1^2 is the second parent (the tip of the merged branch) and M1^1 the first (the base on main), with the notation from lesson 02-06.

  1. git bisect terms: when it is not "good/bad"

bisect is not only for hunting regressions. It serves to locate any monotonic change of state in the history. The typical examples:

  • In which commit was a bug fixed? (here "good" and "bad" are inverted)
  • In which commit did start-up go from taking 200 ms to 900 ms?
  • In which commit did the bundle go from 400 KB to 1.2 MB?

In those cases, the default terminology is confusing. git bisect terms lets you rename it:

git bisect start --term-old=broken --term-new=fixed
git bisect broken v1.0.0
git bisect fixed main

From then on, at each step you answer git bisect broken or git bisect fixed, and Git looks for the first fixed commit.

Alias Equivalent to Meaning
--term-old / --term-good good The old state, the one before the change
--term-new / --term-bad bad The new state, the one we are after

What has to be understood is that Git does not care about the words: it always looks for the first commit with the "new" state, be that "broken", "fixed", "slow" or "fat". To check the active terms at any moment:

git bisect terms
Your current terms are broken for the old state
and fixed for the new state.

A performance example, measuring with the script itself:

#!/usr/bin/env bash
# /tmp/test-size.sh — 0 if the bundle weighs less than 500 KB
npm ci --silent >/dev/null 2>&1 || exit 125
npm run build --silent >/dev/null 2>&1 || exit 125
bytes=$(wc -c < dist/app.min.js)
echo "→ ${bytes} bytes"
[ "$bytes" -lt 512000 ]
git bisect start --term-old=light --term-new=heavy
git bisect light v1.0.0
git bisect heavy main
git bisect run /tmp/test-size.sh

The script's last line is the one that decides: [ "$bytes" -lt 512000 ] returns 0 if it holds and 1 if it does not, which is exactly the contract of bisect run.

  1. What makes a history bisectable

bisect is a tool that gives you back what you gave it. Its effectiveness depends directly on the quality of the history, and that is where module 5 and this one join hands:

Property of the history Effect on bisect
Every commit builds and starts The bisection is clean, with no skips
Small, atomic commits The culprit is a 10-line diff, not an 800-line one
One logical change per commit The diff points at the cause directly
Descriptive messages You understand the why of the guilty change instantly
Giant "Friday's work" commits You find the commit… and you still do not know which line
Commits that do not build Constant skips, blurry or inconclusive result
Merged branches with dirty history Noise: solved with --first-parent

Put another way: the interactive rebase from lesson 05-02 is not cosmetics. When you consolidate the fixup!s, split a giant commit and make sure each one leaves the project in a working state, what you are building is a bisectable history. The bill for not doing it is paid on the day a two-month-old regression has to be found.

And with --exec (lesson 05-02) you can check in advance that an entire branch is bisectable before integrating it:

git rebase -i --exec "node --check app.js" origin/main

Git applies each commit and runs the check on each one. If any of them fails, it stops you there.

All of this — atomic commits, a history that builds, what to consolidate before integrating — is the subject of lesson 08-02: Keeping a Clean History. bisect is the best practical justification there is for those practices: it turns an abstract virtue into minutes of work saved.

One last note on scope: bisect answers "which commit changed this behaviour?". It does not answer "why is my repository in a strange state?" nor "what is Git doing underneath?". That diagnosis — reflog, fsck, GIT_TRACE and company — is the territory of the module 9 lessons, and in particular of 09-06: Advanced Debugging Techniques.

Common Mistakes and Tips

Mistake 1: starting with a dirty working tree. bisect does a checkout at every step and will either fail or drag your changes between commits. Commit or git stash beforehand.

Mistake 2: forgetting git bisect reset. You are left in detached HEAD on a commit from June. Every commit you make there will end up orphaned.

Mistake 3: inverting good and bad at the start. The order in the shortcut is git bisect start <BAD> <GOOD>. If you invert it, Git will search in the opposite direction and will not find anything coherent.

Mistake 4: marking as bad a commit that is broken for another reason. Use skip. Lying to bisect produces a wrong culprit with every appearance of being the right one.

Mistake 5: forgetting the exit 125 in the script. Without it, commits that do not build count as bad and the result is rubbish.

Mistake 6: a script that is not executable or has the wrong path. Codes 126/127: everything comes out "bad" and the culprit is the first commit in the range. Test the script by hand first.

Mistake 7: leaving the script inside the repository. In the old commits it may not exist. Copy it to /tmp and run it from outside.

Mistake 8: a non-deterministic test. If it fails one time in five, bisect will give you a different answer each time. Ensure determinism before automating.

Tip 1: choose the most recent "good" you can. Starting at v1.0.0 rather than at the project's first commit saves steps. Tags (lesson 05-05) are perfect for this.

Tip 2: always save git bisect log. A long session is valuable work. A text file lets you resume it or correct a wrong marking.

Tip 3: first --first-parent, then inside the branch. Fast for locating the merge, precise for locating the commit.

Tip 4: bisect run with npm test is often enough. If you already have tests, do not write anything new.

Tip 5: when you find the culprit, do not blow it up without thinking. Look at it, understand it and decide: git revert if it is published (lesson 05-06), or a fix going forward if the change itself was good and only a detail was missing — as in Bruno's case, where the event delegation was correct and only closest() was missing.

Exercises

Exercise 1: manual bisection

Create a repository with 15 commits where the file value.txt contains 10, and from commit number 9 onwards it contains 99 (the "fault"). Then:

  1. Start a bisection with main as bad and the first commit as good.
  2. At each step, check cat value.txt and mark good or bad.
  3. Check that Git identifies exactly commit 9.
  4. How many steps did you need? Does it match log₂(14)?

Exercise 2: automatic bisection with run

With the same repository as in exercise 1:

  1. Write a script in /tmp that returns 0 if value.txt contains 10 and 1 if it does not.
  2. Test the script by hand on a good commit and on a bad one before using it.
  3. Launch git bisect run and check that it finds the same commit.
  4. Add to the repository an intermediate commit in which value.txt does not exist, and make the script return 125 in that case. Check that the bisection still works.

Exercise 3: custom terms

Create a repository of 10 commits where state.txt contains broken and from commit 7 onwards contains fixed. Using --term-old and --term-new, find the first fixed commit. Also write the corresponding bisect run.

Solutions

Solution 1:

mkdir /tmp/practice-bisect && cd /tmp/practice-bisect
git init -b main

for i in $(seq 1 15); do
  if [ "$i" -lt 9 ]; then echo "10" > value.txt; else echo "99" > value.txt; fi
  echo "line $i" >> log.txt
  git add .
  git commit -q -m "Commit number $i"
done

git log --oneline | tail -1        # the first commit (good)
2a7c9e1 Commit number 1
git bisect start main 2a7c9e1
Bisecting: 6 revisions left to test after this (roughly 3 steps)
[8f3d2c7] Commit number 8
cat value.txt          # 10  → good
git bisect good
Bisecting: 3 revisions left to test after this (roughly 2 steps)
[5c9a4e2] Commit number 12
cat value.txt          # 99  → bad
git bisect bad
Bisecting: 1 revision left to test after this (roughly 1 step)
[1e6b8d3] Commit number 10
cat value.txt          # 99  → bad
git bisect bad
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[7b2f5a9] Commit number 9
cat value.txt          # 99  → bad
git bisect bad
7b2f5a9... is the first bad commit
commit 7b2f5a9...
    Commit number 9
 value.txt | 2 +-
git bisect reset

Four tests for 14 candidates. log₂(14) ≈ 3.8, which rounded up is 4. Exactly right.

Solution 2:

cat > /tmp/test-value.sh <<'END'
#!/usr/bin/env bash
# 0 = good, 1 = bad, 125 = not testable
[ -f value.txt ] || { echo "→ no value.txt: not testable"; exit 125; }
value=$(cat value.txt)
echo "→ value = $value"
[ "$value" = "10" ]
END
chmod +x /tmp/test-value.sh

The preliminary check, which must never be skipped:

cd /tmp/practice-bisect
git switch --detach $(git log --oneline | tail -1 | cut -d' ' -f1)
/tmp/test-value.sh; echo "code: $?"
→ value = 10
code: 0
git switch main
/tmp/test-value.sh; echo "code: $?"
→ value = 99
code: 1
git bisect start main $(git log --oneline | tail -1 | cut -d' ' -f1)
git bisect run /tmp/test-value.sh
running '/tmp/test-value.sh'
→ value = 10
Bisecting: 3 revisions left to test after this (roughly 2 steps)
running '/tmp/test-value.sh'
→ value = 99
...
7b2f5a9... is the first bad commit
    Commit number 9
bisect run success
git bisect reset

The untestable commit:

git switch -c with-gap main
git rm -q value.txt && git commit -q -m "Temporarily remove value.txt"
echo "99" > value.txt && git add . && git commit -q -m "Restore value.txt"

git bisect start with-gap $(git log --oneline main | tail -1 | cut -d' ' -f1)
git bisect run /tmp/test-value.sh

When the bisection reaches the commit with no value.txt, the script returns 125 and Git skips it:

running '/tmp/test-value.sh'
→ no value.txt: not testable
Bisecting: 1 revision left to test after this (roughly 1 step)

The culprit is still commit 9: the 125 has stopped an untestable commit from falsifying the result.

Solution 3:

mkdir /tmp/practice-bisect-terms && cd /tmp/practice-bisect-terms
git init -b main

for i in $(seq 1 10); do
  if [ "$i" -lt 7 ]; then echo "broken" > state.txt; else echo "fixed" > state.txt; fi
  echo "line $i" >> log.txt
  git add . && git commit -q -m "Commit number $i"
done

first=$(git log --oneline | tail -1 | cut -d' ' -f1)
git bisect start --term-old=broken --term-new=fixed
git bisect broken "$first"
git bisect fixed main
Bisecting: 4 revisions left to test after this (roughly 2 steps)
[9c4e7b2] Commit number 5
cat state.txt         # broken
git bisect broken
cat state.txt         # fixed
git bisect fixed
cat state.txt         # fixed
git bisect fixed
4f8a2e6... is the first fixed commit
commit 4f8a2e6...
    Commit number 7

With automation:

git bisect reset

cat > /tmp/test-state.sh <<'END'
#!/usr/bin/env bash
[ -f state.txt ] || exit 125
grep -q '^broken$' state.txt     # 0 = old state (broken), 1 = new
END
chmod +x /tmp/test-state.sh

git bisect start --term-old=broken --term-new=fixed
git bisect broken "$first"
git bisect fixed main
git bisect run /tmp/test-state.sh
git bisect reset

The important thing: even though the terms are called broken and fixed, the script still returns 0 for the old state and 1 for the new one. bisect does not interpret the words; it only looks for the boundary.

Conclusion

git bisect turns a desperate search into a mechanical, bounded procedure. The essentials:

  • It is a binary search over the history: each test discards half the candidates, so log₂(n) tests are needed. 214 commits are 8 tests; a million, twenty.
  • The manual session is startbadgood → the testing loop → reset. The final reset is not optional: without it you are left in detached HEAD.
  • git bisect skip is the honest answer to a commit that cannot be evaluated. Lying by marking good or bad produces a false culprit.
  • git bisect log and replay save long sessions and let you correct a wrong marking without starting from scratch.
  • git bisect run <command> automates the whole process. The contract is the exit codes: 0 good, 1–124 bad, 125 skip, 128+ abort. The 125 is what separates a reliable automation from one that invents answers.
  • Always test the script by hand on a good commit and on a bad one before launching bisect run, and take it out of the working tree (/tmp) so that it exists in every commit.
  • --first-parent limits the search to the mainline: less noise and fewer skips. The two-phase tactic — the merge first, then inside the branch — combines speed and precision.
  • git bisect terms generalises the tool to any monotonic change of state: when something was fixed, when it started running slowly, when the bundle put on weight.
  • And the underlying conclusion: a history of small commits that build is a bisectable history. Lesson 08-02 develops this; bisect is the reason it matters.

Carla now has the guilty commit and the exact diff. But on looking at app.js to fix it she finds something else: twenty lines further down there is a text normalisation function that nobody knows the purpose of, with a replace of odd characters that looks arbitrary. Nobody on the team remembers writing it. Deleting it looks tempting… and would probably break something.

Before touching code you do not understand, you need to know who wrote each line, when and in which commit, so that you can read the message that explains why. That is the tool of lesson 06-03: Git Blame.

Mastering Git: From Beginner to Advanced

Module 1: Introduction to Git

Module 2: Basic Git Operations

Module 3: Branching and Merging

Module 4: Working with Remote Repositories

Module 5: Advanced Git Operations

Module 6: Git Tools and Techniques

Module 7: Collaboration and Workflow Strategies

Module 8: Git Best Practices and Tips

Module 9: Troubleshooting and Debugging

Module 10: Git in the Real World

© Copyright 2026. All rights reserved