git bisect run で「どのコミットで壊れたか」を突き止める
64 コミットから git bisect run で退行を探す実測。41 回の確認が 8 回に、ビルドできないコミットは終了コード 125 でスキップ、1 ms→613 ms の性能劣化も同じ手順で二分探索する。
シリーズ:テスト・デバッグ講座 6/6(上級)。 先月は動いていたのに今は壊れていて、その間に数十のコミットがある、という人向けです。この講座のあとは、失敗する確認をスクリプトにして git bisect run に最初の悪いコミットを探させ、ビルドできないコミットを扱い、性能の劣化も同じやり方で追えるようになります。
障害対応でよくある場面から始めます。問い合わせによると、3.33 ドルの商品に 10% 割引をかけると 3.00 ではなく 2.99 と表示される。前回のリリースでは正しかった。その後 63 コミットが入っていて、ほとんどは無害です。差分を全部読むと半日かかり、1 つずつチェックアウトして確かめるのも楽ではありません。git bisect は履歴を二分探索するので確認は log2(N) 回程度で済み、git bisect run は終了コードを返すスクリプトさえあれば確認作業を自動でやってくれます。新人研修で「git は履歴を保存する道具」と習ったなら、ここで「履歴を検索する道具」でもあることを体験してください。ちょうど 64 コミットの使い捨てリポジトリを作り、3 つの問題を隠して、それぞれの探索にかかった回数と時間を測りました。
環境: Git 2.54.0 (Apple Git-157)、Python 3.9.6、/bin/sh、Apple Silicon Mac (M1 Pro)。コミットはすべてデモ用の名前と固定した UTC の日時で作るので、同じ Git のバージョンで下の生成スクリプトを動かせば、記事と同じコミットハッシュになるはずです。パスは ~/demo/… に短縮しています。前回:flaky なテスト。
練習用リポジトリ:3 つの問題を隠した 64 コミット
生成スクリプトは小さな cart.py(関数 2 つ)を作り、64 回コミットします。ほとんどのコミットは version.py と変更履歴を更新するだけで、特別なのは 4 つです。
- 31「Show totals with money() helper」:
from fmt import moneyを足したが、fmt.pyはまだない。31〜34 は import すらできない。 - 35「Add fmt.money」:足りなかったファイルを追加。
- 41「Simplify discount rounding」:本命のバグ。
round(x, 2)が切り捨てに変わる。 - 52「Make unique_skus easier to read」:性能の退行。
setがリストの検索に置き換わる。
#!/bin/sh
# make_repo.sh <dir>: a throwaway repo with 64 commits (macOS sed syntax)
set -eu
export GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_NOSYSTEM=1
export GIT_AUTHOR_NAME=demo GIT_AUTHOR_EMAIL=demo@example.com
export GIT_COMMITTER_NAME=demo GIT_COMMITTER_EMAIL=demo@example.com
rm -rf "$1"; mkdir -p "$1"; cd "$1"; git init -q -b main
cat > cart.py <<'PY'
from version import VERSION
def total(prices, discount_pct=0):
"""Sum prices (in dollars) and apply a percentage discount, rounded to cents."""
subtotal = sum(prices)
return round(subtotal * (100 - discount_pct) / 100, 2)
def unique_skus(skus):
"""Keep the first occurrence of each SKU, in order."""
seen, out = set(), []
for s in skus:
if s not in seen:
seen.add(s)
out.append(s)
return out
PY
i=1
while [ $i -le 64 ]; do
echo "VERSION = $i" > version.py
echo "- change $i" >> CHANGELOG.md
case $i in
31) sed -i '' 's/^from version import VERSION$/&\nfrom fmt import money # added in 31/' cart.py ;;
35) printf 'def money(x):\n return f"${x:.2f}"\n' > fmt.py ;;
41) sed -i '' 's/return round(subtotal \* (100 - discount_pct) \/ 100, 2)/return int(subtotal * (100 - discount_pct)) \/ 100/' cart.py ;;
52) sed -i '' -e 's/seen, out = set(), \[\]/out = []/' -e 's/if s not in seen:/if s not in out:/' -e '/seen.add(s)/d' cart.py ;;
esac
case $i in
1) msg="Add cart with total() and unique_skus()" ;; 31) msg="Show totals with money() helper" ;;
35) msg="Add fmt.money" ;; 41) msg="Simplify discount rounding" ;;
52) msg="Make unique_skus easier to read" ;; *) msg="Update changelog ($i)" ;;
esac
ts=$(( 1793836800 + i * 3600 )) # 2026-11-05 00:00 UTC + i hours
git add -A
GIT_AUTHOR_DATE="@$ts +0000" GIT_COMMITTER_DATE="@$ts +0000" git commit -q -m "$msg"
i=$((i + 1))
done
GIT_CONFIG_GLOBAL=/dev/null で自分の Git 設定(フック、署名、エイリアス)を実験から外し、固定した日時でハッシュを再現可能にしています。git bisect reset で元に戻れることを確かめるまでは、大事なリポジトリで bisect を練習しないでください。
テストスクリプト:インターフェースは終了コードだけ
git bisect run はコミットをチェックアウトしてコマンドを動かし、その終了コードだけを読みます。今回の確認スクリプトです。
#!/bin/sh
# check_total.sh: lives OUTSIDE the repo, so checking out old commits cannot change it.
# exit 0 = good, 1 = bad, 125 = cannot test this commit (skip)
python3 -c "import cart" 2>/dev/null || exit 125
python3 - <<'PY'
import sys, cart
got = cart.total([3.33], discount_pct=10)
sys.exit(0 if got == 3.0 else 1)
PY
| 終了コード | git bisect run での意味 |
よくある発生源 |
|---|---|---|
| 0 | good | 確認が通った |
| 1〜127(125 を除く) | bad | アサーション失敗、テストランナーが失敗を報告 |
| 125 | スキップ:このコミットはテストできない | ビルドできない、依存が欠けている |
| 126、127 | bad。ただし Git 2.54 は先に good コミットで再確認する | 権限なし、コマンドが見つからない(スクリプトの打ち間違い) |
| 128 以上 | bisect を中止 | スクリプト自体がクラッシュした、強制終了された |
この表から 2 つのルールが出てきます。スクリプトはリポジトリの外(または追跡されていないファイル)に置くこと。でないと古いコミットをチェックアウトした途端に、探索の途中でスクリプトが書き換わったり消えたりします。そして「テストできない」とは何かを意識して決め、ビルドの問題では 1 ではなく 125 を返すことです。
探索:8 回、0.49 秒
最新のコミットを bad、最初のコミットを good として、スクリプトを渡しました。
$ git bisect start main 0129d59
$ git bisect run ../check_total.sh
Bisecting: 31 revisions left to test after this (roughly 5 steps)
[b8afa63c5781266c73dee632f2e57ff2645db3ec] Update changelog (32)
running '../check_total.sh'
Bisecting: 30 revisions left to test after this (roughly 5 steps)
[22d5c50674ffd3cfd696345963e7b32e8d9e3442] Update changelog (33)
running '../check_total.sh'
Bisecting: 30 revisions left to test after this (roughly 5 steps)
[2faf604004b11123e9c19505a110323dd390ec8f] Update changelog (40)
running '../check_total.sh'
Bisecting: 11 revisions left to test after this (roughly 4 steps)
[6ca4e3b711ecad8482f445fdd802702555ed2990] Make unique_skus easier to read
…
Bisecting: 0 revisions left to test after this (roughly 0 steps)
[497854fdc48159eedda284b046a4363da0bb985b] Simplify discount rounding
running '../check_total.sh'
497854fdc48159eedda284b046a4363da0bb985b is the first bad commit
commit 497854fdc48159eedda284b046a4363da0bb985b
Author: demo <demo@example.com>
Date: Fri Nov 6 17:00:00 2026 +0000
Simplify discount rounding
…
bisect found first bad commit
物語として読みましょう。Git が最初に選んだコミット 32 はちょうど真ん中ですが、ビルドできない範囲なのでスクリプトは 125 で終わりました。残りは 31 から 30 に減っただけです。スキップしたコミットは候補から外れるだけで、範囲を半分にはしません。次の 33 もビルドできず、Git は壊れた区間から離れて 40(good)、52(bad)、46、43、42、41(すべて bad)と進みました。スクリプトの実行は8 回、そのうち 2 回がスキップです。最後にこうします。
git bisect log > ~/demo/bisect.log # 判断の記録。git bisect replay で再生できる
git bisect reset # 始める前の main に戻る
原因コミットの差分は 2 行で、round(…, 2) が int(…) / 100 に変わっていました。3.33 × 90 = 299.7 を切り捨てて 299、つまり 2.99 ドルです。
全コミットを順に調べる方法との比較。 古い順に 1 つずつチェックアウトし、終了コード 1 が出たら止まるループは、41 回のチェックアウトで 2.98〜3.07 秒(3 回測定)。git bisect run は 8 回、0.47〜0.51 秒でした。秒数より比率が大事です。今回の確認は 1 回およそ 60 ms ですが、もしビルドとテストに 5 分かかるなら、41 回は 3 時間以上、8 回は 40 分です。
ステップ数と log2。 「log2」の主張を確かめるため、VERSION >= K なら bad とする別のスクリプトで、原因の位置 K = 2〜64 をすべて、スキップなしで bisect しました。62 通りが 6 回、1 通りが 5 回(平均 5.98)、63 通りすべて正解。 log2(64) = 6 です。履歴が 128 コミットに倍増しても、増えるのは 1 回だけで 64 回ではありません。
テストできないコミットのスキップと、しなかった場合
よくある間違い → 実際に起きること → 直し方。 || exit 125 の行を消すと、import エラーはただの Python の失敗、つまり終了コード 1 になります。同じリポジトリ、同じバグで:
ModuleNotFoundError: No module named 'fmt'
…
dda1d4a1afa7cc00da8fadaa54ddf6d2fe25b05c is the first bad commit
Show totals with money() helper
Git は自信満々にコミット 31 を犯人にします。ビルドは壊したものの、丸めには触れていないコミットです。直し方は 125 の行です。「このコミットは判定できない」と「このコミットは悪い」は別物です。
スキップにも限界があります。原因がスキップしたコミットの隣にあると、Git はそれ以上絞り込めません。バグがビルドできない区間内のコミット 33 で入ったことにして試しました。
There are only 'skip'ped commits left to test.
The first bad commit could be any of:
22d5c50674ffd3cfd696345963e7b32e8d9e3442
b8afa63c5781266c73dee632f2e57ff2645db3ec
611340bd60d10b72a26966fce29c32a0923c4425
dda1d4a1afa7cc00da8fadaa54ddf6d2fe25b05c
14e91fd0044a40f3cf34876f640bdad31c3e041e
We cannot bisect more!
error: bisect run cannot continue any more
それでも 63 個から 5 個に減っているので役に立ちます。さらに絞るには、壊れたコミットをテスト可能にして(たとえばスクリプトが足りない fmt.py を一時的に作る)、もう一度回します。
実際にはまったスクリプトの罠 2 つ。 1 つ目は打ち間違いの pyhton3 で、全コミットで終了コード 127 になります。Git 2.54 は最初のコミットを犯人にせず、good のコミットでスクリプトを再実行して error: bogus exit code 127 for good revision で止まりました。古い Git は 127 を単に bad として数えていたので、自分のバージョンの git help bisect を確認してください。終了コード 129 なら error: bisect run failed: exit code 129 from '../check_129.sh' is < 0 or >= 128 で止まりました。2 つ目は、リポジトリの外に置いた Python スクリプト(python3 ../bench_skus.py)が全コミットで ModuleNotFoundError: No module named 'cart' になったことです。Python が sys.path に入れるのはカレントディレクトリではなくスクリプトのあるディレクトリだからで、PYTHONPATH=. python3 ../bench_skus.py で直りました。前に || exit 125 があったら全コミットがスキップされていたはずです。bisect run の前に、good と bad のコミット 1 つずつで手動でスクリプトを試しましょう。
性能の退行を bisect する
正しさの確認は終了コードにしやすいのですが、速さにはしきい値が要り、しきい値には先に測定が要ります。
# bench_skus.py: time cart.unique_skus on fixed data, print the best of 5 in ms
import random, time, cart
rng = random.Random(42)
skus = [f"SKU-{rng.randrange(10_000):05d}" for _ in range(20_000)]
best = min(
(lambda t0: (cart.unique_skus(skus), time.perf_counter() - t0)[1])(time.perf_counter())
for _ in range(5)
)
print(f"{best * 1000:.2f}")
コミット 51 で 10 回測ると 0.98〜1.01 ms(中央値 0.99)、コミット 52 では 612.65〜613.58 ms。リストの検索で、20,000 件に対して O(n²) になっています。2 つの集団の間ならどこでもよく、どちらからも遠い 50 ms を選びました。出力が意図どおりの言葉になるよう、Git の独自の用語を使います。
#!/bin/sh
# check_speed.sh: fast (exit 0) under LIMIT_MS, slow (exit 1) over it
ms=$(PYTHONPATH=. python3 ../bench_skus.py) || exit 125
echo "unique_skus: $ms ms (limit ${LIMIT_MS:-50})"
python3 -c "import sys; sys.exit(0 if $ms < ${LIMIT_MS:-50} else 1)"
$ git bisect start --term-old=fast --term-new=slow
$ git bisect slow main
$ git bisect fast 0129d59
$ git bisect run ../check_speed.sh
…
unique_skus: 0.99 ms (limit 50)
…
unique_skus: 613.02 ms (limit 50)
…
6ca4e3b711ecad8482f445fdd802702555ed2990 is the first slow commit
8 回、4.06 秒で、時間の大半は遅いコミットでの計測です。現実の退行が 600 倍ということはまずありません。10〜20% の劣化なら、繰り返し回数を増やして最良値を取り、マシンを静かにし、しきい値の両側を手で確認してから実行結果を信じてください。原因コミットが分かったあと、コードのどこで時間を使っているかは Python のプロファイリング と、C なら キャッシュに優しいループと lldb を参照してください。
flaky な確認は bisect に嘘をつかせる
bisect は確認が決定的であることを前提にしています。本物の確認に 10% の偽の失敗を混ぜ(実行とコミットごとにシードを決め、結果を再現可能にした)、50 回 bisect しました。46 回はコミット 41 を見つけ、4 回はコミット 15・38・40・40 を犯人にしました。 good のコミットで偽の bad が 1 回出るだけで探索は間違った半分に進み、Git はそれを二度と確かめません。3 回連続で失敗したときだけ bad とするラッパーでは 50 回中 49 回正解(1 回はやはり 40 を指した)でした。リトライは被害を減らしますが、なくしはしません。先に flaky テストを 講座 5 の方法で直しましょう。
知っておくと便利な選択肢:git bisect start --first-parent(Git 2.29 以降)はメインラインのマージコミットだけをたどるので、機能ブランチの途中に壊れたコミットがあるときに役立ちます。git bisect skip <範囲> は壊れていると分かっている範囲を手でスキップします。bisect が巨大なコミットにたどり着いたら、コミットを小さくするサインです。rebase と cherry-pick がその助けになります。犯人を見つけて共有ブランチで安全に取り消すなら git revert と reflog を。
振り返りの習慣と、シリーズのチェックリスト
コミットを見つけるのは話の途中で、終わりではありません。退行のあとは、誰かを責めない形で、次の項目の短い振り返り(ポストモーテム)を書きます。
| 項目 | この講座での例 |
|---|---|
| 症状と最初の報告 | 3.33 ドルの 10% 割引が 2.99 と表示される |
| 原因と見つけ方 | コミット 497854f、git bisect run、8 回、bisect.log を添付 |
| テストが捕まえられなかった理由 | 小数第 3 位が 5 以上になる値のテストがなかった |
| 追加した退行テスト | total([3.33], 10) == 3.0 を恒久的にスイートへ |
| 次回を安くするもの | 確認スクリプトを tools/ に保管、コミットを小さくビルド可能に |
最後の行は飛ばされがちです。bisect 用のスクリプトをリポジトリに残しておけば(古いコミットにはないパスに置くか、使う前に外へコピーする)、次の探索は 1 分で始められます。堅牢なシェルスクリプトとログ のように、確認を毎晩 cron でログ付きで回せば、退行が入ったその日に捕まえられます。
テスト・デバッグ講座のチェックリスト。 人に助けを求める前、またはバグを閉じる前に:
正直な限界・バージョンの注記
- 実行したのは Git 2.54.0 (Apple Git-157) と Python 3.9.6 のみです。
git bisect runはかなり昔からあり、--term-old/--term-newは Git 2.7、--first-parentは Git 2.29 で入りました。126/127 の再確認は 2.54 で観察した動作で、古いリリースでは試していません。 - 生成スクリプトの
sed -i ''は macOS/BSD の書き方です。GNU sed では空文字列なしのsed -iを使います(ここでは未実行)。 - スキップのあと Git がどのコミットを選ぶかは内部の選び方しだいです。履歴が違えば順番は変わり得ますが、答えは変わらないはずです。
- 時間は 1 台のマシンでの値です。1 回あたりのコストはほぼプロセスの起動なので、覚えておくべきは 41 回と 8 回の比率です。
- シリーズ全体の位置づけは プログラミング学習ロードマップ へ。
練習問題
- 確認スクリプトが
make testを実行します。古いコミットの一部ではmakeが「No rule to make target」で失敗します。そのときスクリプトはどの終了コードを返すべきで、テストの失敗とどう区別しますか? - 最後の正常なリリースから今日まで 1,000 コミットあります。
git bisect runはおおよそ何回確認が必要で、何があるとその数がずっと大きくなりますか?
解答
- 先にビルドだけを実行し、失敗したら
exit 125。そのあとでテストを動かし、その終了コード(またはどんな失敗でもexit 1)を返します。「ビルドできない」と「テストが落ちる」を分けることが、上で見た犯人違いを防ぎます。 - およそ log2(1000) ≈ 10 回。スキップされるコミットが多い(それぞれ範囲を絞らずに回数だけ増やす)ことと、flaky な確認(探索を間違った半分に送り得る)が、回数を増やしたり答えを間違えさせたりします。
前へ: テスト・デバッグ講座 5:flaky なテスト · シリーズ最終回です。講座 1:エラーメッセージとスタックトレースの読み方 からもう一度どうぞ。
よくある質問
git bisect run はスクリプトのどの終了コードを期待しますか?
0 は good、125 を除く 1〜127 は bad、125 はこのコミットはテストできないのでスキップ、128 以上は bisect を中止します。Git 2.54.0 では終了コード 126 や 127(コマンドが見つからない)のとき、既知の good コミットでスクリプトを再実行し、そこでも失敗すると間違ったコミットを指す代わりに 'error: bogus exit code 127 for good revision' で止まりました。
git bisect は何ステップ必要ですか?
範囲内のコミット数の log2 程度です。64 コミットなら 6。64 コミットのリポジトリで原因の位置 63 通りをすべて bisect したところ、62 通りがスクリプト 6 回、1 通りが 5 回で、答えはすべて正解でした。終了コード 125 でスキップしたコミットは回数を増やします。ビルドできないコミットが 2 つ混ざった実際の探索では 8 回でした。
git bisect で性能の劣化も探せますか?
探せます。コードの時間を測り、しきい値未満なら 0、超えたら 1 で終わるスクリプトを書き、git bisect start --term-old=fast --term-new=slow で始めます。しきい値は実測した 2 つの水準の間に置きます。今回は劣化前 0.98〜1.01 ms、劣化後 612.65〜613.58 ms だったので 50 ms で安全でした。8 回、4.06 秒で 'is the first slow commit' と表示されました。