husky + lint-staged で「壊れたコードをコミットさせない」門番をつくる
コミット前にESLintとPrettierを変更ファイルだけに走らせる仕組みを、husky + lint-stagedで構築。導入手順、commitlint、CIとの二重化、フックが遅い・効かない時の切り分けまで。
「いい感じにリファクタしといて」とClaude Codeに頼んだら、14ファイルが一気に書き換わって返ってきました。
ロジックはちゃんと直っている。でも差分をよく見ると、片方のファイルだけインデントがタブ、未使用のimportが3つ残り、console.logが1個混ざっていました。レビューで全部拾えるかというと、無理です。14ファイルの差分を行単位で目で追って、フォーマットの揺れまで完璧に弾ける人を、僕は見たことがありません。
人間が頑張って見るのをやめて、機械に門番をやらせる。それがpre-commitフックです。この記事では husky と lint-staged で、コミットする瞬間に「変更したファイルだけ」へESLintとPrettierを走らせ、壊れたコードがそもそもコミットに入らないようにする仕組みを組みます。
この記事の要点
- husky はGitフック(コミット等の前後に走る小さなスクリプト)をリポジトリに同梱して、チーム全員に同じチェックを配るツール。
- lint-staged は
git add済みのファイルだけにコマンドを走らせるツール。全ファイル走査と違って一瞬で終わる。 - pre-commitは「数秒で終わる軽い検査」だけ。型チェック・テスト・ビルドはpre-pushかCIに逃がす。欲張ると
--no-verifyで回避され形骸化する。 - ローカルのフックは「速いフィードバック」、CIは「最後の砦」。両方やる二重化が正解で、どちらか片方では穴があく。
- Claude Codeに壊れたコードをコミットさせないには、フック自体に加えて「失敗を握りつぶさず理由を出す」運用ルールも一緒に書かせる。
husky と lint-staged は役割が違う
最初に混乱しやすいので、3つの「フックっぽいもの」を切り分けておきます。ここを曖昧にしたまま設定すると、後で「なぜ動かない」の沼にはまります。
| 名前 | 何をする | いつ発火 |
|---|---|---|
| Gitフック | コミット/プッシュ前後に走るスクリプト本体 | git commit git push など |
| husky | Gitフックをリポジトリで管理・共有しやすくする | 上と同じ(huskyは器) |
| lint-staged | ステージ済みファイルだけにコマンドを実行 | pre-commitから呼ばれて |
husky は「フックを置く場所と配り方」を整える器です。lint-staged は「変更ファイルにだけlintをかける」中身です。そしてもう1つ、Claude Code hooks(Claude Codeのライフサイクルで動く自動化)という別物があります。名前が似ていますが、コミットを止める責任はGitフック側に置くのが鉄則です。理由は後半で書きます。
まずGitのpre-commitを堅くして、その後で必要ならClaude Codeにも同じルールを説明する。この順番でいきます。
全体像:どこで何を弾くか
設計を決めずにClaude Codeへ「品質チェック追加して」と丸投げすると、pre-commitに全テストとビルドまで詰め込まれて、コミット1回が数分かかる地獄ができあがります。僕が実務で落ち着いた分担はこうです。
flowchart LR
A["Claude Codeが修正"] --> B["git addで対象を選ぶ"]
B --> C["husky pre-commit"]
C --> D["lint-staged"]
D --> E["ESLint --fix / Prettier"]
E --> F["commit"]
C --> G["型チェック・テスト・ビルドはpre-push / CIへ"]
ポイントは1つ。pre-commitには「変更ファイルに近い、数秒で終わる検査」しか置かない。プロジェクト全体の型チェック、E2E、ビルドはpre-pushとCIに寄せます。ここを軽く保てるかどうかが、フックが定着するか形骸化するかの分かれ目です。
参考にした公式ドキュメントは Husky Get started、lint-staged README、Git hooks manual、Claude Code hooks reference です。
まず動く最小構成を入れる
ESLintやPrettierをまだ入れていないなら、先に Claude CodeでESLint設定を最適化する と Claude CodeでPrettier設定をカスタマイズする を整えてから戻ってくると、迷いが減ります。
手で入れる場合、コマンドはこれだけです。
npm install --save-dev husky lint-staged eslint prettier
npx husky init
npx husky init が .husky/pre-commit を作り、package.json の prepare スクリプトも追加してくれます。生成された pre-commit の中身を、lint-staged を呼ぶだけに差し替えます。
npx lint-staged
ここで小ネタを1つ。husky v9以降は #!/usr/bin/env sh や . "$(dirname -- "$0")/_/husky.sh" といった先頭行(おまじない)が不要になりました。v10ではこの行があると警告が出ます。古い記事をコピペすると残りがちなので、フックは「実行したいコマンドだけ」に保ってください。
次に package.json です。既存の scripts があるなら、同じキーを潰さずマージします。
{
"scripts": {
"lint": "eslint .",
"lint:fix": "eslint --fix .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"prepare": "husky"
},
"lint-staged": {
"*.{js,jsx,ts,tsx}": [
"eslint --fix --max-warnings=0",
"prettier --write"
],
"*.{json,md,mdx,yml,yaml,css,scss}": [
"prettier --write"
]
}
}
動作確認は、わざと崩したファイルを作ってからやります。ここをサボると「設定したつもり」で本番に流れます。
git add src/example.ts
npx lint-staged --debug
git commit -m "chore: verify pre-commit checks"
--debug を付けると、どの設定ファイルを読み、どのファイルを対象にし、どんなコマンドを実行したかが全部出ます。後でフックが効かない時、この出力が一番の手がかりになります。Claude Codeに調査させるときも、この出力をそのまま貼ると切り分けが速いです。
実務で効く3つの場面
1. TypeScriptアプリ
Claude Codeがコンポーネント・テスト・型定義を同時に触ると、レビュー前のノイズが増えます。ESLintの自動修正とPrettierを変更ファイルにかけるだけで、差分から「整形のゆらぎ」が消えて、ロジックの変更だけが残ります。型チェックは全体を見ないと意味が薄いので、tsc --noEmit はpre-commitに入れず、pre-pushかCIへ。
2. AstroやNext.jsのブログ MDX・JSON・CSSが混在すると、記事の差分に整形差分が紛れ込みます。lint-stagedでMDXとCSSを整えておくと、レビューで「文章の変更」と「整形の変更」をきれいに分けられます。このサイトもまさにこの構成です。
3. モノレポ
全パッケージのテストをpre-commitで回すと遅すぎて誰も使わなくなります。pre-commitは変更ファイルへのPrettier/ESLintだけ。パッケージ単位のテストはCIの paths 条件やタスクランナーに任せます。Claude Codeには「pre-commitは速く、pre-pushは中くらい、CIは完全検査」と言葉で渡しておくと、過剰なフックを作りにくくなります。
設定が育ったら config ファイルへ
リポジトリが大きくなると、package.json に長い設定を書くより lint-staged.config.mjs に分けたほうが読みやすくなります。次の例は、ファイル名に空白が含まれても壊れないように引用符でくくっています。関数形式では、lint-stagedが自動でファイル名を渡さなくなるので、コマンド側に自分でファイルを並べる点に注意してください。
// lint-staged.config.mjs
// ファイル名に空白や記号が混じっても壊れないよう引用符でくくる
const shellQuote = (file) => `"${file.replaceAll('"', '\\"')}"`;
const joinFiles = (files) => files.map(shellQuote).join(" ");
export default {
// JS/TS: 自動修正 → 整形。--max-warnings=0 で警告も通さない
"*.{js,jsx,ts,tsx}": (files) => [
`eslint --fix --max-warnings=0 ${joinFiles(files)}`,
`prettier --write ${joinFiles(files)}`,
],
// 設定・ドキュメント類は整形だけ
"*.{json,md,mdx,yml,yaml,css,scss}": (files) =>
`prettier --write ${joinFiles(files)}`,
};
この形にしたら、package.json 側の lint-staged キーは消します。設定が2か所にあると、人間もClaude Codeも「どっちが正?」で迷い、片方だけ直して事故ります。設定の正は常に1か所にしておく。地味ですが効きます。
commitlint と pre-push を分けて持つ
コミットメッセージを Conventional Commits(feat: fix: のような接頭辞ルール)にそろえたいなら、commit-msg フックで commitlint を使います。
npm install --save-dev @commitlint/cli @commitlint/config-conventional
// commitlint.config.mjs
export default {
extends: ["@commitlint/config-conventional"],
rules: {
// 件名は72文字まで
"subject-max-length": [2, "always", 72],
},
};
.husky/commit-msg の中身はこれだけです。
npx --no -- commitlint --edit "$1"
これで feat: add settings page のような形式を強制できます。一方、ビルドやテストはpre-pushに逃がします。
npm run validate
package.json 側を次のようにそろえておくと、ローカルとCIで同じコマンドを叩けます。「ローカルで通ったのにCIで落ちる」のズレが減ります。
{
"scripts": {
"typecheck": "tsc --noEmit",
"test:ci": "vitest run --coverage",
"build": "vite build",
"validate": "npm run typecheck && npm run lint && npm run format:check && npm run test:ci && npm run build"
}
}
なぜCIとローカルの二重化が要るのか
「CIで弾くなら、ローカルのフックいらなくない?」とよく聞かれます。逆です。役割が違うから両方やります。
- ローカル(husky): フィードバックが速い。崩れたコードがそもそもコミットに入らない。pushする前に自分で気づける。
- CI: 最後の砦。
--no-verifyでフックを飛ばした人も、フックを入れ忘れた新メンバーも、ここで必ず止まる。
ローカルのフックは飛ばせてしまいます。だからローカルだけだと穴があく。逆にCIだけだと、壊れたコミットが一度リモートに乗ってから落ちるので、修正コミットが積み重なって履歴が汚れます。ローカルで早めに弾き、CIで取りこぼしを止める。この二段構えが、人数が増えても崩れない形です。CI側の組み方は Claude CodeでCI/CDを設定する にまとめてあります。
僕がやらかした失敗3つ
正直に書きます。最初のpre-commitは、むしろ開発の邪魔でした。
ひとつ目は、pre-commitに全部詰め込んだこと。npm run build と全テストを毎回走らせたら、コミット1回に3分かかるようになりました。結果どうなったか。チーム全員が git commit --no-verify を覚えて、フックが完全に死にました。品質を上げるつもりが、回避策を教育していたわけです。今はpre-commitを5秒以内に保つのを目標にしています。
ふたつ目は、部分ステージの挙動を誤解したこと。同じファイルに「ステージ済みの変更」と「未ステージの変更」が混在していると、lint-stagedの対象がどこまでか分からなくなって、「効いてないのでは」と何度も騒ぎました。原因調査は決まって git status --short → git diff --staged → npx lint-staged --debug の3点セット。これで毎回片付きます。
みっつ目は、Windows混在チームで改行コードを放置したこと。フックはシェルスクリプトなので、CRLFが混ざると「動いたり動かなかったり」します。.gitattributes でLFに寄せたら一発で安定しました。
* text=auto eol=lf
*.cmd text eol=crlf
*.bat text eol=crlf
Claude Codeに壊れたコードをコミットさせない
ここがこの記事の本題です。Claude Codeは賢いですが、放っておくと「とりあえず動く差分」を平気でコミットしようとします。未使用import、デバッグ用の console.log、片方だけ違う整形。これを人間のレビューだけで止めるのは無理があります。
そこで二段で縛ります。1つ目はGitフック。誰がコミットしても、Claude Codeがコミットしても、pre-commitは必ず発火します。だから「コミットを止める最終責任はGitフックとCIに置く」。Claude Code hooksは便利ですが、これはGitフックの代替ではなく補助として使います。作業終了時に npm run lint を提案させる、危険なシェルコマンドをブロックする、といった用途ですね。詳しくは Claude Code Hooks入門 を参照してください。
2つ目は、Claude Codeへの依頼文に運用ルールを混ぜることです。フックの実装だけ頼むと、エラーを握りつぶす実装をされることがあります。だから「対象」「重さ」「成功条件」「失敗時の振る舞い」まで明示します。コピペで使える依頼文がこれです。
このNode.js/TypeScriptリポジトリに husky と lint-staged を導入してください。
- pre-commit ではステージ済みの JS/TS/JSON/MD/MDX/CSS だけを対象にし、
eslint --fix --max-warnings=0 と prettier --write を実行する
- 型チェック・テスト・ビルドは pre-commit に入れず、pre-push または CI に分ける
- 失敗は握りつぶさず、どのファイル・どのルールで落ちたか短く出す
- CI と同名の `validate` スクリプトを用意し、ローカルと同じコマンドにそろえる
- 変更後、手動で確認するコマンドを README 用に3行でまとめる
husky v9+ の作法に従い、フックの先頭のおまじない行は書かないでください。
最後にもう1つ。最初から完璧なフックを目指さないことです。まずPrettierだけ通す、次にESLint、最後にcommit-msg。この順で足すと、落ちたときに原因がすぐ分かります。
よくある質問
Q. husky と lint-staged はどっちか片方でいい? 役割が違うので両方使います。husky はフックを配る器、lint-staged は変更ファイルにだけコマンドを走らせる中身です。husky だけだと全ファイルを毎回lintして遅くなり、lint-staged だけだとチームにフックが配られません。
Q. pre-commit が遅い。どうする?
pre-commitに重い処理を入れていないか疑います。tsc・全テスト・build が入っていたらpre-pushかCIへ移動。そのうえで対象拡張子を絞り、eslint --fix --max-warnings=0 のように対象を変更ファイルだけにします。目安は5秒以内です。
Q. フックが効かない(コミットが素通りする)。
まず .husky/pre-commit が存在し中身が npx lint-staged になっているか確認します。次に npm run prepare(= husky)を一度実行してフックを有効化。それでも駄目なら npx lint-staged --debug で対象ファイルとコマンドを確認。core.hooksPath が別の場所を指していないかも見ます。
Q. 緊急時に --no-verify で飛ばすのはあり?
緊急修正で一時的に使う余地は残してOKです。ただし常用している人がいるなら、それはフックが重すぎるサイン。CI側で同じチェックをしておけば、ローカルを飛ばしても最後はCIで止まります。
Q. commitlint は必須? 必須ではありません。コミットメッセージを揃えたい、自動でCHANGELOGを作りたいチームには効きます。まずは pre-commit の lint だけ入れて、メッセージ規約は後から足すのが無理のない順番です。
実際に試した結果
冒頭の「14ファイル一括書き換え」の件以来、僕はClaude Codeの差分を一行ずつ睨むのをやめました。代わりに見るのは、どの門番で止まったかです。
小さなTypeScript/Viteプロジェクトに、わざと整形崩れ・未使用import・MDXのスペース崩れを混ぜて流してみました。pre-commitは変更ファイルだけを処理するので体感数秒で終わり、--debug で対象も全部見えました。型エラーはpre-commitでは拾わず、pre-pushの npm run validate で止める設計にしたら、コミットのリズムを壊さずに運用できました。
結局のところ、husky + lint-staged は「Claude Codeで増えた変更量を、人間のレビューだけに背負わせない」ための現実的な防波堤です。pre-commitは軽く、pre-pushとCIは重く、Claude Codeにはその分担を言葉で渡す。この3点を守るだけで、フックは邪魔な儀式ではなく、チーム全員の作業を一定品質にそろえる足場になります。
仕組み化をチームに広げたいなら、研修・相談 でも具体的な組み方を相談できます。まずは今日、自分のリポジトリにPrettierだけのpre-commitを1つ足すところから始めてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
Claude Codeに1ファイルだけ直させる指示文のつくり方
「もっと良くして」で40行も変えられた失敗から学んだ、触る範囲・検証・戻し方をセットにしたClaude Code用の依頼文テンプレートを紹介します。
Claude Code の権限拒否から復旧する: 止まった理由を次の安全手順に変える
Claude Code のコマンドが拒否されたとき、焦って許可を広げずに、拒否理由、代替手順、証拠コマンド、再試行条件へ分解する方法。
Claude Codeにビルド→スモークテスト→自動修正を回させる足場の作り方
最小スモークテストの選び方、失敗ログを食わせて直させるループ、回数上限と確認ゲートで暴走を止める方法を、コピペで動くコード付きで紹介します。