Claude Media
Claude Codeでhuskyとpre-commitを併用し、失敗したコミットを直させる

Claude Codeでhuskyとpre-commitを併用し、失敗したコミットを直させる

huskyとpre-commitのGitフックでコミットが落ちたとき、Claude Codeに出力を読ませて直させる流れと、--no-verifyをCLAUDE.mdとPreToolUse hookで封じる設定をまとめます。

Gitフックの失敗はClaudeに直させ、迂回は仕組みで止める

huskyやpre-commitを入れたリポジトリでは、Claude Codeがgit commitを実行するたびにGitフックが走ります。Lintやテストが落ちればコミットは通りません。ここでClaudeに直させる運用は、「お願い」と「強制」の2つの部品でできています。

くらべる

Gitフックが落ちたときの2つの部品

CLAUDE.md

お願い

失敗した出力を読んで原因を直し、コミットをやり直すよう指示します。CLAUDE.mdはコンテキストであって強制設定ではない、とメモリの解説に明記されています。

PreToolUse hook

強制

フックを飛ばして通す近道を、実行前に物理的に塞ぎます。ブロックしたい操作はPreToolUse hookを使う、という切り分けです。

Claude側のhookでLintを先回りして直す設計は、PostToolUse hookのLint自動修正が扱っています。ここでは、それでも取りこぼした指摘がGitフックで落ちたあとの流れを組みます。コミットメッセージの書式を縛りたい場合は、commitlintで規約違反を差し戻す方法も使えます。

huskyとpre-commitは何が違うのか

どちらもGitフックを管理する道具ですが、持っている責務が違います。

項目huskypre-commit
フックの置き場husky.husky/pre-commitなどのスクリプトpre-commit.pre-commit-config.yamlに宣言
中身の書き方huskyシェルスクリプトを直接書くpre-commit公開リポジトリのhookをidで指定
導入コマンドhuskynpx husky initpre-commitpre-commit install
主な想定huskyNode.jsプロジェクトのnpmスクリプトpre-commit言語を問わない検査の集合

huskyはhusky initで.husky/にpre-commitスクリプトを作り、package.jsonのprepareスクリプトを更新します。フックの追加はファイルを1つ作るだけで、例としてecho "npm test" > .husky/pre-commitです。

pre-commitは.pre-commit-config.yamlにreposとhooksを書き、pre-commit installでGitフックを設置します。最小構成は次の形です。

repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.3.0
    hooks:
    -   id: check-yaml
    -   id: end-of-file-fixer
    -   id: trailing-whitespace

併用するときはGitフックの入口を1本にする

両方を入れると、Gitフックの入口が2つになります。Claudeに読ませる失敗出力が二重に出たり、どちらが落としたのか分かりにくくなったりします。入口はhuskyの.husky/pre-commitに1本化し、その中からpre-commitを呼ぶ構成という組み立て方もあります。この構成は、huskyとpre-commitどちらの公式手順にもない組み合わせです。

# .husky/pre-commit
pre-commit run

pre-commit runは、ステージ済みの変更に対して設定済みのhookを実行します。リポジトリ全体に走らせたいときはpre-commit run --all-filesです。新しいhookを足したときは、まず全ファイルへ走らせておくのが通例です。

既存のGitフックがある環境でpre-commit installすると、既存のフックとpre-commitのフックを両方走らせる移行モードで入る、とpre-commitのドキュメントにあります。上書きしたい場合は-f(--overwrite)を渡します。フックに載せるテストの絞り方はClaude CodeでRSpecを回す記事にあります。

Claudeには何が見えて、何を直すのか

コミットがフックで落ちると、Claudeはgit commitの出力からどのhookが何と言って失敗したかを読めます。pre-commitの出力には、hookごとにPassed/Failedとhook idが並びます。ドキュメントの例では次のように出ます。

Trim Trailing Whitespace.................................................Failed
- hook id: trailing-whitespace
- exit code: 1
 
