# Bisecting a Bug (git bisect & Binary-Search Thinking)

> When something worked before and is broken now, you don't search commit by commit - you halve the suspect range each test, so 1000 commits take about 10 checks; git bisect automates the hunt, and the same halving idea finds the bad config line, input row, or dependency.


---

# Bisecting a Bug

Something used to work. Now it doesn't. Somewhere between "fine last month" and "broken today" sits the
one change that did it - buried in a hundred commits, or a thousand-line config, or a giant input file.
The slow, miserable way is to check suspects one at a time until you stumble onto it. There's a far
faster way, and it's the same trick whether you're hunting a commit, a line, or a row.

This guide teaches you to find the needle by repeatedly throwing away half the haystack. You'll learn the
idea first, then `git bisect` (the tool that does it for commits, automatically if you let it), then how
to apply the very same method to anything that has a "before it worked / now it's broken" shape.

## How to read this

- **Need to find the bad commit right now?** Jump to [Phase 2: git bisect](02-git-bisect.md) - it's a
  full annotated session you can follow step by step.
- **Want the idea to stick for life?** Read in order. Phase 1 installs the mental model that makes
  everything else obvious, and Phase 3 shows you how far it reaches beyond Git.

## The phases

1. **[Binary-Search Thinking](01-binary-search-thinking.md)** - the powerful idea: halve the suspect
   range each test, so the search cost grows by *adding one check* when the haystack *doubles*. The three
   things you need to do it at all.
2. **[git bisect](02-git-bisect.md)** - the hands-on tool: mark one known-good and one known-bad commit,
   test each midpoint Git hands you, and let it name the exact first bad commit. Plus `git bisect run` to
   automate the whole thing.
3. **[Bisecting Beyond Git](03-bisecting-beyond-git.md)** - the method generalizes: halving to find which
   config line, which input row, which dependency, or which block of code is the culprit - and the one
   thing that quietly ruins every bisect if you skip it.

> Deep rewriting-history rescue (when a bisect or a fix leaves your branch in a tangle) lives in
> [Git Disaster Recovery](/guides/git-disaster-recovery). This guide stays focused on *finding* the bad
> change, not surgically removing it.


---

# Binary-Search Thinking - Halving the Haystack

Picture the moment. A feature that shipped fine is now broken, and you've got a range of changes to blame
- maybe a hundred commits since the last release, maybe a thousand. The instinct is to start at one end
and walk: check this change, nope; check the next, nope; the next… It feels like progress, but if the
culprit is in the middle of a thousand commits, that walk is five hundred checks of pure tedium.

Here's the secret this whole guide rests on: **you don't have to walk the range - you can halve it.**

## The idea: test the middle, throw away half

**What it actually is.** Binary search finds one item in an ordered range by always testing the *midpoint*
and using the answer to discard half of what's left. It's the same move as flipping to the middle of a
dictionary: the word's not on this page, but now you know it's in the *first* half or the *second* half -
eliminating the other half in a single look.

**Why the slow way feels right but isn't.** Walking the range one step at a time (a *linear* search) treats
every suspect as equally likely and checks them in order. Its cost grows in lockstep with the haystack:
twice as many commits means twice as many checks. Halving breaks that link entirely.

**What it does in real life.** Each test cleanly splits the remaining suspects into "before this point,
where it still worked" and "after this point, where it's broken." Keep only the half the bug must be
hiding in, find *its* midpoint, and test again. The range collapses fast:

```mermaid
flowchart LR
  s1["1000 suspects"] -->|test middle, keep broken half| s2["500"]
  s2 -->|halve| s3["250"]
  s3 -->|halve| s4["125"]
  s4 -->|halve ~6 more times| s5["1<br/>first bad commit"]
```

*What just happened:* Every test cut the suspects roughly in half, so after about ten tests a thousand
candidates collapsed to one. That "about ten" is no accident - halving a thousand ten times gets you to
one (2 to the 10th power is 1024). The shape of the win matters more than the number: **when the haystack
doubles, you pay just one more test.** Two thousand commits? About eleven. A million? About twenty. Linear
search would charge you a million.

💡 **Key point.** Linear cost grows *with* the haystack; halving cost grows with how many times you can
*double* to reach it. That gap is small for tiny ranges and enormous for big ones - which is exactly why
this feels like magic the first time a 900-commit regression falls in nine tries.

## The three things you need

Halving only works when the problem has a particular shape. Before you reach for any tool, make sure you
have all three - if one is missing, fix that first.

**1. A known-good point.** Somewhere the thing demonstrably *worked* - a commit, a release tag, a config
you trust, a date. You've seen it work or can check that it does. This is one end of your range.

**2. A known-bad point.** Somewhere it's demonstrably *broken* - usually "right now." This is the other
end. The bug was introduced *somewhere between good and bad*, and that span is your haystack.

