mainのCIは赤いが、昨日はグリーンだった。その間のどこかでバグが忍び込んだ。200個のコミットをスクロールしてdiffを読み、推測することもできる。Slackで聞いて、誰かが関連コードに触れたことを覚えていることを祈ることもできる。あるいは、Gitに仕事を任せることもできる。
git bisect はコミット履歴向けの二分探索ツールだ。1つのコミットをbadとして、1つをgoodとしてマークする。Gitは中間点をチェックアウトする。テストしてgoodかbadかをマークすると、Gitは繰り返す。8ステップで256個の中から1つを絞り込める。12ステップで4,096個の中から針を見つけられる。これはGitが持つ、履歴そのものへのデバッガーに最も近いものだ。
なぜ手動の二分探索は時間の無駄なのか
開発者は気づかずにすでに手動で二分探索を行っている。古いコミットをチェックアウトしてテストを実行し、「まだ壊れている。もっと遡らないと」と考える。さらに古いものをチェックアウトすると、テストが合格する。バグがその2点の間にあることがわかる。そこで中間を選んで絞り込みを続ける。
それが二分探索だ。git bisect は記録作業を自動化し、すでにテストしたコミットを見失うことを防ぐ。さらに重要なのは、「おそらく火曜日の大きなrefactorにあったはずだ」という怠惰な推論を防ぐことだ。それは2時間間違った方向へ進ませる。
真の価値はスピードではない。確実さだ。午後6時にイライラしながらバグを追っていると、ミスをする。チェックアウト後にリビルドし忘れる。同じコミットを2回テストする。テスト結果を誤読してbadコミットをgoodとマークし、探索空間を破壊する。git bisect は、イライラしているときには持ち合わせていない規律を enforcement する。
二分探索が実際にどう動作するのか
Gitは時系列で探索するわけではない。トポロジカルに探索する。既知のgoodとbadのコミット間の中間点を見つけるため、コミットグラフを歩く。履歴にマージがある場合、これは重要だ。なぜなら、時系列の中間点は両方のエンドポイントから到達できない場合があるからだ。
内部で起こっていることを説明しよう。bisectを開始し、境界を指定する。
git bisect start
git bisect bad HEAD # 現在のコミットは壊れている
git bisect good v2.1.0 # このリリースは正常だった
Gitは2点間のコミット数を計算する。真ん中のコミットをチェックアウトし、テストするのを待つ。再現を実行し、バグが存在するかどうかを確認してから、Gitに伝える。
git bisect bad # このコミットにバグがある
git bisect good # このコミットは正常だ
Gitは探索空間の半分を破棄して繰り返す。残り1つのコミットだけになったら停止し、最初のbadコミットを表示する。出力にはコミットハッシュ、作者、日付、メッセージが含まれる。曖昧さはない。このコミットがバグを導入した。議論の余地はない。
実際のコマンドによる具体的な手順
統合テストが今朝から失敗し始めたと仮定する。最後のタグ付きリリース v1.4.0 では合格していたことがわかっている。完全なセッションは以下の通りだ。
# セッションを開始
git bisect start
# 現在のHEADをbadとしてマーク
git bisect bad HEAD
# 最後に確認された正常なリリースをマーク
git bisect good v1.4.0
# Gitが自動的に中間のコミットをチェックアウトする
# 再現スクリプトまたはテストスイートを実行:
npm test -- --grep "checkout flow"
# テストが失敗。badとマーク。
git bisect bad
# Gitが別の中間点をチェックアウトする。再びテストを実行。
npm test -- --grep "checkout flow"
# テストが合格。goodとマーク。
git bisect good
# 繰り返す。Gitが以下を告げるまで:
# "<commit-hash> is the first bad commit"
最後に、Gitはbadコミットに残す。git show で調査し、修正を作成してからクリーンアップする。
git bisect reset
これにより、bisectを開始する前にいたブランチに戻る。resetし忘れると、detached HEADのままになり、次のコミットがブランチに乗らない理由がわからなくなる。私はやった。恥ずかしい話だ。
スクリプトによる完全自動化
手動版でも、テストを実行して good や bad を繰り返し入力する必要がある。再現が、成功時にexit code 0、失敗時に非ゼロを返す単一のコマンドなら、すべてをGitに任せられる。
git bisect start
git bisect bad HEAD
git bisect good v1.4.0
# 制御を自動化スクリプトに委譲
git bisect run npm test -- --grep "checkout flow"
Gitはコミットをチェックアウトし、コマンドを実行し、結果を自動的に分類する。完了すると、キーボードに触れることなく同じ「first bad commit」の出力が得られる。ここで git bisect は、有用から不可欠へと変わる。
スクリプトはテストスイートである必要はない。意味のあるexit codeを返すあらゆる実行可能ファイルでよい。特定のログメッセージをチェックする最小限のシェルスクリプトは以下の通りだ。
#!/bin/bash
# reproduce-bug.sh
# バグが存在しない場合はexit 0(good)
# バグが存在する場合はexit 1(bad)
if curl -s http://localhost:3000/api/health | grep -q "database_timeout"; then
exit 1 # バグが存在する
fi
exit 0 # バグは存在しない
実行する。
chmod +x reproduce-bug.sh
git bisect run ./reproduce-bug.sh
exit codeの契約は厳格だ。exit 0は「good」、exit 1〜124は「bad」、exit 125は「このコミットをスキップ。テスト不可だ。」exit 125は、コミットがコンパイルできない場合や、無関係な設定変更でサーバーが起動できない場合に有用だ。Gitはそのコミットをスキップし、周囲を探索する。
bisectが破綻する場所: 落とし穴
git bisect はバグが単調であることを前提としている。コミットがバグを導入したら、その子孫コミットもすべてbadだ。バグがちらつき、コミット間で現れたり消えたりする場合、二分探索は崩壊する。意味のない結果が出たり、Gitが単一のコミットを絞り込めないと文句を言ったりする。
非決定論的なバグが最悪の犯罪者だ。10%の確率で失敗する競合状態は、時折badコミットをgoodとしてマークし、探索を破壊する。再現が不安定な場合、まず不安定性を修正する。あるいはスクリプト内でテストを複数回実行し、すべての実行で合格した場合にのみコミットをgoodとマークする。これは遅いが、より信頼できる。
ビルド成果物もまた罠だ。ビルドツールのバージョンを変更するコミットから、変更していないコミットに切り替えると、古い node_modules やコンパイル済みバイナリがチェックアウトされたコードと一致しない可能性がある。プロジェクトにビルドステップがある場合、再現スクリプト内で常にクリーンアップとリビルドを行う。
#!/bin/bash
# safer-reproduce.sh
rm -rf node_modules dist
npm ci
npm run build
npm test -- --grep "checkout flow"
これによりイテレーションごとに数秒追加されるが、バグが実際には古い成果物にあるという誤検出のカテゴリ全体を排除する。
bisect ができないこと
git bisect はコミットを見つける。コミットがなぜbadなのかは教えてくれない。40ファイルに触れる5,000行のrefactorが最初のbadコミットになることもあり、どの行が犯人なのかdiffを読むのは依然として自分の仕事だ。bisectは範囲を「先月のどこか」から「このdiffのどこか」に絞り込む。残りは依然として自分の仕事だ。
また、codebaseに存在したがテストで捉えられなかったバグも助けられない。テストスイートがバグ導入時にすでにグリーンなら、bisectは救ってくれない。まず再現が必要だ。bisectは検索ツールであり、検出ツールではない。
blame や log の代わりに bisect を使う場面
git blame は、どのファイルにバグがあるかすでにわかっている場合に優れている。git log --grep は、コミットメッセージについて何か覚えている場合に優れている。bisectは、どちらもわからない場合のためのものだ。ポイントAでは動作し、ポイントBでは失敗することだけがわかっており、その間の空間が大きすぎて推論できない。
チームがすべてのコミットでCIを実行している場合、CI履歴を読むことで時折より速くbisectできる。しかしCIはテストするものだけを捉える。パフォーマンスの回帰、視覚的なバグ、またはテストが見逃す微妙な論理エラーはCIログには現れない。bisectはCIが検証するものに関係なく、スクリプト化できるあらゆる再現に機能する。
bisectをワークフローに組み込む方法
特別なセットアップは不要だ。コマンドはGitに組み込まれている。しかし、実際に必要になったときにbisectをスムーズにする2つの習慣がある。
第一に、リリースにタグを付ける。bisectには既知のgoodコミットが必要で、タグがそれを提供する最も簡単な方法だ。最後のリリースが3週間前で正常だったとわかっていれば、git bisect good v1.4.0 が1行の固定点になる。
第二に、再現を最小限に保つ。30秒かかる再現は8回のイテレーションで耐えられる。10分かかる再現は拷問だ。bisectする前に、5分かけて再現を可能な限り最小のコマンドに絞り込む。将来の自分に感謝される。
完了したら、すぐに git bisect reset を実行する。detached HEADに未コミットの変更があると、作業を失う絶好の方法になる。これは私が身をもって学んだことだ。あなたには経験してほしくない。