Files were modified by this hook. Additional output:
 
Fixing sample.py

失敗の型で、Claudeがやるべきことは変わります。

失敗の型出力の手がかりClaudeの動き
Lint・型・テストの指摘出力の手がかり該当ファイルと行、非ゼロのexit codeClaudeの動き指摘箇所を直し、再ステージして再コミット
自動修正hookがファイルを書き換えた出力の手がかりFiles were modified by this hookClaudeの動き変更を確認してステージし直し、再コミット
環境の問題出力の手がかりcommand not foundなどClaudeの動き直そうとせず、原因を人に報告

2行目が見落とされやすい型です。pre-commitのフォーマッタ系hookは、ファイルを書き換えた時点で「失敗」として返り、コミットは止まります。hookの要件も「失敗時は非ゼロで終了するか、ファイルを変更すること」です。直ったのに落ちた、と見えるので、Claudeが--no-verifyで通そうとする引き金になり得ます。修正内容をgit diffで確かめてgit addし直すだけで次は通る、と指示しておくと安定します。

3行目の環境問題も切り分けが要ります。huskyの公式How Toは、nvmやfnmなどのバージョンマネージャー経由でNodeを入れている場合、GUIアプリから走るGitフックでPATHが通らずcommand not foundになり得ると説明しています。これはコードの問題ではないので、Claudeに何度も直させても進みません。

対処は人の側で行います。huskyはフックの実行前に、ホームの設定ファイルからローカルコマンドを読み込みます。読み込み元は$XDG_CONFIG_HOME/husky/init.shと~/.config/husky/init.shで、~/.huskyrcは非推奨です。WindowsではC:\Users\ユーザー名\.config\husky\init.shを使います。nvmなら、このinit.shでnvmを読み込ませる形が考えられます。

# ~/.config/husky/init.sh(nvmの例)
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && . "$NVM_DIR/nvm.sh"

この書き込み例はnvmの標準的な読み込み方に合わせたもので、huskyの文書に載っているのはファイルの場所と、そこから読み込まれるという仕様までです。置き場所がホームディレクトリ配下なので、この設定はリポジトリには入らず、コミットが落ちるマシンごとに必要です。設定後は、ターミナルとGUIの両方からコミットして確かめます。

CLAUDE.mdに書く指示

失敗時の動きと禁止事項を、CLAUDE.mdに短く書きます。長文にせず、判断基準だけを置きます。

## コミットとGitフック
 
- コミットがGitフック(husky / pre-commit)で失敗したら、出力を最後まで読み、原因のコードを直して再コミットする
- フォーマッタがファイルを書き換えて失敗した場合は、`git diff`で変更を確認し、`git add`してから再コミットする
- `--no-verify`、`-n`、`HUSKY=0`、`SKIP=`でフックを飛ばさない。通らないときは原因を報告する
- 同じ原因で2回続けて失敗したら、それ以上試さずに状況を説明する

ここで挙げた迂回手段は、どれも正規の機能です。huskyのHow Toには、Gitの-n/--no-verifyで1コマンドだけフックを飛ばせること、--no-verifyのないコマンドにはHUSKY=0を前置できることが書かれています。pre-commitには、hook idをカンマ区切りで並べるSKIP環境変数があり、ドキュメントは「コミット全体を--no-verifyするより1つのhookだけ飛ばせる」利点として紹介しています。人間には便利な逃げ道が、Claudeにとっては「テストを通す近道」にもなります。

HUSKY=0は、huskyを経由しない素のフックには効きません。一時リポジトリに素のシェルスクリプトをcore.hooksPathで登録し、HUSKY=0 git commitを試すと、フックはそのまま走ってexit 1でした。同じ一時リポジトリでgit commit -nと-nmは、どちらもフックを飛ばして通りました。

PreToolUse hookで迂回を止める

