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
- The idea: binary search over the history
- How many steps are really needed
- A complete manual session
git bisect skip: commits that cannot be testedgit bisect logandreplay: not losing the work- Automating with
git bisect run - The test script for
task-manager - The exit codes, in detail
--first-parent: skipping the inside of mergesgit bisect terms: when it is not "good/bad"- What makes a history bisectable
- 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?
- 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:
"106 revisions left to test, roughly 7 steps". It is not an optimistic estimate: it is mathematics.
- 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).
Step 1: start the session.
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.
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:
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:
It works. She marks it:
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:
Bisecting: 26 revisions left to test after this (roughly 5 steps) [7a1f5c3e9b2d6a8c4f0e7b3d5a9c1e6f2b8d4a7c] Rewrite the list rendering
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:
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.
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:
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 skip: commits that cannot be tested
git bisect skip: commits that cannot be testedOn 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:96That 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:
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:
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.
git bisect log and replay: not losing the work
git bisect log and replay: not losing the workHalfway 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 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.txtreplay 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
- Automating with
git bisect run
git bisect runThe 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 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:
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.jsgrep -q returns 0 if it finds something, 1 if not. It is exactly the contract bisect run expects.
- The test script for
task-manager
task-managerSince 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
fiAnd 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:
- It tests one single thing. Only deletion. If the test also failed for other reasons,
bisectwould find the first commit that breaks anything, which is not what we are after. - It distinguishes "does not work" from "cannot be evaluated". That is the role of
125, and it is what we shall see now. - 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:
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 successForty 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:
- 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:
125is the key piece. It is the automatic equivalent ofgit bisect skip. Without it, a commit that does not build would be marked "bad" andbisectwould point you at the wrong culprit.126and127are deadly traps. If the script is not executable or the path is wrong,bisectwill 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 128and above. Akill -9of 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 1Thirty seconds that stop you bisecting for ten minutes towards an invented answer.
--first-parent: skipping the inside of merges
--first-parent: skipping the inside of mergestask-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
mainis concerned, what matters is which merge the problem came in through, not which of the branch's six commits introduced it.
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.shM1^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.
git bisect terms: when it is not "good/bad"
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:
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:
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.shThe 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.
- 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 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.
bisectis 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:
- Start a bisection with
mainas bad and the first commit as good. - At each step, check
cat value.txtand markgoodorbad. - Check that Git identifies exactly commit 9.
- 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:
- Write a script in
/tmpthat returns 0 ifvalue.txtcontains10and 1 if it does not. - Test the script by hand on a good commit and on a bad one before using it.
- Launch
git bisect runand check that it finds the same commit. - Add to the repository an intermediate commit in which
value.txtdoes not exist, and make the script return125in 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)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.shThe 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: $?"git bisect start main $(git log --oneline | tail -1 | cut -d' ' -f1)
git bisect run /tmp/test-value.shrunning '/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 successThe 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.shWhen 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 maincat state.txt # broken
git bisect broken
cat state.txt # fixed
git bisect fixed
cat state.txt # fixed
git bisect fixedWith 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 resetThe 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
start→bad→good→ the testing loop →reset. The finalresetis not optional: without it you are left in detached HEAD. git bisect skipis the honest answer to a commit that cannot be evaluated. Lying by markinggoodorbadproduces a false culprit.git bisect logandreplaysave 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. The125is 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-parentlimits the search to the mainline: less noise and fewerskips. The two-phase tactic — the merge first, then inside the branch — combines speed and precision.git bisect termsgeneralises 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;
bisectis 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
- What Is Git?
- Installing Git
- Basic Git Terminology
- The Git Data Model
- Configuring Git
- Initial Configuration
Module 2: Basic Git Operations
- Creating a Repository
- Cloning a Repository
- The Basic Git Workflow
- Staging and Committing Changes
- Inspecting Changes with git diff
- Viewing Commit History
Module 3: Branching and Merging
- Understanding Branches
- Creating and Switching Branches
- Merging Branches
- Merge Strategies
- Resolving Merge Conflicts
- Branch Management
Module 4: Working with Remote Repositories
- Understanding Remote Repositories
- Adding a Remote Repository
- Authenticating with Remote Repositories
- Fetching and Pulling Changes
- Pushing Changes
- Tracking Branches
Module 5: Advanced Git Operations
Module 6: Git Tools and Techniques
- Using Git Hooks
- Git Bisect
- Git Blame
- Git Log and Aliases
- Git Submodules
- Multiple Working Copies with git worktree
Module 7: Collaboration and Workflow Strategies
- Forks and Pull Requests
- Code Reviews with Git
- The Git Flow Workflow
- GitHub Flow
- Trunk Based Development
- Continuous Integration with Git
Module 8: Git Best Practices and Tips
- Writing Good Commit Messages
- Keeping a Clean History
- Ignoring Files with .gitignore
- File Attributes with .gitattributes
- Security Best Practices
- Performance Tips
Module 9: Troubleshooting and Debugging
- Common Git Problems
- Undoing Changes
- Resolving Divergence with the Remote
- Recovering Lost Commits
- Dealing with Corrupted Repositories
- Advanced Debugging Techniques