**3. A reliable yes/no test.** At any point in between, you must be able to answer one question with
confidence: **"Is the bug present here - yes or no?"** That's the whole engine - each answer is what lets
you throw away a half. It can be clicking a broken button, running one failing test, or eyeballing an
output - but it must give the *same* answer every time you ask it at the same point.

```text
  good ●────────────────────────────────────────────● bad
   (it worked here)                          (it's broken here)
                         ▲
                   somewhere in this span, one change flipped good → bad.
                   a yes/no test at any point tells you which side it's on.
```

⚠️ **Gotcha - the test has to be trustworthy.** Every halving step *bets the whole rest of the search* on
one yes/no answer. If that answer is wrong even once - because the bug only shows up sometimes, or your
test checks the wrong thing - you'll discard the half that actually held the culprit and hunt forever in
the wrong place. A shaky test doesn't just slow a bisect down; it sends it confidently to the wrong answer.
[Phase 3](03-bisecting-beyond-git.md) comes back to this hard, since it's the single most common way
bisecting goes bad.

**Why this saves you later.** Once you can spot the "worked before / broken now" shape, you stop dreading
regressions. A scary "something in the last 300 commits broke checkout" turns into a calm, finite
procedure: nine or so tests and you have the exact change to read. The next phase hands that procedure to
a tool that picks the midpoints and does the bookkeeping for you.

## Recap

1. **Don't walk the range - halve it.** Test the midpoint and throw away the half the bug can't be in.
2. **Doubling the haystack adds one test**, not double the tests - that's why a thousand commits take
   about ten checks, not a thousand.
3. You need exactly three things: a **known-good point**, a **known-bad point**, and a **reliable yes/no
   test** for "is the bug here?"
4. The yes/no test is the engine - and it has to give the **same answer every time**, or the whole search
   goes wrong.

Watch it animated: [binary-searching for a bug](/explainers/BinarySearchDebug.dc.html)


---

# git bisect - Letting Git Drive the Search

You've got the mental model from [Phase 1](01-binary-search-thinking.md): halve the range, test the
midpoint, keep the half with the break. Doing that by hand means checking out commits, computing each new
midpoint, and tracking cleared halves - fiddly bookkeeping Git will happily do for you.

`git bisect` is that binary search, built into Git. Supply the two ends and answer "good or bad?" at each
step; Git picks the midpoints, checks out the code, and tells you when it finds the culprit.

## What git bisect actually is

A guided session where Git walks you through a binary search over your commit history. Hand it a
known-bad commit and a known-good commit, and Git keeps checking out the midpoint of the *remaining*
suspect range for you to judge. Each judgment discards half the commits, until one is left - the first
one where things went bad.

Git physically *moves your working tree* to each commit it wants tested, so the code on disk is that
commit's code. You test it, type `git bisect good` or `git bisect bad`, and Git jumps to the next one -
handling the bookkeeping so you only ever answer one question at a time.

📝 **Terminology.** *First bad commit* is what bisect hunts for: the earliest commit where the bug is
present - everything before is good, it and everything after is bad. It's the change that introduced the
bug, the thing you want to read.

The hunt walks a line of commits looking for the exact good→bad flip:

```mermaid
gitGraph
  commit id: "v2.3.0 (good)"
  commit id: "C1 (good)"
  commit id: "first bad"
  commit id: "C3 (bad)"
  commit id: "HEAD (bad)"
```

## A full bisect session, start to finish

Say checkout is broken on `main` today, but worked at the `v2.3.0` release tag - that's your known-good;
`HEAD` is your known-bad.

**Start and mark the two ends.**
```console
$ git bisect start
$ git bisect bad                 # current commit (HEAD) is broken
$ git bisect good v2.3.0         # this tag is known to work
Bisecting: 214 revisions left to test after this (roughly 8 steps)
[a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0] Refactor cart pricing
```
*What just happened:* Git did the Phase 1 math (214 commits, roughly 8 tests), then checked out the
*midpoint* commit (`a1b2c3d…`, "Refactor cart pricing") - ready to test.

**Test it, then tell Git the verdict.** Run your reliable yes/no test - here, a checkout in the app:
```console
$ npm test -- checkout
... 1 passing
$ git bisect good
Bisecting: 106 revisions left to test after this (roughly 7 steps)
[f9e8d7c6b5a4f3e2d1c0b9a8f7e6d5c4b3a2f1e0] Add promo-code validation
```
*What just happened:* The test passed, so you marked it `good`. Git threw away this commit and everything
older (all still-working), halving the range from 214 to 106, and checked out the new midpoint.