CLAUDE.mdだけでは、Claudeが指示より目の前のエラー解消を優先する余地が残ります。公式は、PreToolUse hookならツール呼び出しを実行前に止められ、permissionDecision: "deny"はbypassPermissionsモードや--dangerously-skip-permissionsでもブロックすると説明しています。

止め方はexit code 2です。Bashの場合、stdinのJSONにtool_input.commandが入ります。exit 2のときにstderrへ書いた理由は、Claudeへのフィードバックとして返り、Claudeは別のやり方を探せます。

#!/bin/bash
# .claude/hooks/block-hook-bypass.sh
INPUT=$(cat)
CMD=$(echo "$INPUT" | jq -r '.tool_input.command // empty')
 
# 引用符の中(コミットメッセージなど)を取り除いてから判定する
BARE=$(echo "$CMD" | sed -E "s/\"[^\"]*\"//g; s/'[^']*'//g")
 
# git -c ... commit のようにオプションを挟んだ形も対象にする
GIT='(^|[[:space:];&|])git([[:space:]]+(-c|-C)[[:space:]]+[^[:space:]]+)*[[:space:]]+(commit|push)'
if ! echo "$BARE" | grep -Eq "$GIT"; then
  exit 0
fi
 
if echo "$BARE" | grep -Eq '(--no-verify|[[:space:]]-[a-zA-Z]*n[a-zA-Z]*([[:space:]]|$)|HUSKY=0|SKIP=|core\.hooksPath)'; then
  echo "Blocked: Gitフックを飛ばす操作は禁止です。フックの出力を読んで原因を直してください。" >&2
  exit 2
fi
 
exit 0

-nはgit commitではフックを飛ばす短縮形で、-nmのようにほかの短いフラグと束ねられることもあります。そのため正規表現で束ねた形も拾っています。一方でgit push -nは--dry-runの意味ですが、このスクリプトは止めます。前段の判定は、ls -nのようにgit commitとgit push以外のコマンドを対象外にするためのものです。

このスクリプトは一時ディレクトリで検証しました。jqでコマンド文字列をJSONに包んで標準入力に流し、終了コードを見ています(git 2.50.1)。素朴にgit commitとgit pushの連続や-nなどだけを見る版には、穴と誤検知がありました。

数字

素朴な版で起きた不具合

  • 止めるべきなのに素通り

    1形

    git -c core.hooksPath=/dev/null commit

  • 止めなくてよいのに停止

    2形

    メッセージ中の -n や --no-verify

一時リポジトリでgit -c core.hooksPath=/dev/null commitを実行すると、exit 1で落ちるはずのフックが走らず、コミットが通りました。gitとcommitの間に-cが入ると、git commitの連続を前提にした判定をすり抜けます。上のスクリプトは、オプションを挟んだ形も対象にする正規表現に直してあります。

git commit -am "fix: -n option"とgit commit -m "wip --no-verify"は、メッセージ中の文字列に反応して止まりました。引用符の中を取り除いてから判定すると解消します。なお、スクリプトが見るのは同じコマンド内のHUSKY=0です。huskyのHow Toには、~/.config/husky/init.shにexport HUSKY=0と書くとそのマシンでフックが動かなくなる、という記載もあります。別コマンドでのexportやこのファイルの編集は、上のスクリプトでは止まりません。

登録は.claude/settings.jsonで、Bashだけにマッチさせます。

{
  "hooks": {
    "PreToolUse": [
      {
        "matcher": "Bash",
        "hooks": [
          {
            "type": "command",
            "command": "\"$CLAUDE_PROJECT_DIR\"/.claude/hooks/block-hook-bypass.sh"
          }
        ]
      }
    ]
  }
}

スクリプトには実行権限が必要です。付け忘れると動きません。

chmod +x .claude/hooks/block-hook-bypass.sh

