Claude Code に commit 前のチェックをスキップされたので、commit 検証を入れました。その過程で、よくある pre-commit の定番と少し違うやり方を採用したので紹介します。
目次
背景
最近、自作 CDK を開発しています。
AWS CDK は裏で CloudFormation が動きますが、こちらでは CloudFormation でなく AWS SDK でリソースが構築されるように、CDK CLI 自体と CloudFormation のエンジン部分をフルスクラッチしています。おかげで爆速なデプロイが実現できています。
この cdkd における開発中、Claude Code に「commit 前に必ず /check skill (typecheck / lint / build / tests) を走らせる」というルールを設けていました。CLAUDE.md だけに書いていて、サボって hook (PreToolUse hook) を使っておらず、Claude Code が一度スキップして commit してしまう場面に遭遇しました。
やはりルールに書くだけでは限界があるので、機械的に止める仕組み (= commit 検証) を入れることにしました。ただ、hook で lint などのコマンドを直接叩く定番の方法とは少し違うアプローチを採用しました。
hook がコマンドを実行する方式
まずは、いわゆる定番のアプローチのお話からです。
Claude Code の PreToolUse hook に限らず、husky や lefthook などの git hooks でも、hook の中で lint/test などの検証コマンドを直接実行して、失敗したら commit を止めるというやり方が定番です。シンプルで分かりやすく事例も豊富です。
最初に cdkd で検討したのも、この方式でした。Coding Agent に commit まで任せる運用なので、Claude Code 経由の tool 呼び出しに発火する PreToolUse hook を選択しました。
// .claude/settings.json の例 { "hooks": { "PreToolUse": [{ "matcher": "Bash", "hooks": [{ "type": "command", "command": "pnpm run lint && pnpm test", "if": "Bash(git commit*)" }] }] } }
二重実行の問題
しかし cdkd の場合、プロジェクト共通の手順としてすでに /check (typecheck / lint / build / tests) という skill が定義されていました。
また、その /check skill を、実装用の skill の中でも自動で走らせるようにしていました。さらに、開発者(人間)が手動で走らせることもあります。
その場合、hook の中で検証コマンドを直接実行するようにすると、このような状況が発生します。
- Claude Code が実装 skill のフロー内で
/checkを走らせた → 続けて Claude Code 経由でgit commit→ hook でもう一度走る - 開発者が commit 前に手動で
/checkを走らせた → 続けて Claude Code 経由でgit commit→ hook でもう一度走る
つまり、hook で検証コマンドを実行するケースでは、毎コミットで同じ検証コマンドを 2 回実行することになるわけです。
昨今の linter / formatter やテストツールの処理はどんどん高速化されて数秒で済んでいるのですが、Coding Agent とのイテレーションでは commit 回数が増えがちなので、その数秒も積み重なるとバカになりません。そのため、やはり「さっき実行したのに」と無駄に感じてしまう部分がありました。(もちろん各種テストツールなどのキャッシュ機構はありますが...)
hook でカバーしたいのは「Claude Code が skill をスキップしたケース」だけです。スキップしなかった大多数のケースのために、毎回二重実行のコストを払うのは避けたいと考えました。
マーカーで確認する方式
そこで、hook の責務を 「検証コマンドを実行する」から「skill が検証コマンドを成功させたかどうかを確認する」 に変えました。
仕組みはシンプルで、/check skill が成功した時に 「そのときの状態を表すハッシュ」 (現在の HEAD SHA + 未コミット変更の content hash) を一時ファイル (マーカー) に書いておき、hook は現在の状態と照合するだけです。
状態が変わる (commit で HEAD が進む、あるいは新しい編集が入る) と、ハッシュも変わってマーカーは自動で無効になります。
/checkskill が検証コマンドを実行- 全 pass なら
/tmp/cdkd-check-marker.jsonに{ HEAD SHA, content hash }をマーカーとして書く
- 全 pass なら
git commit(Claude Code 経由)- PreToolUse hook がマーカーを読んで現状と照合
- 一致なら allow
- マーカーが無い / 古い場合は
exit 2で「先に/checkを走らせて」と止める
このアプローチの場合、hook 自体は検証コマンドを一切走らせません。そのため、skill 側で実行済みの検証コマンドが二重に走ることはありません。
つまり、キャッシュのようなアプローチになります。
発想自体は、nx や turborepo のビルドキャッシュ、SLSA の CI attestation などと通ずるところがあります。これらの発想をローカルの commit 検証に落とし込んだ形です。
hook の中身
# .claude/hooks/check-gate.sh (抜粋) MARKER=/tmp/cdkd-check-marker.json # git commit でなければスルー cmd=$(jq -r '.tool_input.command // ""') printf '%s' "$cmd" | grep -qE '\bgit[[:space:]]+commit\b' || exit 0 # 現状の content hash を計算 head=$(git rev-parse HEAD) content=$({ git diff HEAD --name-only git ls-files --others --exclude-standard } | sort -u | while IFS= read -r f; do if [ -f "$f" ]; then printf 'FILE:%s\n' "$f"; cat "$f" else printf 'DEL:%s\n' "$f" fi done | shasum -a 256 | cut -c1-16) current=$(printf '{"head":"%s","content":"%s"}' "$head" "$content") # マーカーと照合 [ -f "$MARKER" ] || { echo "run /check first" >&2; exit 2; } [ "$current" = "$(cat "$MARKER")" ] || { echo "state changed since /check" >&2; exit 2; }
content hash は git diff HEAD と untracked ファイルの両方を拾って計算しています。こうすることで、git add の前後で値が変わらないようにしています。
また、HEAD が動けば自動的にマーカーが古くなるため、TTL の管理は不要です。
実際の実装は cdkd の PR をご覧ください。
定番方式との比較
| ケース | hook がコマンドを実行する方式 | マーカーで確認する方式 |
|---|---|---|
開発者が手動で /check 済 → commit |
再実行 (二重) | 即 commit |
agent が skill で /check 済 → commit |
再実行 (二重) | 即 commit |
agent が /check をスキップ → commit |
hook 内で検証実行 | commit 拒否 → agent が /check を実行して再 commit |
どちらの方式も「/check をスキップしたケース」で、検証をせずにコミットすることは防げますが、差が出るのは「/check 済みケース」です。マーカー方式なら検証コマンドは 1 回しか走らず、二重実行の無駄がありません。
hook に全部寄せない理由
hook だけで検証コマンドを完結させて、実装などの skill 内での検証を廃止する選択肢もありますが、cdkd ではそうしませんでした。
というのも、commit というタイミング以外にも、開発イテレーションの途中でチェックを走らせたい場面があるからです。
- 実装用の skill の終わりに
/checkを自動で走らせる (まだ commit はしないが、実装が終わった時点で一旦動作確認したい) - 開発者 (人間) が手動で
/checkを叩く (デバッグ中や PR 前の確認など)
Claude Code の hook は commit 以外のイベントにも設定できますが、「実装 skill の中で一緒に検証したい」といったモチベーションもあったため、このような構成にしています。
他のユースケースへの応用パターン
今回の構造は、Coding Agent による skill のスキップを防ぎつつ、複数の場面で叩かれうる skill の二重実行を避けたい場面に応用できます。
- PR 作成の前にドキュメント整合性をチェック (
/check-docs→gh pr create) - image の push 前に脆弱性スキャン (
/security-scan→docker push) - push 前にカバレッジレポート生成 (
/coverage→git push)
最後に
あまり規模の大きくないプロジェクトであれば、定番の「hook でコマンドを直接実行する方式」でも十分だと思います。しかし、少しでも待ち時間を削減したかった、単に無駄を感じていた、他のユースケースにも応用できそう、といった理由で、今回の「マーカーで確認する方式」を採用しました。
追記: OSS 化しました
この方式を特定のプロジェクトに依存しない汎用的なマーカーゲート CLI として OSS 化しました。詳しい使い方や設計の話は別記事にまとめています。