Claude Codeでlefthookを使う — pre-commitの失敗を直させる設定
lefthook.ymlのpre-commitでコミットが落ちたとき、Claude Codeに出力を読ませて直させる設定を、glob・stage_fixed・fail_textの実測つきでまとめます。迂回を止める方法も扱います。
lefthookのpre-commitが落ちたら、Claudeに出力を読ませて直させる
Claude Codeがgit commitを実行すると、lefthookを入れたリポジトリではpre-commitのジョブが走ります。Lintや型チェックが落ちればコミットは止まり、失敗の理由はgit commitの出力に出ます。この出力をClaudeに読ませて直させるのが、この記事の運用です。
必要なのは3つです。
- lefthook.ymlに、落ちたときに原因が読める形でジョブを書く
- 自動で直せるものはlefthookに直させ、残りをClaudeに任せる
--no-verifyやLEFTHOOK=0による迂回を、仕組みで止める
huskyとpre-commitで同じことをする手順は、huskyとpre-commitを併用して失敗したコミットを直させる記事にあります。ここではlefthook固有の設定に絞ります。
lefthookは何をしているのか
lefthookはGitフックの管理ツールです。lefthook.ymlを書いてlefthook installを実行すると、.git/hooks/にフックが作られます。そのフックは、実行されるとlefthook run {hook-name}を呼ぶだけの単純なスクリプトです。
導入の流れは短くまとまります。
lefthookを入れてフックを有効にする
- 1
パッケージを入れる
Node.jsのプロジェクトなら、npmパッケージが最も手軽です。
- 2
lefthook.ymlを書く
プロジェクトの直下に置きます。
.lefthook.ymlや.config/lefthook.ymlも使えます。 - 3
フックを設置する
lefthook installを実行します。npmパッケージならpostinstallで自動実行されます。
npm install --save-dev lefthook
npx lefthook installHomebrew、winget、apt、scoopなどでの導入にも対応しています。npmパッケージにはlefthook、@evilmartians/lefthook、@evilmartians/lefthook-installerの3種類がありますが、後ろ2つはレガシー扱いで、将来は終了予定と案内されています。新規の導入はlefthookで足ります。
一度installしたあとは、lefthook.ymlを書き換えても再インストールは要りません。設定ファイルはGitフックが走るたびに読まれます。Claudeにlefthook.ymlを編集させる運用では、この性質が効きます。
Claudeが読める失敗を出すlefthook.yml
最小構成は次の形です。MarkdownにあるTODOの取り残しを検出して落とします。
# lefthook.yml
pre-commit:
jobs:
- name: check-todo
glob: "*.md"
run: "! grep -n TODO {staged_files}"
fail_text: "TODO を消してから再コミットしてください"jobsはlefthook 1.10.0で加わった書き方で、コマンドとスクリプトを同じ形で並べられます。従来のcommandsでも同じことが書けます。
runに渡せるファイルのテンプレート
runには、対象ファイルに置き換わるテンプレートを書けます。Claudeの変更に対して検査を絞るときの基本部品です。
| テンプレート | 置き換わる中身 |
|---|---|
{staged_files} | 置き換わる中身これからコミットするステージ済みファイル |
{push_files} | 置き換わる中身コミット済みで未pushのファイル |
{all_files} | 置き換わる中身Gitが追跡している全ファイル |
{files} | 置き換わる中身filesオプションのコマンド結果 |
{1}、{2} | 置き換わる中身Gitフックに渡された引数 |
ファイル数が多くてコマンド行の長さ制限を超えるときは、lefthookがファイルを分割して、複数回に分けて順に実行します。
globで検査対象を絞る
globで対象を拡張子やパスに絞ります。1.10.10からは、globをリストで書けます。
pre-commit:
jobs:
- name: lint
run: yarn eslint {staged_files}
glob:
- "*.ts"
- "*.tsx"runにファイルのテンプレートを書かない場合、pre-commitでは{staged_files}、pre-pushでは{push_files}が自動で絞り込まれます。該当ファイルが残らなければ、ジョブは丸ごとスキップされます。npm run lintのようなコマンドでも、glob: "*.js"を付けると、JSがステージされていないコミットでは走りません。
落とし穴は**の意味です。lefthookの**は「1階層以上」のディレクトリに一致し、0階層を含みません。
glob: "src/**/*.js" # src/file.js には一致しない
glob: "src/*.js" # src/file.js だけに一致するsrc直下のファイルと、サブディレクトリ配下の両方を拾いたいときは、パターンを2つ書くか、glob_matcher: doublestarで標準的な挙動にします。Claudeがsrc/**/*.tsと書いて、直下のファイルが検査されない設定を作ることがあるので、この違いはレビューで見る価値があります。
fail_textで次の行動を書く
fail_textは、ジョブが失敗したときに出す文言です。Claudeへの指示を書いておくと、失敗の出力から次の行動が読み取れます。
失敗したとき、コミットの出力はこうなる
一時リポジトリでlefthook v2.2.2を入れ、上のlefthook.ymlを置いて試しました。TODO fixと書いたa.mdをステージしてgit commitすると、コミットは終了コード1で止まります。出力は枠線を省くと次のとおりです。
🥊 lefthook v2.2.2 hook: pre-commit
┃ check-todo ❯
1:TODO fix
exit status 1
summary: (done in 0.01 seconds)
🥊 check-todo: TODO を消してから再コミットしてください (0.01 seconds)ジョブ名、ジョブ自体の出力(grepが見つけた行)、fail_textの順に並びます。Claudeが読みたいのは「どのジョブが、どのファイルの何行目で落ちたか」です。ジョブ名をcheck-todoのように意味の分かる名前にし、検査コマンドが行番号付きで出力するようにしておくと、直す場所がそのまま分かります。
TODOを消して再度ステージすれば、✔️ check-todoと出てコミットが通ります。
落ちる理由は、大きく3つに分かれます。
| 失敗の型 | 出力の手がかり | Claudeの動き |
|---|---|---|
| 指摘による失敗 | 出力の手がかりジョブ名と、該当ファイル・行 | Claudeの動き指摘箇所を直して、ステージし直す |
| 検査が書き換えて失敗 | 出力の手がかり自動修正の後に差分が残る | Claudeの動きgit diffで確認して、ステージし直す |
| 環境の失敗 | 出力の手がかりcommand not foundなど | Claudeの動き直さず、原因を人に報告する |
環境の失敗は、コードを直しても進みません。fail_textに「nodeをPATHに通してください」のような案内を書いておくと、Claudeが無駄な修正を重ねる前に止まれます。
stage_fixedで往復を減らす
フォーマッタやeslint --fixのように、検査がファイルを書き換えるジョブは、stage_fixed: trueが使えます。ジョブの実行後に、lefthookがgit addまで自動で行います。
pre-commit:
parallel: true
jobs:
- name: prettier
glob: "*.{js,ts,css,md}"
run: yarn prettier --write {staged_files}
stage_fixed: truestage_fixedはpre-commitフックでだけ働きます。git addの呼び出しが失敗したときは、フックも失敗します。そうしないと、直っていない内容でコミットが通ってしまうためです。globやexcludeのフィルタは、git addの対象にも適用されます。
行末の空白を取るジョブで挙動を確かめました。perl -pi -e 's/[ \t]+$//' {staged_files}にstage_fixed: trueを付け、末尾に空白のあるc.mdをコミットします。コミットは1回で通り、保存された内容は空白が取れたものでした。ステージ後にglobへ一致しないd.txtだけをコミットしたときは、trim (skip) no files for inspectionと出てジョブが飛ばされました。
parallel: trueを付けると、ジョブが同時に走ります。既定は順次実行です。なおpiped: trueとparallel: trueを同時に指定するとエラーになります。
これは、直せるものをlefthookに、直せないものをClaudeに分担させる設計です。
修正の分担
lefthookに任せる
整形や空白の除去など、機械的に直せるもの。ステージまで自動で済み、コミットも1回で通ります。
Claudeに任せる
型エラー、Lintの指摘、落ちたテストなど、意味を読んで直す必要があるもの。
fail_on_changesを使うと、ファイルが書き換わったときの扱いを選べます。never(既定)、always、ci、non-ciの4値があり、ciはCI環境変数があるときだけ、非ゼロで終了します。ローカルではstage_fixedでなめらかに通し、CIでは書き換えを失敗として検出する、という使い分けができます。
CLAUDE.mdに書く指示
失敗時の動きは、CLAUDE.mdに短く書いておきます。長文にせず、判断基準だけを置きます。
## コミットとlefthook
- コミットがlefthookで失敗したら、出力を最後まで読み、原因のコードを直して再コミットする
- 失敗したジョブだけを確かめたいときは `npx lefthook run pre-commit --job <ジョブ名>` を使う
- 自動修正でファイルが書き換わった場合は `git diff` で確認し、`git add` してから再コミットする
- `--no-verify`、`-n`、`LEFTHOOK=0` でフックを飛ばさない。通らないときは原因を報告する
- 同じ原因で2回続けて失敗したら、それ以上試さずに状況を説明するCLAUDE.mdはClaudeにとってコンテキストであり、強制される設定ではありません。memoryの解説にも、作業を確実にブロックしたいならPreToolUse hookを使うよう明記されています。次の節の迂回対策は、その前提で組みます。
Claude自身にフックを回させる
コミットを試す前に、Claudeがフックを単独で実行できると、失敗の特定が早くなります。lefthook runは、Gitを介さずにフックを直接実行します。
# pre-commitを実行する(ステージ済みのファイルが対象)
npx lefthook run pre-commit
# 全ファイルを対象にする
npx lefthook run pre-commit --all-files
# 指定したファイルだけを対象にする
npx lefthook run pre-commit --file src/a.ts --file src/b.ts
# 特定のジョブだけを実行する
npx lefthook run pre-commit --job lint--all-filesを付けると、{staged_files}のようなテンプレートが全ファイルに置き換わります。新しいジョブを足した直後に、既存のコード全体へ走らせて確かめるときに使えます。--all-filesと--fileを両方指定した場合は--all-filesが無視されます。
設定そのものの確認には、次の2つがあります。
lefthook validate: lefthook.ymlの構文を検証するlefthook dump:extendsやremotesをマージしたあとの最終的な設定を出力する
Claudeにlefthook.ymlを編集させたあとは、lefthook validateを通す、とCLAUDE.mdに添えておくと、タイプミスの混入を防げます。
出力量を絞りたいときは、outputか環境変数LEFTHOOK_OUTPUTを使います。たとえばLEFTHOOK_OUTPUT="meta,success,summary"にすると、実行ログを省いて要約だけを出せます。ただし、Claudeに失敗の原因を読ませたいときは、failureを残してください。
迂回を止める
lefthookには、人間のための逃げ道が公式に用意されています。
LEFTHOOK=0 git commit: lefthookのフックをまるごと無効にするgit commit --no-verify(-n): Git自体がフックを実行しない
一時リポジトリでLEFTHOOK=0 git commitを試すと、TODOを含むファイルのコミットがそのまま通りました。Claudeにとっては、テストを通す近道としても使えてしまいます。
仕組みで止めるなら、PreToolUse hookです。PreToolUse hookはどのパーミッションモードよりも先に評価され、permissionDecision: "deny"を返すと、bypassPermissionsモードや--dangerously-skip-permissionsでもツールの実行をブロックします。exit code 2で止める場合は、stderrに書いた理由がClaudeへのフィードバックになります。
.claude/hooks/block-hook-bypass.shの検査パターンは、huskyの記事のスクリプトから次の1箇所を差し替えて流用できます。HUSKY=0|SKIP=の部分を、lefthookの迂回手段に置き換えます。
# huskyの記事のスクリプトの、迂回を検出する正規表現の行
if echo "$BARE" | grep -Eq '(--no-verify|[[:space:]]-[a-zA-Z]*n[a-zA-Z]*([[:space:]]|$)|LEFTHOOK=0|LEFTHOOK=false|core\.hooksPath)'; thenスクリプト全体と、git -c core.hooksPath=/dev/null commitのようにオプションを挟んだ形への対策は、PreToolUse hookの基本と、huskyの記事に載っています。引用符の中を取り除いてから判定する、といった落とし穴もそちらにあります。
権限側のdenyルールを足す方法もあります。
{
"permissions": {
"deny": [
"Bash(git commit *--no-verify*)",
"Bash(git push *--no-verify*)"
]
}
}この形で拾えるのは明白な--no-verifyだけです。-nの束ね書きやLEFTHOOK=0の前置は止まらないため、hookのスクリプトの保険として使います。
つまずきやすい点
- pnpmでフックが入らない: npmパッケージのpostinstallが実行されません。
pnpm-workspace.yamlのonlyBuiltDependenciesと、ルートのpackage.jsonのpnpm.onlyBuiltDependenciesにlefthookを足します core.hooksPathが設定済み: postinstallは素のlefthook installを実行するため、メッセージを出して止まります。一度だけ意図してlefthook install --forceか--reset-hooks-pathを実行します- CIで
installが走らない: CIがCI=trueを自動で設定する場合、LEFTHOOK=1 npm installのように指定するとpostinstallでフックを設置できます - lefthookが未導入のマシンでフックが黙って抜ける:
assert_lefthook_installed: trueを書くと、実行ファイルが見つからないときにフックが終了コード1で失敗します - マージとリベースで重い検査が走る: ジョブに
skip: [merge, rebase]を付けると、その間は飛ばせます。ブランチ単位ではskip: - ref: mainのように書けます - モノレポで対象がずれる:
rootを指定しても、globはGitリポジトリの実際のルートから計算されます。rootは無視されます
Lintをコミット前でなくClaudeの編集直後に回す設計は、PostToolUse hookのLint自動修正にまとめています。Gitフックはそれでも残った指摘を拾う最後の関門として、役割を分けると見通しが立ちます。コミットメッセージの書式を縛るcommitlintの設定も、lefthookのcommit-msgフックから呼べます。フック全体の仕様はClaude Code Hooks完全ガイドで確認できます。
まとめ
lefthookは、設定を書き換えるたびに再インストールが要らず、globとテンプレートで対象を絞れるため、Claudeに触らせやすいGitフック管理です。整形のような機械的な修正はstage_fixedに寄せ、意味を読む修正だけをClaudeに回すと、コミットの往復が減ります。
一方で、LEFTHOOK=0と--no-verifyは公式の機能なので、CLAUDE.mdの指示だけでは止まりません。迂回を許さない運用にしたいなら、PreToolUse hookまで入れて初めて強制になります。