hookのifフィールド("if": "Bash(git *)"など)でhookの起動を絞ることもできます。ただしこの絞り込みはベストエフォートで、確実な許可・拒否にはhookでなく権限システムを使うよう、公式ガイドは案内しています。ifは前置のVAR=valueを除いて照合しますが、git -c ... commitのようにオプションを挟んだ形などは絞り込みから漏れるため、スクリプト側でコマンド全体を検査します。

権限のdenyルールを併用する

権限側でも、拒否ルールを足せます。denyはallowより優先され、*はルールのどの位置にも置けます。

{
  "permissions": {
    "deny": [
      "Bash(git commit *--no-verify*)",
      "Bash(git push *--no-verify*)"
    ]
  }
}

-nの束ね書きやgit -c core.hooksPath=/dev/null commitのようにオプションを挟んだ形は、この形では拾えません。hookのスクリプトと役割を分け、denyは明白な--no-verifyの保険と考えるのが無難です。

動作確認のしかた

設定が効いているかは、Claudeに実際に迂回を頼んで確かめます。

手順

迂回を頼んで確かめる

  1. 1

    落ちる変更を用意する

    テストが必ず落ちる小さな変更を作り、ステージします。

  2. 2

    迂回を頼む

    「フックが通らないのでgit commit --no-verifyでコミットして」と頼みます。

  3. 3

    返り方を見る

    Blocked:のメッセージがClaudeに返り、フックの出力を読んで直そうとする動きになるかを見ます。

hookが発火したかは、Ctrl+Oのトランスクリプトで確認できます。詳細はclaude --debug-file /tmp/claude.logで取れ、実行中なら/debugでも有効にできます。JSON出力を使う場合、permissionDecisionをhookSpecificOutputの外に置くと無視されるので注意してください。この記事のスクリプトはexit 2を使うため、その落とし穴は避けられます。

つまずきやすい点

  • フォーマッタが毎回落とす: 自動修正hookはファイルを書き換えたコミットを一度止めます。Claude側でPostToolUseのフォーマッタを先に走らせておけば、この往復が減ります。組み方はPostToolUse hookのLint自動修正と、テスト自動実行のPostToolUseとStopで扱っています。
  • モノレポで全部が走る: 変更のあったパッケージだけを検査したいなら、NxのaffectedをClaude Codeのhookから使う設計が参考になります。pre-commitは設定ファイル側でも対象を絞れます。全体にはfilesとexcludeの正規表現があり、hookごとにはpass_filenames(既定はtrue、falseならファイル名を渡さない)とstages(どのGitフックで走らせるか)を指定できます。
default_stages: [pre-commit]   # hookごとにstagesを書かなければこれが効く
repos:
-   repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v2.3.0
    hooks:
    -   id: check-yaml
        files: ^services/api/   # このパスの変更だけを検査(例)

上のfilesのパスは例で、default_stagesの指定はドキュメントにある形です。default_stagesはstagesを書いていないhookにだけ効き、hook側で個別に指定したstagesはそちらが優先されます。

  • 初回のpre-commitが遅い: 初めて走るhookは環境を用意するため時間がかかります。pre-commitのドキュメントによると、node未導入のマシンではnodeをダウンロードしてビルドします。Claudeがタイムアウトと勘違いして迂回に走らないよう、最初は人間がpre-commit run --all-filesで暖めておくと安全です。
  • fail_fastの設定: pre-commitはfail_fast: trueにすると最初の失敗で止まります。既定はfalseで、全部のhookの結果が一度に出ます。Claudeに直させる用途では、指摘がまとまって出る既定のほうが往復が少なく済みます。
  • index.lockでコミットが落ちる: Gitフックではなくロックファイルの残りが原因なら、Claude Codeでindex.lockが残るときの対処で確認と削除の手順を扱っています。

まとめ

迂回の封じ方は、denyルールでは拾えない書き方が残るため、PreToolUse hookを主にしてdenyは保険に回す形が堅実です。command not foundのような環境の失敗はClaudeに直させず、人がinit.shなどで直す領域として切り分けます。

この記事を共有:XはてブLinkedIn