**Keep going.** Each round, test what's checked out and mark it:
```console
$ npm test -- checkout
... 1 failing
$ git bisect bad
Bisecting: 52 revisions left to test after this (roughly 6 steps)
[c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3] Tidy currency formatting
```
*What just happened:* This time the test *failed*, so you marked it `bad`. Git discarded this commit and
everything *newer* (all broken), keeping the older half where the good→bad flip must be - 106 down to 52.

**Git names the culprit.** After the last round:
```console
$ npm test -- checkout
... 1 failing
$ git bisect bad
3f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a is the first bad commit
commit 3f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a
Author: Dana Lee <dana@acme.dev>
Date:   Tue Jun 3 14:22:10 2026 -0400

    Switch cart total to integer cents

 src/cart/total.js | 18 ++++++++---------
 1 file changed, 9 insertions(+), 9 deletions(-)
```
*What just happened:* The range collapsed to one commit, and Git declared it the **first bad commit**,
printing author, date, message, and files touched. "Something broke checkout" is now "this 18-line change
to `total.js` broke checkout."

**Clean up - mandatory.** Your working tree is still parked mid-search. Put yourself back:
```console
$ git bisect reset
Previous HEAD position was 3f0a1b2 Switch cart total to integer cents
Switched to branch 'main'
```
*What just happened:* `git bisect reset` ended the session and returned your working tree to the branch
you were on before `git bisect start`. Skip it and you're left in a detached state on an old commit.

⚠️ **Gotcha - don't forget `git bisect reset`.** A bisect leaves you on a "detached HEAD." Fix the bug
there and your fix lands detached from any branch, easy to lose. Always `reset` first, *then* fix on a
real branch. (Already committed in a detached state and panicked?
[Git Disaster Recovery](/guides/git-disaster-recovery) walks you back.)

💡 **Key point.** Marked a commit wrong? No need to start over - `git bisect log` shows every verdict, and
`git bisect replay` plus editing can redo it. Simpler: if only the *last* answer was wrong, `git bisect
reset` and start fresh - eight tests is cheap.

## Skip a commit you can't test

