- 二分探索(binary search) の概念は面接問題だけでなく、実際の開発ツールである Git でも活用される
- 大規模な monorepo 環境でテストが突然失敗したとき、ログだけでは原因を追跡しにくい状況が発生する
- ある同僚が 良いコミットと悪いコミットを指定して
git bisect で自動探索を行い、バグが始まった問題のコミットを正確に見つけ出した
- 各段階でスクリプトを実行し、テスト結果に応じてコミットを自動分類しながら、最初に失敗したコミットを特定する
- 二分探索の原理を活用した
git bisect は、大規模コードベースでバグの原因を迅速に追跡する強力なツールである
アルゴリズムと実例
- 二分探索(binary search) アルゴリズムは単なる面接問題にとどまらず、実際のデバッグツールでも中核となる原理として機能する
git bisect は「バグを最初に導入したコミット(first bad commit)」を見つけるために二分探索を使うツールとして利用できる
- Leetcode の “First Bad Version” 問題と似た原理で動作する
実際の業務環境での問題状況
- 大規模な monorepo を使う環境では、1日に数百〜数千のコミットが発生する
- 特定のテスト失敗の原因は、ログだけでは追跡が難しい
- 失敗の原因は、リモート呼び出しに必要なトークンを取得する設定ファイルの文字列変更で、別のアカウントを参照するようになったためテストが失敗したことにあった
- この変更は統合テストを通過したが、実際には問題を引き起こしており、膨大なコミットの中のどの時点で発生したのかを見つけるのが難しかった
git bisect を使った問題解決
- 他チームの同僚が
git bisect コマンドを使って問題のコミットを素早く特定した
- 良いコミット(good) と 悪いコミット(bad) を指定した後、自動で中間のコミットをチェックアウトしてテストを実行し、原因を絞り込んでいった
- 各テスト実行には時間がかかったが、最終的に 正確に問題を導入したコミットを見つけ出した
- そのコミットを元に戻すと、テストはすべて正常に復旧した
git bisect の実行過程
結論
git bisect は、二分探索の原理をコード履歴の探索に適用した実用的なツールである
- 大規模リポジトリや複雑な変更履歴でも、バグが導入された時点を迅速に追跡できる
- テスト自動化と組み合わせることで、大規模コードベースでも安定したデバッグが可能になる
2件のコメント
こうした問題があるため、TBD(trunk-based-develop)を使います。
Hacker Newsのコメント
以前、テストカバレッジもなく抽象化もひどい巨大なコードベースで働いていたとき、
git bisectはほぼ唯一まともに使えるツールだった。コードが複雑すぎて論理的にバグを追跡するのが不可能だったため、どのコミットで問題が発生したのかを見つけるほうがずっと簡単だった。
しかし、品質の高いコードベースでは、あえて bisect を使う必要はなかった。各コンポーネントを独立してテストでき、可観測性(observability) も十分に整っていて、どこを見ればよいかが明確だった。
git bisectは決して不要ではないと思う。単にバグを見つけるだけでなく、そのバグがなぜ発生したのかを理解させてくれる。コミットメッセージが充実しているプロジェクトなら、bisect を通じて過去のコミットの文脈を把握し、その内容をバグ修正コミットに反映できる。こうした循環がコミット文化そのものを強化する。
直接追跡するのは不可能だったが、bisect スクリプトを書いて30分ほど回したら、問題のコミットを正確に特定できた。
git bisectはもともとLinuxカーネルのリグレッション(regression) を見つけるために導入されたツールだった。ハードウェアドライバのようにテストが不可能な場合でも、一般ユーザーが自分でカーネルを bisect して問題のコミットを特定できるようになった。
以前はメールで開発者に助けを求める必要があったが、今ではユーザー自身で問題を絞り込めるようになった。
たとえば誤って処理されたデータの範囲を追跡したり、「これはバグなのか仕様なのか」を判断したりするときに有用だ。
たとえば顧客が6年前のバージョンで問題を抱えているとき、4年前のバージョンにアップグレードすれば解決するか確認できる。
あるいはコードが大規模にリファクタリングされたとき、そのバグ修正が意図的だったのか偶然だったのかも把握できる。
git bisectはうまく機能するときは素晴らしいが、あらゆるバグを見つけられるわけではない。バグによっては、導入された当初は症状がなく、後の別の変更によって表面化することもある。
このような場合、bisect の前提(良いコミットと悪いコミットの間でバグが一度だけ登場する)が崩れる。
テスト不能なコミットは
skipできるが、それが問題のコミットだった場合、結果が曖昧になる。最近初めて本格的に
git bisectを使ってみたが、ほとんど魔法のようだった。同じ名前の関数が2つあり、コードフォーマット作業の最中に正しい関数の import が削除されたことで問題が発生していた。
何度もコードを見直したが、bisect で問題のコミットを特定するまでは原因がまったく分からなかった。
私はたいてい、バグが発生したファイルや関数の範囲をすでに把握しているので、bisect を頻繁には使わない。
その代わり、
git log -L :func_name:path/to/file.cコマンドで特定の関数の変更履歴を追跡する。.gitattributesの設定が必要だ。.gitattributesの設定について知りたいという質問があった。どのような内容が必要なのか、もっと詳しく知りたいという反応だった。git log -Lは弱い。同じ名前のオーバーロード関数のうち、特定のバージョンを追跡しにくいためだ。.gitattributesがなければ、git log -Sで特定の文字列を含むコミットを探すのも一つの方法だ。テストスクリプトではexit code 125を覚えておくとよい。
ビルド失敗のようにテスト結果を判定できない場合、125を返せば bisect がそのコミットをスキップする。
関連内容は私のブログ記事にまとめてある。
git bisect --first-parentを使うと便利だ。こうすると「どのPRがバグを持ち込んだのか」を素早く見つけられ、その後そのブランチでさらに詳細な bisect をもう一度回せばよい。
フレーキーテスト(flaky test) が発生したとき、bisect は真価を発揮する。
レースコンディションのせいで、確信を持つためにテストを何十万回も回す必要がある場合でも、bisect スクリプトをバックグラウンドで走らせておけば現実的に解決できる。
最近、Svelteで作った音楽プレーヤープロジェクト(lets-make-sweet-music.com)で、bisect を使ってバグの原因を突き止めた。
テストもなく、エラーログもなかったが、
dependabotの更新でコミットが増えていて追跡が難しかった。bisect のおかげで問題のコミットを見つけ出せたが、原因は私が差し替えたファイルがイベントの多重バインディング機能を実装していなかったことだった。
コミットを小さく保てば、bisect で見つけた問題の原因を素早く絞り込める。
誰かが「面接で二分探索(binary search) を学ばなければならないなんて無理がある」と言っていたが、
git bisectはその概念を実際に示す良い例だ。ただし自分で実装する必要はない。たいていの言語ではすでに標準ライブラリとして提供されている。
中間インデックスを計算するときに
(low + high) / 2とするとオーバーフローが起こりうる。不変条件(invariant) に基づく思考を鍛える最高の練習だ。
Gitには bisect のほかにも、
log -L、log -S、blameのような優れたコード探索ツールがある。以前、このテーマについてブログ記事を書いたことがある。