Bisection answers a precise historical question: within selected history, where does a reproducible observation change from good to bad? It does not identify a root cause merely by naming a commit. The search depends on correct endpoint labels and consistent intermediate decisions. Treat the reported boundary as evidence to inspect, then confirm the behavior on both sides and explain the change that accounts for it.
Choose endpoints by observation, not date
At least one known-good and one known-bad commit establish the search. More good revisions can further constrain a graph. A recently verified good commit usually reduces the range better than an unnecessarily ancient baseline. Dates are not the search order: Git uses ancestry, and a commit’s author timestamp need not describe where it sits in the graph.
Run the same reproduction at both endpoints before assigning labels. Define what counts as the symptom, including boundary inputs and relevant configuration. A test that simply checks whether any command fails can confuse the product regression with a missing interpreter, unavailable service, or incompatible build environment. An untestable revision has an unknown result, not an automatic bad verdict.
Use a clean disposable checkout or a dedicated worktree. Bisect normally changes HEAD to selected revisions and updates checked-out files. Preserve unfinished work beforehand without assuming that the search itself creates a backup. Keep the probe and required evidence outside files that disappear or change as historical revisions are checked out.
Make each decision about the same property
After inspecting the selected revision, record git bisect good or git bisect bad according to the reproduction. Use git bisect skip when a specific revision cannot be classified for a reason unrelated to the symptom. Skips near the transition may prevent a unique answer, so report the remaining candidate set instead of pretending the first displayed object is proven responsible.
Simple binary-search intuition assumes a reasonably stable transition in the selected history. A bug introduced, fixed, and later reintroduced is not a single monotonic timeline for that predicate. Nondeterministic tests create the same kind of misleading labels. Refine the question or the interval when the observations contradict the assumed transition; do not force them into a convenient story.
For a merge-heavy history, deciding whether to search all ancestry or only the first-parent integration line changes the question. A first-parent search can locate the merge where an integration branch acquired the failure rather than the original topic commit. Similarly, limiting paths can exclude a relevant configuration or caller change. Apply those restrictions only when they match the investigation.
When it breaks
One mistaken label can exclude the true boundary. Save git bisect log before abandoning the session. If a decision was wrong, retain the original evidence, correct the decision record, reset, and replay the corrected log. Replay reconstructs decisions; it does not rerun tests or prove their old environment is still valid.
When a candidate is found, rerun the exact reproduction there and on the relevant parent. For a normal linear boundary, the candidate should be bad and its parent good. A merge may require examining several parents and the combined behavior. Read the patch and affected call chain; a candidate that only exposes a preexisting issue still needs a causal explanation.
Avoid immediately reverting the candidate in a shared or dirty branch as a diagnostic shortcut. Any corrective experiment belongs in a controlled checkout with its own review and tests. After recording the findings, git bisect reset ends the session and normally restores the original checkout. Confirm the active branch and revision rather than assuming the final diagnostic checkout is the development branch.
Check it quickly
Run this with Python 3 and Git 2.28 or newer. The temporary repository uses synthetic identity, disables hooks and signing, excludes personal Git configuration and routing variables, and has no remote. Seven revisions change a numerical limit from inclusive to exclusive at revision three. The probe checks the exact boundary input, ten.
import os
import subprocess
import sys
import tempfile
from pathlib import Path
env = {key: value for key, value in os.environ.items() if not key.startswith("GIT_")}
env.update(GIT_CONFIG_NOSYSTEM="1", GIT_CONFIG_GLOBAL=os.devnull)
with tempfile.TemporaryDirectory(prefix="git-bisect-boundary-") as directory:
root = Path(directory)
repo, log = root / "repo", root / "bisect.log"
repo.mkdir()
def git(*args):
return subprocess.run(
["git", "-c", "user.name=Boundary Example",
"-c", "user.email=boundary@example.invalid",
"-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", *args],
cwd=repo, env=env, text=True, capture_output=True, check=True,
)
def verdict():
result = subprocess.run([sys.executable, "-B", "-c",
"from limit import accepts; print(int(accepts(10)))"],
cwd=repo, text=True, capture_output=True, check=True)
assert result.stdout in ("0\n", "1\n"), result
return "good" if result.stdout == "1\n" else "bad"
git("init", "-q", "-b", "main")
commits = []
for number in range(7):
operator = "<" if number >= 3 else "<="
(repo / "limit.py").write_text(f"def accepts(n):\n return n {operator} 10\n")
(repo / "revision.txt").write_text(f"{number}\n")
git("add", "limit.py", "revision.txt")
git("commit", "-qm", f"revision {number}")
commits.append(git("rev-parse", "HEAD").stdout.strip())
endpoints = []
for revision in (commits[0], "main"):
git("switch", "-q", "--detach", revision)
endpoints.append(verdict())
git("switch", "-q", "main")
print("endpoints-confirmed:", endpoints == ["good", "bad"])
git("bisect", "start", commits[-1], commits[0])
try:
while len(git("rev-list", "refs/bisect/bad", "--not",
"--glob=refs/bisect/good-*").stdout.splitlines()) > 1:
git("bisect", verdict())
found = git("rev-parse", "refs/bisect/bad").stdout.strip()
log.write_text(git("bisect", "log").stdout)
print("boundary-identified:", found == commits[3])
finally:
git("bisect", "reset")
checks = []
for revision in (found, f"{found}^"):
git("switch", "-q", "--detach", revision)
checks.append(verdict())
git("switch", "-q", "main")
print("candidate-and-parent-retested:", checks == ["bad", "good"])
try:
git("bisect", "replay", str(log))
print("log-replays-boundary:", git("rev-parse", "refs/bisect/bad").stdout.strip() == found)
finally:
git("bisect", "reset")
print("original-branch-restored:", git("symbolic-ref", "--short", "HEAD").stdout.strip() == "main"
and git("rev-parse", "HEAD").stdout.strip() == commits[-1]
and not git("status", "--porcelain").stdout)
All five checks should print True. The loop uses explicit good/bad decisions until one candidate remains in this linear, skip-free fixture. Revision three is found, and independent retests show its boundary behavior fails while its parent succeeds. Replaying the saved log reaches the same result without repeating the probe, then reset restores main. The observed patch explains the regression: equality at the limit was removed.
Further reading
Sources
- Git bisect reference, labels, skips, ancestry restrictions, logging, and replay, checked 2026-09-07.
- Pro Git: debugging with Git, testing historical endpoints and inspecting the discovered boundary, checked 2026-09-07.