Claude Codeでgit bisectを回し、回帰の入った変更を特定する
再現コマンドを終了コードで返すスクリプトにしてgit bisect runに渡し、Claude Codeには見つかったコミットの差分読解と修正だけを任せる分担と、権限・worktreeの設定をまとめます。
「先週までは通っていたテストが落ちる」という回帰は、原因のコミットを見つけるまでが長い仕事です。Claude Codeに「どのコミットで壊れたか調べて」と頼むこともできますが、二分探索そのものはgit bisectが決まった手順で済ませてくれます。
分担は単純です。壊れたかどうかの判定は、終了コードを返すスクリプトに任せる。Claude Codeには、スクリプトを書く作業と、見つかったコミットの差分を読んで直す作業を渡します。この記事では、その組み立て方と、無人で回すときの権限・作業ツリーの設定を順に示します。
全体の流れ
- 1
再現スクリプトを用意する
壊れた状態で非0、正常な状態で0を返すコマンドを1本にします。
- 2
良い側と悪い側で試す
過去の正常なタグと現在のHEADで、期待どおりの終了コードが返ることを確かめます。
- 3
git bisect runを回す
範囲を二分しながら、スクリプトが判定を繰り返します。
- 4
見つかったコミットをClaudeに読ませる
差分と、その変更の意図を突き合わせて修正案を出させます。
再現コマンドを終了コードで返すスクリプトにする
git bisect runに渡すコマンドには、終了コードの決まりがあります。0なら正常(good/old)、1から127の間(125を除く)なら不具合あり(bad/new)、125なら「このコミットは検証できない」としてスキップされます。それ以外の終了コードが返ると、二分探索そのものが中断します。
| 終了コード | bisectの解釈 |
|---|---|
| 0 | bisectの解釈このコミットは正常 |
| 1〜127(125を除く) | bisectの解釈このコミットは不具合あり |
| 125 | bisectの解釈検証できないのでスキップ |
| 上記以外(128以上) | bisectの解釈探索を中断 |
126と127はシェルが「実行権限がない」「コマンドが見つからない」を示すのに使う値で、スクリプトの不備はbisect側から見ると通常のエラーとして扱われます。つまり、実行権限の付け忘れ(chmod +x)は「全コミットがbad」という形で現れ、しかも探索は中断しません。ここは最初の落とし穴です。
最小のスクリプトは次のようになります。例として、total()が小数を丸めてしまう回帰を題材にしました。
#!/bin/sh
# repro.sh: 0=正常 1=不具合 125=検証不能
[ -f price.js ] || exit 125
node -e '
const { total } = require("./price.js");
process.exit(total([{ price: 0.5, qty: 3 }]) === 1.5 ? 0 : 1)
'最初の1行は、そのコミットにファイルが無いときのスキップです。ビルドが通らない古いコミットが範囲に含まれるなら、make || exit 125のように「ビルド失敗はスキップ」と書けます。この書き方はgit bisectの公式ドキュメントにも載っています。
スクリプトはClaudeに書かせてよい
公式のワークフロー集には、バグ調査の心得として「再現するコマンドを伝える」「再現手順を伝える」「エラーが断続的か常時かを伝える」が挙がっています。ここで言う再現コマンドをそのままスクリプトの中身にします。
price.jsのtotal()で、0.5円×3個の合計が1.5ではなく1になることを確認したい。
これを判定する repro.sh を書いて。正常なら exit 0、不具合なら exit 1、
price.jsが無いコミットでは exit 125。書いたらHEADで実行して終了コードを見せて。書かせたあとは、必ず人が両端で試します。スクリプトの判定が間違っていると、bisectは間違ったコミットを自信ありげに指します。
落とし穴
スクリプトの判定が逆だと、二分探索は間違ったコミットを返す
bisectが信じるのは終了コードだけです。壊れた状態でも0を返すスクリプトを渡すと、範囲は壊れた側へ狭まらず、的外れなコミットで探索が終わります。次の節の「両端での確認」は省略しないでください。
両端で終了コードを確かめてから探索する
探索を始める前に、悪い側(今のHEAD)と良い側(過去の正常なタグ)の両方で、スクリプトが期待どおりの値を返すか確認します。
./repro.sh; echo "HEAD exit=$?"
git checkout -q v1.0
./repro.sh; echo "v1.0 exit=$?"
git checkout -q main手元の小さなリポジトリ(コミット9件、Git 2.50.1)で試したところ、HEADではexit=1、v1.0ではexit=0が返りました。この2つが揃って初めて、探索の前提が成立します。
git bisect runで原因のコミットを探す
準備ができたら、悪い側と良い側を指定してgit bisect runに渡します。
git bisect start HEAD v1.0
git bisect run ./repro.sh
git bisect reset同じリポジトリでの出力の末尾は次のとおりです。コミットIDと日時は環境で変わります。
d1d50fc7c912a56966b36bed48e20d19f2677bab is the first bad commit
commit d1d50fc7c912a56966b36bed48e20d19f2677bab
update: round total
price.js | 3 +--
1 file changed, 1 insertion(+), 2 deletions(-)
bisect found first bad commitv1.0より後ろは4コミットで、スクリプトが実際に走ったのは2回でした。二分探索なので、範囲が倍になっても検証は1回増えるだけです。数百コミットの範囲でも、検証は十数回前後で収まります。最後のgit bisect resetは探索の後始末です。忘れると、リポジトリは探索の途中で切り替わった古いコミットを指したままになります。
後始末を省きたいときは、git bisect runに--reset-when-foundを付けます。見つかった時点で結果を報告して探索の状態を片付けてくれます。戻り先は既定で探索前のコミット(original)です。見つかったコミットをそのままチェックアウトしておきたいなら、--reset-when-found=foundと書きます。
見つかったコミットをClaude Codeに読ませる
ここからがClaude Codeの出番です。コミットIDが分かっているので、読ませる範囲は1つの差分に絞れます。
git bisectの結果、d1d50fcで price.js の total() が壊れたと分かっている。
git show d1d50fc で差分を読み、なぜ 0.5×3 が 1 になるのか説明して。
そのうえで、小数の合計を保ったまま意図を満たす修正案を2つ出して。
repro.sh が exit 0 になることを確かめてから報告して。最後の1行が要点です。公式のベストプラクティスは、Claudeに「完了の基準」と「自分の作業を確かめる手段」を渡すよう勧めていて、その例として失敗するテストを挙げています。repro.shはまさにその役を果たします。修正後にrepro.shが0を返すかどうかで、Claudeの修正が回帰を直したと判定できるからです。
こうしておくと、Claudeに「探す」作業を任せずに済みます。差分が1つに絞られていれば、読むのはその変更の意図と副作用だけで、会話に流れ込む出力も小さく保てます。
修正の前に、意図を確認する
見つかったコミットが「悪い」とは限りません。仕様変更の結果として古いテストが落ちているだけのこともあります。Claudeに修正させる前に、コミットメッセージと関連するissueを読ませ、「意図した変更か、副作用か」を分けて報告させると、無駄な手戻りを減らせます。
Claudeに回させるときの権限と作業ツリー
git bisectは作業ツリーを過去のコミットへ次々に切り替えます。手元で編集中のファイルがあるリポジトリで回すと、検証と作業が絡みます。Claude Codeには、専用のworktreeで起動する--worktree(-w)フラグがあり、作業ツリーを分離できます。
claude --worktree bisect-totalworktreeは新規のチェックアウトなので、依存関係のインストールなどの環境準備はそのworktreeで改めて必要です。進め方の詳細はWorktree実践ガイドにまとめています。
Claudeにgit bisectのコマンドまで実行させるなら、権限ルールで許可しておくと途中で止まりません。ルールは「*をサブコマンドの後ろに置く」のが決まりで、Bash(git *)のように広げすぎると他のgit操作まで通ります。
{
"permissions": {
"allow": [
"Bash(git bisect *)",
"Bash(git show *)",
"Bash(git log *)"
],
"deny": [
"Bash(git push *)"
]
}
}注意点が1つあります。git bisect runは任意のコマンドを実行できるので、Bash(git bisect *)を許可すると、git bisect run <任意のコマンド>も通ります。判定スクリプトを自分で用意した場合は、git bisect run ./repro.shを実行するよう指示に書いておくと、意図しない実行を減らせます。
非対話モードで一括実行するなら、-pと--allowedToolsに同じルールを書きます。
claude -p "repro.shを使ってgit bisectでv1.0以降の回帰コミットを特定し、
そのコミットの差分を説明して。終わったらgit bisect resetを実行して" \
--allowedTools "Read,Bash(git bisect *),Bash(git show *),Bash(git log *)"--allowedToolsは許可ルールの書式をそのまま受け取り、末尾の *が前方一致を表します。無人で回すなら、ターン数や費用の上限も添えておくと、判定スクリプトが想定外に長引いたときの被害を抑えられます。
判定が割れるときの切り分け
実際のリポジトリでは、全コミットがきれいにビルドできるとは限りません。よくある場面と対処を並べます。
bisectが途中で止まった・結果がおかしいとき
- Qスクリプトは両端で期待どおりの終了コードを返しますか?答えがいいえのとき答えがはい
repro.shを直します。ここが崩れていると、以降の結果は信用できません。
- Q途中のコミットは、すべてビルドが通りますか?答えがいいえのとき答えがはい
通らない箇所は
|| exit 125でスキップにします。壊れたコミットが連続すると、最初の不具合コミットを一意に絞れない場合があります。 - Q同じコミットで、結果は毎回同じですか?答えがいいえのとき答えがはい
不安定なテストが原因です。スクリプト側で複数回実行し、すべて成功したときだけ0を返すなど、判定を安定させます。
マージの多い履歴では
git bisect start --first-parentを使うと、マージされたブランチ内の壊れたコミットに惑わされず、マージコミット単位で原因を特定できます。
追加で知っておくと便利な操作を、公式ドキュメントから挙げます。
- 範囲を絞る:
git bisect start -- <パス>のようにパスを指定すると、関係するディレクトリだけに探索範囲を狭められます - 判定の間違いを直す:
git bisect logで記録を出力し、誤った行を消したファイルをgit bisect replayに渡すと、正しい状態に戻れます - 不具合ではなく修正を探す:
oldとnewを使い、git bisect new HEAD、git bisect old HEAD~10のように指定すれば、「いつ直ったか」を探せます
良い側の端点は、確実に正常だったと言えるタグやリリースを選びます。端点が実は悪い側だと、探索は意味のない結果で終わります。git履歴をClaudeに読ませる別の場面としては、パッチノートをgitログから起こす手順があります。テスト側の整え方はRSpecを回して失敗だけを直させる記事が参考になります。
判断の分かれ目: 探索をClaudeに任せない理由
Claudeに「このリポジトリで壊れたコミットを探して」と頼むこと自体はできます。それでも二分探索をスクリプトに寄せるのは、次の理由からです。
- 探索は決定論的に進み、同じ入力なら同じ結果が出る
- Claudeの判断が介在しない分、途中のコミットの検証で会話の出力が膨らまない
- 結果が1つのコミットIDに収束するので、Claudeに渡す範囲が明確になる
一方、判定スクリプトを書く作業は、コードを読んで再現条件を言語化する仕事で、Claudeが得意な領域です。逆に、見つかったコミットが意図した変更かどうかを判断する仕事も、人かClaudeが差分を読んで決めるしかありません。機械的な部分はgit bisect、読解と判断はClaudeという線引きが、手戻りの少ない形です。
まとめ
判定を終了コードで表せる回帰なら、git bisect runで原因のコミットは機械的に絞れます。Claude Codeに任せるのは、再現スクリプトの下書きと、見つかった差分の読解・修正です。悪い側と良い側の両方でスクリプトを試すことと、無人で回すときの権限ルールをgit bisect *に絞ることの2点を押さえておけば、無駄な出力と間違った結論を避けられます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。