git bisectで問題のあるコミットを特定する
git bisectで不具合の原因となったコミットを特定。信頼できる基準を選び、テストを自動化し、不安定な結果やスキップに対処して原因を確認します。
git bisectは、リグレッションを引き起こしたコミットを二分探索で特定するコマンドです。正常に動作することがわかっているコミットと、不具合があることがわかっているコミットを1つずつマークすると、テストを1回行うたびに残りの範囲が半分に絞り込まれます。300件のコミットであれば、およそ8〜9回のチェックで済みます。
先月までは機能が正常に動作していました。その後mainには300件のコミットがマージされましたが、カート関連のコードに手を入れた覚えのある人はいません。すべての差分を読んでいたら午後が丸ごと潰れてしまいます。
5 Git Commands Beyond Commit and Pushでは、start、good、bad、run、skip、resetを紹介しました。本記事ではさらに踏み込み、次の内容を解説します。信頼できる正常なコミットの選び方、終了コードでGitを誤らせないgit bisect runスクリプトの書き方、そしてフレーキーテストや誤ったマークによって探索が誤った方向に進んだときの対処法です。
重要なポイント
- bisectの範囲が2倍になっても、チェックは1回増えるだけです。古い正常なコミットを選ぶことの本当のコストは、もはやビルドできずにスキップせざるを得ない古いコミットが増えることにあります。
git bisect runでは、終了コード0はgood、125はスキップ、1〜127のそれ以外のコードはbadとしてマークされます。128以上の場合はbisect自体が中断されます。- 断続的に発生する不具合の場合は、複数回の実行のうち1回でも失敗すればbad、すべて成功した場合のみgoodとマークします。
- マークを間違えても最初からやり直す必要はありません。
git bisect logを保存し、誤った行とそれ以降をすべて削除してから、git bisect resetとgit bisect replayを実行します。 - 結果を検証するには、特定されたコミットとその親コミットをテストします。親コミットではテストが成功し、特定されたコミットでは失敗するはずです。
git bisectの手動ループ
手動ループのセットアップに必要なコマンドは3つです。まずgit bisect startを実行し、次に不具合のあるコミットに対してgit bisect bad、正常に動作するコミットに対してgit bisect good <ref>を実行します。あとはGitがチェックアウトする各コミットをテストしてマークしていけば、最終的にGitが原因のコミットを特定します。同じ流れはPro Gitの「Gitによるデバッグ」の章でも解説されています。Gitに慣れていない方は、日常的な基本操作をまとめた10 Git commands every developer should knowも参照してください。
git bisect start
git bisect bad # HEAD has the bug
git bisect good v4.12.0 # last release known to work
Bisecting: 149 revisions left to test after this (roughly 7 steps)
[9e41c07b2d85a3f16c0e7b94d2a58f3e1c6b0d27] Refactor cart line-item formatter
Gitが範囲の中間点をチェックアウトしました。テストを実行し、その結果を報告します。
git bisect bad
Bisecting: 74 revisions left to test after this (roughly 6 steps)
[2b7f0d9a4c16e83b5f2a07d9c4e1b68a3f05c9d1] Update currency util types
テストとマークを繰り返します。候補が1つに絞られると、Gitが結果を出力します(以下のSHAや名前はプレースホルダーです)。
3c9d2e7a51f04b8e96d1a2c7f05e4b3d8a6c1f92 is the first bad commit
commit 3c9d2e7a51f04b8e96d1a2c7f05e4b3d8a6c1f92
Author: Example Dev <dev@example.com>
Date: <date>
Round line totals before applying discount
src/cart/total.ts | 4 ++--
正常なコミットはどう選ぶべきか
git bisectで指定する正常なコミットとして最適なのは、不具合なしでリリースされたことがわかっている最新のリリースタグです。ただし、マークする前に必ずテストしてください。実際には不具合のあるrefをgoodとして指定しても、bisectはもっともらしい答え(多くの場合、指定したgoodのrefの直後のコミット)を返しますが、その答えは間違っています。
bisectで使うものと同じチェックでタグをテストします(ここで使っているVitestはテストランナーの一例です)。
git switch --detach v4.12.0
npm ci && npx vitest run src/cart/total.test.ts # must pass
git switch -
確実性を犠牲にしてまで範囲を狭めようとしないでください。範囲が2倍になっても、チェックは1回増えるだけです。300件なら約8〜9回、600件なら9〜10回、2,400件でも11〜12回です。むしろ、遠く遡ることには別のコストがあります。古いコミットでは、古いバージョンのNodeが必要だったり、すでに公開が取り下げられた依存パッケージが必要だったり、ロックファイルの形式が異なっていたりします。ビルドできないコミットはスキップするしかなく、スキップされた範囲が長くなると、その中に原因のコミットが埋もれてしまう可能性があります。
セッションリプレイを使えば、リグレッションが実際のユーザーに初めて影響を与えた時点がわかるため、その直前にデプロイされたリリースを正常なコミットとして絞り込めます。
git bisect runで探索を自動化するには
git bisect run <script>は、候補となる各コミットでスクリプトを実行し、その終了コードを判定結果として読み取ります。対応関係はgit-bisectのドキュメントで次のように定義されています。
| 終了コード | 意味 |
|---|---|
| 0 | good |
| 1〜127(125を除く) | bad |
| 125 | スキップ(テスト不可) |
| 128以上 | bisectを中断 |
スクリプトはリポジトリの外に置いてください。そうすれば、古いコミットをチェックアウトしてもスクリプトが変わることはなく、git clean -fdxのようなクリーンアップコマンドで削除されることもありません。
cat > ../bisect-test.sh <<'EOF'
#!/usr/bin/env bash
npm ci --silent || exit 125 # can't install: skip
npm run build --silent || exit 125 # can't build: skip
npx vitest run src/cart/total.test.ts || exit 1
EOF
chmod +x ../bisect-test.sh
git bisect run ../bisect-test.sh
インストールやビルドに失敗した場合は125で終了するため、無関係な破損はbadとして数えられずスキップされます。最後の|| exit 1は、テストの失敗をすべて1に変換します。これにより、テストランナーがたまたま125や128以上を返したために、コミットが誤ってスキップされたり実行が中断されたりすることを防げます。
終了コード126(実行不可)と127(コマンドが見つからない)は、通常badとして扱われます。そのため、スクリプトのパスが間違っていたり実行権限が付与されていなかったりすると、すべてのコミットがbadとマークされる恐れがあります。Git 2.36のリリースノートには、これに対する安全策が記載されています。Gitは実行できないスクリプトを検出しようとし、原因のコミットを特定する代わりに早期に停止します。git bisect runが早期に停止した場合は、コードを調べる前にスクリプトのパスと権限を確認してください。
よくあるつまずき:スキップ、リセット、未コミットの変更
中断の原因はほとんどの場合、テストできないコミット、セッションを終了する必要がある場合、邪魔になるローカルの変更の3つです。
git bisect skip
git bisect skipを実行すると現在のコミットが保留され、Gitは近くの別のコミットに移動します。原因のコミットがスキップされたコミットの範囲内にある場合、Gitは1つに特定せず、候補の一覧を表示します。
git bisect reset # back to the branch you started on
git bisect reset 3c9d2e7 # end the session on a specific commit
git bisect startの前に、ローカルの変更はコミットするかstashしておいてください。git bisect start -- src/cart/のように指定すると、探索対象を特定のパスに限定できます。git bisect visualizeは残りの容疑コミットをgitkで表示します。グラフィカルなデスクトップセッションが見つからない場合はgit logにフォールバックします。
フレーキーテストや誤ったマークにどう対処するか
断続的に発生する不具合をbisectで調べる場合は、各コミットでテストを複数回実行します。1回でも失敗すればbad、すべて成功した場合のみgoodとマークします。マークを1つ間違えるだけで探索は誤った半分へと進み、それでもGitは「最初の不具合コミット」を報告してしまうからです。本当に不具合のあるコミットが偶然テストに通ることはあり得ますが、正常なコミットがこのテストで失敗することはないはずです。
#!/usr/bin/env bash
npm ci --silent || exit 125
npm run build --silent || exit 125
for i in $(seq 1 10); do
npx vitest run src/cart/total.test.ts || exit 1 # any failure = bad
done
exit 0 # all passed = good
仮定のケースで計算してみましょう。不具合のあるコミットが20%の確率で失敗する場合、10回連続で偶然成功する確率は0.8¹⁰ ≈ 0.11です。実行回数を増やせば、このリスクはさらに小さくなります。判断がつかないコミットをスキップしても問題は解決しません。不安定な結果はそのまま残り、最終的な答えの範囲が広がるだけです。なお、このルールはフレーキーさがリグレッションによって新たに生じたものであることを前提としています。リグレッション以前からテストがフレーキーだった場合は、先にテストを安定させるか、より限定的なチェックを書いてください。そうしないと、正常なコミットがbadとマークされてしまいます。
すでにマークを間違えてしまった場合でも、最初からやり直さずに修正できます。
git bisect log > ../bisect.log
# edit ../bisect.log
git bisect reset
git bisect replay ../bisect.log
保存されたログにはコメント行も含まれますが、ここではコマンド行のみを示します。編集前は次のとおりです。
git bisect start
git bisect bad 8d1f...
git bisect good 5a0c...
git bisect good 9e41... <- wrong: this commit was flaky
git bisect bad 2b7f...
編集では、誤った行とそれ以降をすべて削除します。
git bisect start
git bisect bad 8d1f...
git bisect good 5a0c...
git bisect replayを実行すると、最後の正しいマークの時点までセッションが復元されるので、そこから作業を再開できます。
結果をどう検証するか
git bisectが特定したコミットは、検証が済むまでは手がかりとして扱ってください。git show 3c9d2e7で差分を確認し、そのコミットの親コミットとコミット自体をテストします。
git switch --detach 3c9d2e7^ # parent: test should pass
git switch --detach 3c9d2e7 # culprit: test should fail
親コミットでテストが成功し、そのコミットで失敗すれば、リグレッションの原因を突き止めたことになります。ここからは、git blameを使って周辺の行が変更された理由を調べたり、git historyを使ってまだプッシュしていないコミットを修正したりできます。次にリグレッションが発生したときは、テスト済みのリリースタグと、各コミットでテストを複数回実行するスクリプトから始めましょう。
よくある質問
git bisectで、バグを引き起こしたコミットではなく、バグを修正したコミットを探すことはできますか?
できます。goodとbadの代わりに、oldとnewという用語を使います。git bisect startを実行し、修正済みのコミットをgit bisect newで、それより古い不具合のあるコミットをgit bisect oldでマークすると、Gitは最初のnewコミット、つまり修正コミットを報告します。より分かりやすいラベルを使いたい場合は、git bisect start --term-old broken --term-new fixedを実行し、git bisect fixedとgit bisect brokenでコミットをマークします。
git bisectはフィーチャーブランチからのマージコミットをどのように扱いますか?
デフォルトでは、git bisectはマージされたブランチ内部のコミットもテスト対象とします。その中にはビルドできない作業途中のコミットが含まれている可能性もあります。Git 2.29で追加されたgit bisect start --first-parentを使うと、探索を履歴のメインラインに限定できます。mainでは、バグを持ち込んだマージコミットで探索が停止し、ブランチ内のコミットはテストされません。そのブランチを対象にもう一度bisectを行えば、正確なコミットを特定できます。
正常なコミットが不具合のあるコミットの祖先でない場合はどうなりますか?
Gitは2つのコミットのマージベースをチェックアウトし、まずそれをテストするよう求めます。マージベースが正常であれば、マージベースから不具合のあるコミットまでの範囲で通常どおり二分探索が続行されます。マージベースの時点ですでに不具合がある場合、Gitは探索を行わずに停止します。正常なコミットは、バグが存在しないか、あるいはバグが修正された別の履歴ライン上にあるためです。
git bisectとgit blameの違いは何ですか?
git blameは、ファイルの各行について、どのコミットが最後にその行を変更したかを示します。つまり、特定の行を最後に編集したのは誰かという問いにしか答えられません。一方、git bisectはコミットをまたいで動作をテストするため、原因が依存パッケージの更新や設定の変更、あるいは症状が現れているファイルとは別のファイルにある場合でも、リグレッションを特定できます。bisectで原因のコミットを特定し、blameでそのコミットが変更した行を追跡するという使い分けがおすすめです。