Nine local commits produced one exact boundary: the search encountered a midpoint it could not judge, skipped that revision, and still identified 5e1ee2edd2bb... as the first bad commit. The predicate—an executable test command that labels each selected revision—then returned 0 for its parent and 1 for the reported culprit, while git bisect reset restored the original main branch and clean worktree.
Trust in that result depends on a strict contract. Give Git a genuinely known-good endpoint, a genuinely known-bad endpoint and a deterministic command that classifies every testable revision the same way. Return 125 when setup or source state makes a revision untestable; do not quietly label that failure as the regression.
Developers and build engineers following this practical guide should be able to use a shell, read commit history and interpret process exit statuses. The lab ran on Linux with Git 2.47.3 and Bash. It creates no remote, uses no credentials, changes no global Git configuration and works only inside marker-owned /tmp/voxfor-git-bisect-147-lab. Do not point the commands at a production checkout with uncommitted work.
git bisect performs a binary search across commit history. After you label an older revision good and a newer revision bad, Git checks out a midpoint; each answer removes part of the remaining range. git bisect run replaces the human answer with a command whose exit status becomes the label.
Current Git bisect documentation defines four outcomes. The distinction between bad and untestable is the most important one for automation:
| Predicate outcome | Exit status | Git action | Reader meaning |
|---|---|---|---|
| Property is absent | 0 |
mark good/old | the regression has not appeared |
| Property is present | 1–127, except 125 |
mark bad/new | the regression is reproducible |
| Revision cannot be judged | 125 |
skip | setup or source state cannot answer |
| Predicate aborts | outside the accepted range | stop the run | repair the test before trusting history |
Shell statuses 126 and 127 technically fall inside Git’s bad range, even though shells commonly use them for “not executable” and “command not found.” A robust wrapper should catch dependency or build failures and deliberately return 125 when those failures are unrelated to the property under investigation. Jesse Duffield’s worked explanation of exit 125 makes the same separation visible in a smaller script.
Automation cannot rescue false endpoints. Before starting, run the predicate once at the chosen good revision and once at the chosen bad revision. Expect 0 and a normal bad status respectively. This resembles Voxfor’s fail-closed Ansible second-run check: a command completing is not enough; the observed state must match the exact acceptance condition.
Ownership comes before the first write. If the path already exists, deletion is allowed only when the expected marker is present. A repository-local identity prevents the fixture from reading or changing your global author configuration.
Run all seven tested Bash blocks in one shell session and in order. set -Eeuo pipefail stops on an unset variable, failed assertion or hidden pipeline failure.
set -Eeuo pipefail
LAB_ROOT=/tmp/voxfor-git-bisect-147-lab
MARKER="$LAB_ROOT/.voxfor-git-bisect-147"
REPO="$LAB_ROOT/repo"
PREDICATE="$LAB_ROOT/test-regression.sh"
if test -e "$LAB_ROOT"; then
test -f "$MARKER"
test "$(<"$MARKER")" = voxfor-git-bisect-147-v1
find "$LAB_ROOT" -xdev -depth -delete
fi
mkdir -m 700 "$LAB_ROOT"
printf '%s\n' voxfor-git-bisect-147-v1 > "$MARKER"
command -v git >/dev/null
git --version
git init -q -b main "$REPO"
git -C "$REPO" config user.name 'Voxfor Lab'
git -C "$REPO" config user.email 'lab'@'example.invalid'
One configuration value drives the history: a workload needs a limit of at least 50. Commits 1–4 remain good, commit 5 contains a deliberately nonnumeric value, commit 6 restores a valid good value, and commit 7 lowers the limit to 40. Two later commits stay bad without moving the boundary.
Keeping the untestable commit before a restored good commit matters. A skipped revision immediately beside the true boundary can make the answer ambiguous; this range lets Git skip commit 5 and still prove commit 6 good before it reaches commit 7.
commit_state() {
local sequence=$1 message=$2 content=$3
printf '%s\n' "$content" > "$REPO/app.env"
git -C "$REPO" add app.env
GIT_AUTHOR_DATE="2026-01-01T00:${sequence}:00Z" \
GIT_COMMITTER_DATE="2026-01-01T00:${sequence}:00Z" \
git -C "$REPO" commit -q -m "$message"
}
commit_state 01 'baseline accepts 50 requests' 'LIMIT=100'
GOOD=$(git -C "$REPO" rev-parse HEAD)
commit_state 02 'document the workload' $'LIMIT=100\nPROFILE=steady'
commit_state 03 'refactor the parser' $'PROFILE=steady\nLIMIT=90'
commit_state 04 'reduce unused headroom' $'PROFILE=steady\nLIMIT=80'
commit_state 05 'temporary format cannot be tested' $'PROFILE=steady\nLIMIT=pending'
UNTESTABLE=$(git -C "$REPO" rev-parse HEAD)
commit_state 06 'restore a valid threshold' $'PROFILE=steady\nLIMIT=70'
commit_state 07 'lower threshold below workload' $'PROFILE=steady\nLIMIT=40'
EXPECTED_BAD=$(git -C "$REPO" rev-parse HEAD)
commit_state 08 'rename an unrelated profile' $'PROFILE=normal\nLIMIT=40'
commit_state 09 'document the new profile' $'PROFILE=normal\nLIMIT=40\nNOTE=release'
BAD=$(git -C "$REPO" rev-parse HEAD)
test "$(git -C "$REPO" rev-list --count HEAD)" -eq 9
printf 'HISTORY commits=9 known_good=%s known_bad=%s expected_first_bad=%s untestable=%s\n' \
"${GOOD:0:12}" "${BAD:0:12}" "${EXPECTED_BAD:0:12}" "${UNTESTABLE:0:12}"
git -C "$REPO" log --reverse --format='%h %s'
Nine commits are enough to expose several search decisions without turning the article into a synthetic benchmark. Real repositories may have thousands of candidates; binary search reduces the number of test executions, but each execution still needs to rebuild or isolate every artifact that can affect the result.
LIMIT supplies the predicate’s only input from the currently checked-out revision. A missing or nonnumeric value cannot answer whether the workload succeeds, so the script prints UNTESTABLE and returns 125. Numeric values at least 50 return 0; lower values return 1.
cat > "$PREDICATE" <<'PREDICATE'
#!/usr/bin/env bash
set -u
repo=${1:?repository path required}
value=$(sed -n 's/^LIMIT=//p' "$repo/app.env")
if ! [[ $value =~ ^[0-9]+$ ]]; then
printf 'UNTESTABLE commit=%s limit=%s\n' \
"$(git -C "$repo" rev-parse --short=12 HEAD)" "${value:-missing}"
exit 125
fi
if (( value >= 50 )); then
printf 'GOOD commit=%s limit=%s required=50\n' \
"$(git -C "$repo" rev-parse --short=12 HEAD)" "$value"
exit 0
fi
printf 'BAD commit=%s limit=%s required=50\n' \
"$(git -C "$repo" rev-parse --short=12 HEAD)" "$value"
exit 1
PREDICATE
chmod 700 "$PREDICATE"
bash -n "$PREDICATE"
Your production predicate may compile a binary, run one focused test, query a benchmark or boot a disposable environment. Keep it self-contained. Thoughtbot demonstrates the basic pattern with git bisect run and RSpec, while Artem Khvastunov’s worked Java example initially found the wrong culprit because the code had not been recompiled after each checkout. Cached output from another commit is not a test optimization; it is false evidence.
Now challenge both endpoint labels before handing control to Git. The block detaches at each exact hash, records the status and returns to main. It temporarily disables set -e only around the expected bad result.
git -C "$REPO" switch -q --detach "$GOOD"
"$PREDICATE" "$REPO"
GOOD_STATUS=$?
git -C "$REPO" switch -q --detach "$BAD"
set +e
"$PREDICATE" "$REPO"
BAD_STATUS=$?
set -e
test "$GOOD_STATUS" -eq 0
test "$BAD_STATUS" -eq 1
printf 'ENDPOINTS known_good_exit=%s known_bad_exit=%s\n' \
"$GOOD_STATUS" "$BAD_STATUS"
git -C "$REPO" switch -q main
A false “good” baseline can exclude the real culprit; a false “bad” endpoint can search for a property that is no longer present. If either status differs, stop and repair the predicate or choose different endpoints. Do not relabel the commits just to make bisection start.
Save the branch and head before git bisect start. Git checks out commits during the search, so a detached HEAD is expected. The run writes both the terminal transcript and git bisect log, then asserts three facts: the calculated bad ref equals the known regression commit, the untestable hash appears in the log, and the terminal transcript contains the first-bad line.
START_BRANCH=$(git -C "$REPO" branch --show-current)
START_HEAD=$(git -C "$REPO" rev-parse HEAD)
git -C "$REPO" bisect start "$BAD" "$GOOD"
git -C "$REPO" bisect run "$PREDICATE" "$REPO" 2>&1 \
| tee "$LAB_ROOT/bisect-run.log"
git -C "$REPO" bisect log > "$LAB_ROOT/bisect.log"
CULPRIT=$(git -C "$REPO" rev-parse refs/bisect/bad)
test "$CULPRIT" = "$EXPECTED_BAD"
grep -Fq "$UNTESTABLE" "$LAB_ROOT/bisect.log"
grep -Fq 'is the first bad commit' "$LAB_ROOT/bisect-run.log"
printf 'BISECT first_bad=%s expected_match=yes skip_seen=yes\n' \
"${CULPRIT:0:12}"
Christian Couder’s LWN account of fully automated bisection shows the same core idea at kernel scale: Git selects a revision, a command evaluates it, and the exit status feeds the next decision. The local receipt below is intentionally smaller, so every state is inspectable.
HISTORY commits=9 known_good=be95704e6945 known_bad=4a155cf7181e expected_first_bad=5e1ee2edd2bb untestable=c5e5f35a3074
ENDPOINTS known_good_exit=0 known_bad_exit=1
UNTESTABLE commit=c5e5f35a3074 limit=pending
GOOD commit=ebccd481461e limit=70 required=50
BAD commit=4e09643ffd5a limit=40 required=50
BAD commit=5e1ee2edd2bb limit=40 required=50
5e1ee2edd2bb1537a75bb9b3b72b6f282b01fb8c is the first bad commit
BISECT first_bad=5e1ee2edd2bb expected_match=yes skip_seen=yes
BOUNDARY parent=ebccd481461e parent_exit=0 culprit=5e1ee2edd2bb culprit_exit=1
RESET branch=main original_head=4a155cf7181e restored=yes clean=yes
CLEANUP path_absent=yes scope=marker-owned-lab-only
Five predicate executions were needed inside this automated run because one selected midpoint was skipped. Test count stays logarithmic only when history and labels support the binary-search model; more skips can increase work or prevent a unique answer.
“First bad commit” means the predicate changed between a reported revision and the good ancestry Git established. It does not prove the author intended the bug, that one changed line is the root cause, or that an external dependency played no role.
Recheck the boundary with the same predicate. The current checkout is the culprit after a successful run. A detached worktree at its parent provides an independent good comparison without disturbing bisection state.
set +e
"$PREDICATE" "$REPO"
CULPRIT_STATUS=$?
set -e
test "$CULPRIT_STATUS" -eq 1
git -C "$REPO" worktree add -q --detach "$LAB_ROOT/parent" "$CULPRIT^"
"$PREDICATE" "$LAB_ROOT/parent"
PARENT_STATUS=$?
test "$PARENT_STATUS" -eq 0
printf 'BOUNDARY parent=%s parent_exit=%s culprit=%s culprit_exit=%s\n' \
"$(git -C "$LAB_ROOT/parent" rev-parse --short=12 HEAD)" \
"$PARENT_STATUS" "${CULPRIT:0:12}" "$CULPRIT_STATUS"
git -C "$REPO" worktree remove -f "$LAB_ROOT/parent"
The search is verified when the saved good endpoint exits 0, the bad endpoint exits 1, the log records the deliberately untestable hash, refs/bisect/bad equals the expected regression commit, its parent exits 0 and the culprit exits 1 with the same predicate. The reproduced run satisfied every condition and reported parent ebccd481461e... before culprit 5e1ee2edd2bb....
That two-sided check follows a wider operations principle: tooling status and workload acceptance are separate. Voxfor’s live-traffic reload acceptance test applies the distinction to HAProxy; here, the comparable evidence is a good parent and bad child rather than a successful git bisect run message alone.
Inspect the diff only after the boundary holds. Start with git show --stat "$CULPRIT" and the changed paths relevant to the predicate, then consider generated files, dependency locks, test fixtures and environment inputs. The hash narrows the investigation; it does not replace it.
Binary search assumes the tested property changes in one direction across the selected ancestry. If a bug appears in commit 20, disappears in 30 and returns in 40, “good” and “bad” do not describe one boundary. Flaky tests are worse: the same commit can feed contradictory labels into the search.
Stabilize time, random seeds, network dependencies, concurrency and data fixtures before automating. Performance predicates need declared warm-up, sample size and tolerance; a single noisy latency comparison is not monotonic evidence. When uncertainty remains, repeat the predicate and require a defined consensus instead of translating the first failure into “bad.”
Merge-heavy histories add another decision. First-parent bisection answers which mainline merge introduced observable behavior; full-DAG bisection may enter a topic branch. Pathspecs can reduce candidates only when commits outside the path truly cannot affect the property. Memfault’s bisection field guide discusses merge and build complications in practical embedded work.
An isolated CI environment can make an expensive predicate repeatable, but isolation must already be designed before arbitrary historical code executes. Use a dedicated user, restricted secrets and disposable workspace if you move the script to Voxfor’s isolated self-hosted runner. Never expose current deployment credentials to untrusted old revisions merely because the search is read-only in Git.
Once the culprit is understood and fixed, regression localization still does not deploy anything. Place the new test before any deployment workflow with explicit rollback so the same behavior cannot re-enter during a later release.
git bisect run checks out candidate commits and runs one command at each selection. The command’s exit status labels the revision good, bad or untestable; Git uses those labels to narrow the known-good to known-bad range until it can report the first transition.
Return 0 for good, 1–127 except 125 for bad, and 125 only when the revision cannot be judged. Catch ordinary setup failures explicitly; otherwise a missing executable status can be mistaken for evidence that the regression exists.
Endpoint checks prove that the predicate can see both states before Git starts changing revisions. If the “good” commit fails or the “bad” commit passes, the selected range, environment or test definition is wrong, so an automated answer would be meaningless.
Yes. Return 125 when the build failure prevents judging the target behavior and is not itself the regression you are searching for. Too many skipped commits—or one adjacent to the real boundary—can leave Git unable to name a unique first bad commit.
No. It proves a reproducible state transition under the supplied predicate. Verify the parent and culprit, inspect the diff and account for dependency, fixture and environment changes before attributing cause.
Save git bisect log outside the checkout if the evidence belongs in an incident or pull request. Then run git bisect reset and confirm the original branch, original head and clean worktree before deleting only marker-owned lab files.
git bisect reset normally returns to the HEAD that was active before git bisect start. The final block asserts the saved branch and hash, requires a clean worktree, and only then removes the marker-owned lab. Readers turning this lab into a recurring engineering practice can use Voxfor’s DevOps operations library to find adjacent CI, observability and rollback workflows; keep this bisection receipt attached to the specific regression investigation.
If the predicate, endpoints or result become questionable, stop without assigning blame: save git bisect log, run git bisect reset, confirm the original branch and head, and repair the test in a separate clean workspace. Resetting exits bisection; it does not undo commits, rewrite history or push anything to a remote.
git -C "$REPO" bisect reset >/dev/null
test "$(git -C "$REPO" branch --show-current)" = "$START_BRANCH"
test "$(git -C "$REPO" rev-parse HEAD)" = "$START_HEAD"
test -z "$(git -C "$REPO" status --porcelain)"
printf 'RESET branch=%s original_head=%s restored=yes clean=yes\n' \
"$START_BRANCH" "${START_HEAD:0:12}"
test "$LAB_ROOT" = /tmp/voxfor-git-bisect-147-lab
test -f "$MARKER"
test "$(<"$MARKER")" = voxfor-git-bisect-147-v1
find "$LAB_ROOT" -xdev -depth -delete
test ! -e "$LAB_ROOT"
printf '%s\n' 'CLEANUP path_absent=yes scope=marker-owned-lab-only'
Branch main and head 4a155cf7181e... were restored, git status --porcelain was empty, and the guarded lab path was absent. Those checks close the operation: the first-bad boundary remains in the saved evidence, while the working repository no longer remains in bisection state.