Sometimes the midpoint can't be tested - it doesn't build, or fails for an unrelated reason, so your
yes/no test can't give a reliable answer. Don't guess - tell Git to set it aside:
```console
$ git bisect skip
Bisecting: 52 revisions left to test after this (roughly 6 steps)
[b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1] Bump build tooling
```
*What just happened:* `git bisect skip` told Git "I can't judge this one," so it tried a nearby commit
instead. Use it only when genuinely untestable, never to dodge an uncertain verdict - a guess corrupts
the search (Phase 1's warning).

## Let Git run the whole thing: `git bisect run`

If your yes/no test is a command - a script or test suite exiting `0` for good, non-zero for bad - you
don't have to sit there answering each round. Hand it to `git bisect run` and Git drives the search itself:
```console
$ git bisect start
$ git bisect bad
$ git bisect good v2.3.0
$ git bisect run npm test -- checkout
running 'npm test' '--' 'checkout'
... (Git checks out a commit, runs the command, reads the exit code, repeats) ...
3f0a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a is the first bad commit
bisect run success
```
*What just happened:* For each midpoint, Git ran `npm test -- checkout` and read the exit code as your
verdict (`0` = good, non-zero = bad) - looping unattended through all ~8 rounds and printing the first bad
commit at the end. Write the test once, get the culprit hands-free.

📝 **Terminology.** *Exit code* (or *exit status*) is the number a command returns on finishing - `0` for
success by convention, anything else for failure. `git bisect run` reads it as the good/bad answer, so
your test must exit `0` only when the bug is *absent*. (Exit `125` means untestable, same as `skip`.)

**Why this saves you later.** A `git bisect run` one-liner turns "spend an afternoon hunting the
regression" into "write one test, walk away, come back to the exact commit." Still `reset` before you fix.

## Recap

1. **`git bisect start`**, then **`git bisect bad`** (broken end) and **`git bisect good <commit>`**
   (working end) - Git checks out the midpoint for you.
2. **Test what's checked out**, then mark it **`git bisect good`** or **`git bisect bad`**; Git halves the
   range and hands you the next midpoint.
3. Git announces the **first bad commit** with its full message and changed files - the change to read.
4. **`git bisect reset`** when done, *always* - it returns you from the detached commit to your branch.
5. **`git bisect skip`** an untestable commit; never guess a verdict.
6. **`git bisect run <command>`** automates the whole loop using the command's exit code (`0` = good).


---

# Bisecting Beyond Git - The Method Is Everywhere

`git bisect` is the famous one, but it's only a special case. The real prize from
[Phase 1](01-binary-search-thinking.md) is the *thinking*: any problem shaped "this worked, now it
doesn't, and the cause is hiding somewhere in here" can be halved instead of walked. Git just built a tool
for the commit-history version - the method works anywhere: a config file that won't load, a data import
crashing on one row, a broken dependency upgrade, a misbehaving function with no obvious culprit. Same
move every time - cut in half, test, keep the broken half, repeat.

## The config file that won't load

Your app booted fine yesterday. You edited a 200-line config, and now it won't start, with a vague parse
error pointing nowhere useful. Reading all 200 lines is the walk - halve instead: comment out the bottom
half and try to start.
```console
$ ./app --check-config
Config OK
```
*What just happened:* With the bottom 100 lines disabled, the config is valid - the bad line is in the
*bottom* half you removed. Restore it, comment out the bottom *quarter*, test again. Each step halves
what's left, so 200 lines give up the bad one in about eight tries, not a line-by-line read - same three
Phase 1 ingredients, applied to lines instead of commits.

## The input row that crashes the import

A nightly job imports a 50,000-row CSV and one row blows it up, but the error doesn't say which. Don't
scroll: feed it the first half and see if it still crashes.
```console
$ head -n 25000 data.csv | ./import
Error: malformed record
```
*What just happened:* The crash reproduced on the first 25,000 rows, so the bad row is in *that* half.
Try the first 12,500 next, keep narrowing - roughly sixteen tests gets 50,000 rows down to the offending
record (2 to the 16th passes 50,000).

## The dependency upgrade that broke the build

You bumped thirty packages at once and the build now fails - "which one?" is a real mystery. Revert
*half* the upgrades and rebuild.
```console
$ npm run build
Build succeeded
```
*What just happened:* With half the packages rolled back, the build is green - the culprit is among the
*half you reverted*. Re-apply a quarter, rebuild, keep halving until one package is left. Known-good "all
old versions," known-bad "all new," yes/no test "does it build?"

💡 **Key point - bisect the change, not the whole world.** Every example works because there's a clean
"before" and "after" with the culprit in between. When stuck, ask: *where did this last work, and what's
the smallest set of changes since then?* That set is your haystack.

## Even inside one function: comment out half

The method scales all the way down. A function returns wrong results and you can't see why? Comment out
half its body (stubbing whatever the other half needed), run your test, see which half the wrongness
lives in. Crude, but it's the same binary search - finds the bad block in a hairy 80-line function faster
than staring.

```text
  The shape never changes, only the haystack:

   git bisect      ── halve a range of COMMITS
   config bisect   ── halve the LINES of a file
   data bisect     ── halve the ROWS of an input
   dependency      ── halve the SET of changed packages
   code bisect     ── halve the LINES of a function

   one move:  test the middle ► keep the broken half ► repeat
```

## The one thing that ruins every bisect

The Phase 1 warning again, and it matters more the wider you apply the method: **bisecting is only as
trustworthy as your yes/no test.** Every halving step bets the entire remaining search on one answer. Get
one wrong and you discard the half that *held the culprit*, hunting confidently through the wrong half -
a *plausible* wrong answer, worse than an obviously wrong one.

⚠️ **Gotcha - a flaky test will send your bisect to the wrong place.** A *flaky* test sometimes passes and
sometimes fails on the **exact same code**, nothing changed - but bisect demands the same verdict every
time at the same point. A race condition, timing dependency, random seed, or leftover state makes "good"
and "bad" meaningless, and `git bisect run` marches to a false culprit without blinking.

📝 **Terminology.** *Flaky* (or "intermittent," "non-deterministic") describes a test not fully determined
by the code under test, so it can flip with nothing changed - the natural enemy of binary search.

**Make the test reliable first.** This is the discipline of
[How to Reproduce a Bug](/guides/how-to-reproduce-a-bug): pin down steps that reproduce the bug *every
time*, removing the randomness, the leftover state, the "works on the third try." That reproduction *is*
the reliable yes/no test bisecting runs on.

**Why this saves you later.** The biggest wins here aren't in Git at all - turning "I have no idea what
broke this" into a short, finite hunt. Avoid the sneaky failure: trusting a bisect built on a flaky test,
fixing the named "culprit," and finding the bug still there.

## Recap

1. The halving method isn't about commits - it fits **any** "worked before / broken now" problem:
   config lines, input rows, dependency sets, blocks of code.
2. The move is always the same: **test the middle, keep the broken half, repeat** - clearing half the
   haystack per test.
3. Find your **known-good**, your **known-bad**, and the **smallest set of changes** between them; that
   set is what you halve.
4. Bisecting is only as good as its **yes/no test** - one wrong answer routes the whole search to a
   plausible-but-wrong culprit.
5. **A flaky test ruins a bisect.** Make the bug reproduce reliably *first*
   ([How to Reproduce a Bug](/guides/how-to-reproduce-a-bug)), then halve.

**Related guides:** [How to Reproduce a Bug](/guides/how-to-reproduce-a-bug) · [Git Disaster Recovery](/guides/git-disaster-recovery)
