Git hooksとは、git commit や git push などの操作の前後にGitが自動で実行するスクリプトのことで、huskyはそのスクリプトをリポジトリ内の .husky/ フォルダで管理し、npm install した全員に同じフックを配る仕組みです。フックを使うと、「lintを通していないコードをコミットできない」「規約に合わないコミットメッセージを受け付けない」「テストが落ちる状態ではpushできない」といったルールを、チーム全員の手元で自動的に守れます。

この記事では、まずGit hooks 8種の早見表(タイミング・引数・止められるか)を示し、素の .git/hooks と core.hooksPath で最小例を動かしてから、husky 9.1.7 の現行手順(npx husky init)を、lint-staged 17.6.0・commitlint 21.2.3・pre-push でのテストと組み合わせて実際に実行します。コミットが止まる出力と通る出力を両方そのまま掲載し、--no-verify や HUSKY=0・CI・モノレポでの扱い、lefthook 2.1.15 と pre-commit 4.6.2(pip)との比較表、フックが動かない時の対処表まで、検索上位の記事にはない実測ベースでまとめました。バージョンはすべてnpm・PyPIで2026年10月1日に確認したものです。

Git hooksの早見表

まずは「どのフックが・いつ・何を受け取って・止められるか」の一覧です。コミットを止めたいなら pre-commit か commit-msg、pushを止めたいなら pre-push と覚えておくと、ほとんどの用途は足ります。

フック 呼ばれるタイミング 受け取る引数 非ゼロ終了で止められるか --no-verify で飛ばせるか 主な用途
pre-commit git commit 直後、メッセージ入力の前 なし 止められる(コミット中止) 飛ばせる lint・フォーマット・秘密情報チェック
prepare-commit-msg 既定メッセージ作成後、エディタ起動前 $1 メッセージファイル、$2 由来(message / template / merge / squash / commit)、$3 コミットID(amend時) 止められる 飛ばせない ブランチ名からチケット番号を自動挿入
commit-msg メッセージ確定後、コミット作成の直前 $1 メッセージファイル(.git/COMMIT_EDITMSG) 止められる 飛ばせる メッセージ規約(commitlint)
post-commit コミット作成後 なし 止められない(通知用) 対象外 通知・ログ
pre-merge-commit git merge の自動マージ成功後、コミット前 なし 止められる 飛ばせる マージコミット前の検査
pre-rebase git rebase 開始前 $1 上流ブランチ、$2 対象ブランチ(現在のブランチ以外を指定した時のみ) 止められる git rebase --no-verify で飛ばせる 特定ブランチのrebase防止
post-checkout checkout / switch / clone / worktree add の後 $1 前のHEAD、$2 新しいHEAD、$3 フラグ(1=ブランチ切替、0=ファイル取り出し) 止められない(終了コードに反映されるだけ) 対象外 依存関係の再インストール案内
post-merge git merge / git pull の成功後 $1 squashマージなら1 止められない 対象外 lockファイル変更時の npm install 案内
pre-push git push でリモートに送る前 $1 リモート名、$2 URL。標準入力に <local ref> <local sha> <remote ref> <remote sha> が1行ずつ 止められる(push中止) git push --no-verify で飛ばせる テスト実行・mainへの直接push防止
post-rewrite commit --amend / rebase の後 $1 amend か rebase。標準入力に書き換え前後のSHA 止められない 対象外 メモ・タグの追従
pre-receive / update / post-receive サーバー側で git push を受け取った時 標準入力または引数で更新される参照と前後のSHA pre-receive と update は拒否できる 飛ばせない サーバー側のポリシー強制(GitHub等ではブランチ保護で代替)

補足として、Git公式ドキュメント(githooks)に書かれている前提を3つ押さえておきます。

  • フックは $GIT_DIR/hooks/(通常は .git/hooks/)にある実行可能ファイルが対象で、git config core.hooksPath で場所を変えられます(Git 2.9以降)
  • .git/hooks/ は git clone でコピーされません。チームで共有するには、リポジトリ内のフォルダを core.hooksPath に向けるか、husky・lefthook・pre-commit のようなツールで各自の環境にインストールする必要があります
  • クライアント側のフックはワーキングツリーのルートをカレントディレクトリとして実行されます

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Git hooks・husky v9の設定方法|実行結果とlefthook比較」

素のGit hooksで最小例を動かす

ツールを入れる前に、Gitが素で持っている仕組みを2通り(.git/hooks/ 直置きと core.hooksPath)で動かしておくと、huskyが内部で何をしているかがよくわかります。検証環境は git 2.43.0(Linux)です。

.git/hooks/pre-commit にスクリプトを置く

git init 直後の .git/hooks/ には pre-commit.sample など14個のサンプルがあり、.sample を外した名前の実行可能ファイルを置くだけでフックは有効になります。ステージ済みの .js に console.log が残っていたらコミットを止める最小例です。

#!/bin/sh
# .git/hooks/pre-commit
# ステージ済みの .js ファイルに console.log が残っていたらコミットを止める
files=$(git diff --cached --name-only --diff-filter=ACMR -- '*.js')
[ -z "$files" ] && exit 0
if grep -Hn 'console\.log' $files; then
  echo "pre-commit: console.log が残っています。削除してからコミットしてください。"
  exit 1
fi
chmod +x .git/hooks/pre-commit
git add util.js
git commit -m "add label()"

実行結果(止まる場合)です。フックが非ゼロで終了すると、Gitは何も言わずにコミットを中止します。メッセージはフック自身が出したものです。

util.js:2:  console.log("label", n);
pre-commit: console.log が残っています。削除してからコミットしてください。
exit code: 1

console.log を消して再度コミットすると、そのまま通ります。

[main 9e8d2fe] add label()
 1 file changed, 3 insertions(+)
 create mode 100644 util.js
exit code: 0

実行権限が付いていないと、フックは無視されます。この場合Gitがヒントを出してくれるので、「フックが動かない」と感じたらまずこの行を探します(chmod -x で再現)。

hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable.
hint: You can disable this warning with `git config advice.ignoredHook false`.
[main fba1c37] add label()

core.hooksPath でリポジトリ内のフォルダを使う

.git/hooks/ はcloneで配られないため、フックをリポジトリ内の .githooks/ に置いてコミットし、各自が git config core.hooksPath .githooks を1回実行するのが、ツールなしで共有する定番です。commit-msg と prepare-commit-msg を置いて、引数の中身も確認します。

#!/bin/sh
# .githooks/commit-msg  ($1 = コミットメッセージが入った一時ファイル)
msg=$(head -n1 "$1")
if ! echo "$msg" | grep -Eq '^(feat|fix|docs|refactor|test|chore)(\(.+\))?: .+'; then
  echo "commit-msg: 1行目は 'feat: 内容' の形式にしてください。受け取った1行目: $msg"
  exit 1
fi
#!/bin/sh
# .githooks/prepare-commit-msg  ($1=メッセージファイル, $2=由来, $3=コミットID)
echo "prepare-commit-msg: \$1=$1 \$2=$2 \$3=$3"
chmod +x .githooks/*
git config core.hooksPath .githooks
git add version.js .githooks
git commit -m "version file"
prepare-commit-msg: $1=.git/COMMIT_EDITMSG $2=message $3=
commit-msg: 1行目は 'feat: 内容' の形式にしてください。受け取った1行目: version file
exit code: 1

-m で渡したので $2 は message、amendではないので $3 は空です。feat: を付けると通ります。

prepare-commit-msg: $1=.git/COMMIT_EDITMSG $2=message $3=
[main 3b37873] feat: add version file
 3 files changed, 11 insertions(+)
 create mode 100755 .githooks/commit-msg
 create mode 100755 .githooks/prepare-commit-msg
 create mode 100644 version.js
exit code: 0

core.hooksPath を設定すると .git/hooks/ は見られなくなる点と、設定はリポジトリごと(.git/config)なのでcloneした人が各自実行する必要がある点が、この方式の運用上のポイントです。husky v9は、まさにこの core.hooksPath を npm install 時に自動設定するツールです。

husky v9の設定方法(npx husky init)

インストールと初期化

前提はNode.js 18以上(検証は Node 22.22.2 / npm 10.9.7 / husky 9.1.7)。v9では npx husky init の1コマンドで初期設定が完了します。

npm i -D husky
npx husky init

husky init が行うのは次の3つです。

作られる・変わるもの 内容
package.json の scripts.prepare "prepare": "husky" を追加(npm install のたびに husky が走り、フックを再インストール)
.husky/pre-commit npm test の1行(pnpmなら pnpm test、bunなら bun test など、実行したパッケージマネージャに合わせて生成)
.husky/_/(gitignore済み) 実体のフック群。git config core.hooksPath .husky/_ も同時に設定される

実行後の状態です。

{
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1",
    "prepare": "husky"
  },
  "devDependencies": {
    "husky": "^9.1.7"
  }
}
$ cat .husky/pre-commit
npm test

$ git config core.hooksPath
.husky/_

$ ls .husky/_
.gitignore  applypatch-msg  commit-msg  h  husky.sh  post-applypatch  post-checkout
post-commit  post-merge  post-rewrite  pre-applypatch  pre-auto-gc  pre-commit
pre-merge-commit  pre-push  pre-rebase  prepare-commit-msg

.husky/_/ の各フックはすべて同じ2行で、h というスクリプトが「同名のファイル .husky/<フック名> があれば sh -e で実行する」だけの仕組みです。.husky/_/ は .gitignore(中身は *)で無視され、コミットされるのは .husky/pre-commit などの中身だけです。

#!/usr/bin/env sh
. "$(dirname "$0")/h"

h の中身(9.1.7)を読むと、この記事で後述する挙動がすべて説明できます(右側のコメントは筆者が追加したものです)。

#!/usr/bin/env sh
[ "$HUSKY" = "2" ] && set -x                      # HUSKY=2 でデバッグ出力
n=$(basename "$0")
s=$(dirname "$(dirname "$0")")/$n                  # .husky/<フック名>

[ ! -f "$s" ] && exit 0                            # ファイルがなければ何もしない

if [ -f "$HOME/.huskyrc" ]; then
    echo "husky - '~/.huskyrc' is DEPRECATED, please move your code to ~/.config/husky/init.sh"
fi
i="${XDG_CONFIG_HOME:-$HOME/.config}/husky/init.sh"
[ -f "$i" ] && . "$i"                              # 個人用の初期化ファイルを読む

[ "${HUSKY-}" = "0" ] && exit 0                    # HUSKY=0 でスキップ

export PATH="node_modules/.bin:$PATH"              # npx なしでもコマンドが見つかる
sh -e "$s" "$@"                                    # -e: 途中で失敗したら即終了
c=$?

[ $c != 0 ] && echo "husky - $n script failed (code $c)"
[ $c = 127 ] && echo "husky - command not found in PATH=$PATH"
exit $c

最初のコミットで動作確認

npm test が「テスト未設定」で exit 1 するので、初期状態ではコミットが止まります。これが動作確認になります。

$ git add -A && git commit -m "chore: add husky"

> husky-demo@1.0.0 test
> echo "Error: no test specified" && exit 1

Error: no test specified
husky - pre-commit script failed (code 1)
exit code: 1

HUSKY=0 を付けるとフックを飛ばしてコミットできます(初回だけこの方法で入れると、履歴がきれいになります)。

$ HUSKY=0 git commit -m "chore: add husky"
[main 11c3157] chore: add husky
 3 files changed, 39 insertions(+), 2 deletions(-)
 create mode 100644 .husky/pre-commit
 create mode 100644 package-lock.json

v8以前の手順との違い

検索上位にはv6〜v8時代の手順が多く残っています。v9で削除・非推奨になったコマンドを打つと、9.1.7では次のメッセージが出ます。

v8以前の書き方 v9(9.1.7)での結果 v9の書き方
npx husky install husky - install command is DEPRECATED(動作はする) npx husky(prepare スクリプトが呼ぶ)
npx husky add .husky/pre-commit "npm test" husky - add command is DEPRECATED で終了コード1 echo "npm test" > .husky/pre-commit
npx husky set ... / npx husky uninstall 同じく DEPRECATED で終了コード1 ファイルを直接編集 / git config --unset core.hooksPath
"prepare": "husky install" 動くが非推奨 "prepare": "husky"
フック先頭の #!/usr/bin/env sh と . "$(dirname -- "$0")/_/husky.sh" 実行のたびに husky - DEPRECATED ... They WILL FAIL in v10.0.0 と表示 2行を削除してコマンドだけ書く

pre-commitでlint-stagedを動かす

lint-stagedは、ステージされたファイルだけを対象にlintやフォーマッタを走らせるツールです。プロジェクト全体にESLintをかけると数十秒かかる場合でも、変更した数ファイルなら1〜2秒で終わります。lint-staged 17.6.0 は Node 22.22.1 以上が必要です。

npm i -D lint-staged eslint @eslint/js prettier   # lint-staged 17.6.0 / eslint 10.11.0 / prettier 3.9.9
echo "npx lint-staged" > .husky/pre-commit

.lintstagedrc.json(キーがglob、値が実行コマンド。配列なら順に実行)と、no-unused-vars と no-console をエラーにした eslint.config.js です。

{
  "*.js": ["eslint --fix", "prettier --write"],
  "*.{json,md,yml}": "prettier --write"
}
// eslint.config.js(package.json に "type": "module" がある前提)
import js from "@eslint/js";

export default [
  js.configs.recommended,
  {
    files: ["**/*.js"],
    languageOptions: {
      ecmaVersion: 2024,
      sourceType: "module",
      globals: { console: "readonly" },
    },
    rules: {
      "no-unused-vars": "error",
      "no-console": "error",
    },
  },
];

コミットが止まる出力

未使用変数と console.log を含む src.js をコミットしてみます。

export function total(items) {
  const unused = 1;
  console.log(items);
  return items.reduce((sum, i) => sum + i.price * i.qty, 0);
}
$ git add src.js && git commit -m "feat: add total()"
⋯ Backing up original state…
✔ Done backing up original state (aaf8e48)!
⋯ Running tasks for staged files…
    *.js — 1 file
      ⋯ eslint --fix
      ⋯ prettier --write

✖ eslint --fix
↓ prettier --write

✖ Failed to run tasks for staged files!
↓ Skipped staging changes from tasks…
⋯ Reverting to original state because of errors…
✔ Done reverting to original state!
⋯ Cleaning up temporary files…
✔ Done cleaning up temporary files!

✖ eslint --fix:

/path/to/husky-demo/src.js
  2:9  error  'unused' is assigned a value but never used  no-unused-vars
  3:3  error  Unexpected console statement                 no-console

✖ 2 problems (2 errors, 0 warnings)
husky - pre-commit script failed (code 1)
exit code: 1

出力の読み方は次のとおりです(パスは短縮しています)。

  • Backing up original state:lint-stagedは実行前に作業ツリーの状態を退避し、失敗したら Reverting to original state で元に戻します。--fix が途中まで書き換えた状態が残る心配はありません
  • ✖ eslint --fix で止まった後の ↓ prettier --write は「実行されなかった」印です
  • 最後の husky - pre-commit script failed (code 1) はhuskyの h が付けた行で、どのフックが何番で失敗したかがわかります

コミットが通る出力(自動整形がステージされる)

エラーを直し、わざとインデントを4つ・セミコロンなしにした状態でコミットすると、prettierが整形した結果が自動でステージに反映されてコミットされます。

$ git add src.js && git commit -m "feat: add total()"
⋯ Backing up original state…
✔ Done backing up original state (f21931a)!
⋯ Running tasks for staged files…
    *.js — 1 file
      ⋯ eslint --fix
      ⋯ prettier --write

✔ eslint --fix
✔ prettier --write

✔ Done running tasks for staged files!
⋯ Staging changes from tasks…
✔ Done staging changes from tasks!
⋯ Cleaning up temporary files…
✔ Done cleaning up temporary files!
[main ab12ad3] feat: add total()
 1 file changed, 3 insertions(+)
 create mode 100644 src.js
exit code: 0
$ git show HEAD:src.js
export function total(items) {
  return items.reduce((sum, i) => sum + i.price * i.qty, 0);
}

lint-stagedの設定でつまずきやすい点

やりたいこと 書き方・注意
設定ファイルの置き場所 package.json の "lint-staged"、.lintstagedrc(JSON/YAML)、.lintstagedrc.json、lint-staged.config.js(ESM/CJS)、.lintstagedrc.mjs など
複数コマンドを順に実行 値を配列にする:["eslint --fix", "prettier --write"]
TypeScriptの型チェック "*.ts": "tsc --noEmit" と書くとファイル名が渡されて tsconfig.json が無視される。JS設定で関数にしてファイル名を捨てる:"*.ts": () => "tsc -p tsconfig.json --noEmit"
ステージ以外も含めて全ファイル npx lint-staged --all(--no-stash と --allow-empty を含意)
直列で実行したい npx lint-staged --concurrent false
失敗しても全タスク走らせたい --continue-on-error
デバッグ npx lint-staged --debug。フック全体は HUSKY=2 git commit で set -x 相当の出力

.husky/pre-commit に npx lint-staged と書きましたが、h が node_modules/.bin を PATH に足すので lint-staged だけでも動きます。npx を付けておくと、フック以外から手で実行した時も同じ書き方で済みます。

commit-msgでcommitlintを動かす

commitlintは、コミットメッセージが「type(scope): subject」形式(Conventional Commits)に沿っているかを検査するツールです。commit-msg フックは $1 にメッセージファイルのパスを受け取るので、それを --edit に渡します。

npm i -D @commitlint/cli @commitlint/config-conventional   # 21.2.3
echo 'npx --no -- commitlint --edit "$1"' > .husky/commit-msg
// commitlint.config.js("type": "module" の場合。CJSなら module.exports = {...})
export default { extends: ["@commitlint/config-conventional"] };

npx --no は「未インストールならレジストリから取りに行かずに失敗する」指定で、フックの中でネットワークアクセスが発生しないようにするためのものです。

止まるメッセージと通るメッセージ

$ git commit -m "update tax"
⧗   --- input ---
update tax
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint

husky - commit-msg script failed (code 1)
exit code: 1
$ git commit -m "Feat: Add tax rate."
⧗   --- input ---
Feat: Add tax rate.
✖   subject must not be sentence-case [subject-case]
✖   subject may not end with full stop [subject-full-stop]
✖   type must be lower-case [type-case]
✖   type must be one of [build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test] [type-enum]

✖   found 4 problems, 0 warnings
ⓘ   Get help: https://github.com/conventional-changelog/commitlint/#what-is-commitlint

husky - commit-msg script failed (code 1)
exit code: 1
$ git commit -m "feat(order): add tax rate"
[main b26b527] feat(order): add tax rate
 1 file changed, 1 insertion(+)
 create mode 100644 tax.js
exit code: 0

[type-enum] のようにルール名が角かっこで出るので、緩めたい時は commitlint.config.js の rules にそのルール名で上書きします(例:"subject-case": [0] で無効化)。メッセージの書き方自体の規約は、コミットメッセージの記事に譲ります。

pre-pushでテストを動かす

pushの直前にテストを走らせると、落ちるコードがリモートに上がることを手元で止められます。pre-push はコミットのたびには走らないので、数十秒かかるテストでも置きやすいフックです。例では外部ライブラリ不要の node --test を使います。

npm pkg set scripts.test="node --test --test-reporter=spec"
echo "npm test" > .husky/pre-push
// src.test.js
import { test } from "node:test";
import assert from "node:assert/strict";
import { total } from "./src.js";

test("total() sums price * qty", () => {
  assert.equal(total([{ price: 100, qty: 2 }, { price: 50, qty: 1 }]), 240);
});

期待値をわざと間違えた(正しくは250)状態でpushすると止まります(スタックトレースは一部省略)。

$ git push -u origin main

> husky-demo@1.0.0 test
> node --test --test-reporter=spec

✖ total() sums price * qty (1.17386ms)
ℹ tests 1
ℹ pass 0
ℹ fail 1

✖ failing tests:

test at src.test.js:5:1
✖ total() sums price * qty (1.17386ms)
  AssertionError [ERR_ASSERTION]: Expected values to be strictly equal:

  250 !== 240

      at TestContext.<anonymous> (/path/to/husky-demo/src.test.js:6:10)
husky - pre-push script failed (code 1)
error: failed to push some refs to '../remote.git'
exit code: 1

error: failed to push some refs to は、フックが非ゼロで終わった時にGitが出す定型文です。テストを直すと通ります(ℹ の集計行は一部省略)。

$ git push

> husky-demo@1.0.0 test
> node --test --test-reporter=spec

✔ total() sums price * qty (0.793033ms)
ℹ tests 1
ℹ pass 1
ℹ fail 0
To ../remote.git
   edd4bd0..b6622ad  main -> main
exit code: 0

ここまでの .husky/ の完成形は次の3ファイルです。

.husky/pre-commit   → npx lint-staged
.husky/commit-msg   → npx --no -- commitlint --edit "$1"
.husky/pre-push     → npm test

フックをスキップする方法とCI・モノレポでの設定

スキップの方法の早見表

方法 効く範囲 使いどころ
git commit --no-verify(-n) pre-commit と commit-msg(prepare-commit-msg は飛ばせない) WIPコミットを急いで残したい時
git push --no-verify pre-push 同上
HUSKY=0 git commit ... huskyが管理する全フック(h の先頭で exit 0) --no-verify を持たないコマンドにも効く
export HUSKY=0 … unset HUSKY シェルセッションの間 まとめて複数操作する時
~/.config/husky/init.sh に export HUSKY=0 その端末で常に GUIクライアントから止めたい時
git -c core.hooksPath=/dev/null commit ... Gitのフック機構そのもの ツールに関係なく1回だけ完全に無効化

実行例です。--no-verify を付けると、lintに通らないファイルも規約外のメッセージもそのまま入ります。

$ git add bad.js && git commit -m "wip" --no-verify
[main 1dbabeb] wip
 1 file changed, 2 insertions(+)
 create mode 100644 bad.js
exit code: 0

CI(GitHub Actions)ではフックを入れない

CIではコミットしないのでフックは不要です。husky公式が案内するのは環境変数 HUSKY: 0 の設定で、prepare が走っても husky は HUSKY=0 skip install と表示して何もしません。

# .github/workflows/ci.yml
name: ci
on: [push, pull_request]
env:
  HUSKY: 0
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: actions/setup-node@v7
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test

npm ci の付け方によって prepare の挙動が変わるので、cloneしたての状態で試した結果を表にします。--omit=dev は「devDependenciesのhuskyがないのに prepare は走る」ため失敗するのが最も多いつまずきです。

コマンド 結果(実測) core.hooksPath
npm ci > husky が走り、フックがインストールされる .husky/_
HUSKY=0 npm ci HUSKY=0 skip install と表示して終了コード0 未設定
npm ci --ignore-scripts prepare 自体が走らない 未設定
npm ci --omit=dev(本番用) sh: 1: husky: not found → npm error code 127 でインストール失敗 未設定
"prepare": "husky || true" にして npm ci --omit=dev husky: not found は出るが終了コード0で続行 未設定

本番イメージのビルドなど --omit=dev を使う場面では、HUSKY=0 を渡すか、"prepare": "husky || true" にしておくと安全です。もう少し丁寧にするなら、公式の例のように .husky/install.mjs で CI や NODE_ENV=production の時に process.exit(0) する方法もあります。

モノレポ(package.jsonがサブディレクトリにある場合)

huskyはカレントディレクトリに .git があることを前提にしているので、frontend/ の中で npx husky を実行すると次のように出ます。

$ cd frontend && npx husky
.git can't be found

公式の対処は、prepare でリポジトリのルートに移動してから .husky の場所を引数で渡す方法です。フック側では逆に frontend/ に降りてから実行します。

{
  "scripts": {
    "prepare": "cd .. && husky frontend/.husky"
  }
}
# frontend/.husky/pre-commit
cd frontend
npx lint-staged
$ npm run prepare
> frontend@1.0.0 prepare
> cd .. && husky frontend/.husky

$ git config core.hooksPath
frontend/.husky/_

$ git commit -m "chore: husky in subdir"
> frontend@1.0.0 test
> echo frontend-test-ok
frontend-test-ok
[main (root-commit) 3604463] chore: husky in subdir

lint-staged側は、各パッケージに .lintstagedrc.json を置くと、ステージされたファイルに最も近い設定ファイルが使われ、その設定のディレクトリでコマンドが実行されるので、パッケージごとに違うlint設定でも1つの pre-commit から扱えます。

lefthook・pre-commit(pip)との比較

huskyはNode.jsプロジェクトの定番ですが、Go製の lefthook(並列実行・言語非依存)と Python製の pre-commit(Pythonプロジェクトの定番)も広く使われています。同じ「ESLint + Prettier + commitlint」を lefthook で、「ruff」を pre-commit で動かして比べます。

lefthook 2.1.15 の最小設定

npmからは npm i -D lefthook で入り、postinstallで lefthook install が自動実行されます(バイナリはプラットフォーム別パッケージ lefthook-linux-x64 などから取得)。設定は lefthook.yml 1ファイルで、フック名の下に jobs を並べ、parallel: true で並列実行できます。

# lefthook.yml(lefthook 2.1.15)
pre-commit:
  parallel: true
  jobs:
    - name: eslint
      glob: "*.js"
      run: npx eslint --fix {staged_files}
      stage_fixed: true
    - name: prettier
      glob: "*.{js,json,md,yml}"
      run: npx prettier --write {staged_files}
      stage_fixed: true

commit-msg:
  jobs:
    - name: commitlint
      run: npx --no -- commitlint --edit {1}

pre-push:
  jobs:
    - name: test
      run: npm test

{staged_files} がステージ済みファイル、{1} がフックの第1引数、stage_fixed: true が「修正結果を自動でステージ」に相当します。lefthook.yml を書き換えたら npx lefthook install で同期します。

$ npx lefthook install
sync hooks: ✔️(pre-commit, commit-msg, pre-push)

huskyと違ってcore.hooksPath は変えず、.git/hooks/pre-commit などに「lefthookを探して lefthook run pre-commit を呼ぶ」ラッパーを書き込みます。同じ src.js をコミットした出力です(色コードは除去)。

$ git commit -m "add total"
╭───────────────────────────────────────╮
│ lefthook  v2.1.15   hook:  pre-commit │
╰───────────────────────────────────────╯
┃  prettier ❯
src.js 70ms (unchanged)

┃  eslint ❯

/path/to/lefthook-demo/src.js
  2:9  error  'unused' is assigned a value but never used  no-unused-vars
  3:3  error  Unexpected console statement                 no-console

✖ 2 problems (2 errors, 0 warnings)

exit status 1
  ────────────────────────────────────
summary: (done in 2.00 seconds)
✓ prettier (1.70 seconds)
✗ eslint (1.99 seconds)
exit code: 1

コードを直してメッセージが規約外のままだと、pre-commit は通り commit-msg で止まります。

$ git commit -m "add total"
(pre-commit の枠は省略:✓ prettier ✓ eslint)
╭───────────────────────────────────────╮
│ lefthook  v2.1.15   hook:  commit-msg │
╰───────────────────────────────────────╯
┃  commitlint ❯
⧗   --- input ---
add total
✖   subject may not be empty [subject-empty]
✖   type may not be empty [type-empty]

✖   found 2 problems, 0 warnings

exit status 1
summary: (done in 0.81 seconds)
✗ commitlint (0.81 seconds)
exit code: 1
$ git commit -m "feat: add total()"
(pre-commit・commit-msg とも ✓)
[main cb92b7f] feat: add total()
 1 file changed, 3 insertions(+)
 create mode 100644 src.js
exit code: 0

スキップは LEFTHOOK=0 git commit ...、手動実行は npx lefthook run pre-commit --all-files、有効な設定の確認は npx lefthook dump です。

pre-commit 4.6.2(pip)の最小設定

pre-commit(ツール名がフック名と同じなので紛らわしいのですが)は、.pre-commit-config.yaml に「どのリポジトリの・どのバージョンの・どのフックを使うか」を書き、ツールが各フックの実行環境を自動で用意する仕組みです。Pythonプロジェクトの定番で、pre-commit sample-config がひな形を出してくれます。

pip install pre-commit        # 4.6.2
pre-commit sample-config > .pre-commit-config.yaml
pre-commit install            # .git/hooks/pre-commit に書き込む
pre-commit run --all-files    # 手動で全ファイルに実行
pre-commit autoupdate         # rev を最新タグに更新

一般的な設定は次のように公開リポジトリを指定します(この環境からはGitHubに接続できないため、この形は未実行です)。

# .pre-commit-config.yaml(一般的な形。rev は pre-commit autoupdate で更新)
repos:
  - repo: https://github.com/pre-commit/pre-commit-hooks
    rev: v6.0.0
    hooks:
      - id: trailing-whitespace
      - id: end-of-file-fixer
  - repo: https://github.com/astral-sh/ruff-pre-commit
    rev: v0.16.8
    hooks:
      - id: ruff-check
        args: [--fix]
      - id: ruff-format

同じ内容を、手元にインストール済みのコマンドを呼ぶ repo: local で書いて実行しました。

# .pre-commit-config.yaml(repo: local 版。pre-commit 4.6.2 / ruff 0.16.9 で実行)
repos:
  - repo: local
    hooks:
      - id: ruff-check
        name: ruff check
        entry: ruff check --fix
        language: system
        types: [python]
      - id: ruff-format
        name: ruff format
        entry: ruff format
        language: system
        types: [python]
$ pre-commit install
pre-commit installed at .git/hooks/pre-commit

$ git add -A && git commit -m "add total()"
ruff check...............................................................Failed
- hook id: ruff-check
- exit code: 1
- files were modified by this hook

F841 Local variable `unused` is assigned to but never used
 --> app.py:3:5
  |
2 | def total(items):
3 |     unused = 1
  |     ^^^^^^
4 |     return sum(i["price"] * i["qty"] for i in items)
  |
help: Remove assignment to unused variable `unused`

Found 2 errors (1 fixed, 1 remaining).
No fixes available (1 hidden fix can be enabled with the `--unsafe-fixes` option).

ruff format..............................................................Failed
- hook id: ruff-format
- files were modified by this hook

1 file reformatted

exit code: 1

pre-commitは「フックがファイルを書き換えたら、それだけで Failed 扱い」です(files were modified by this hook)。未使用importの削除と整形は自動で済んでいるので、残った unused = 1 を消して git add し直せば通ります。

$ git add -A && git commit -m "add total()"
ruff check...............................................................Passed
ruff format..............................................................Passed
[main (root-commit) 75fbda2] add total()
 3 files changed, 17 insertions(+)

特定フックだけ飛ばすには SKIP=ruff-check git commit ...、全部飛ばすなら git commit --no-verify です。

4方式の比較表

観点 素の core.hooksPath husky 9.1.7 lefthook 2.1.15 pre-commit 4.6.2
インストール 不要(各自 git config core.hooksPath .githooks) npm i -D husky(prepare で自動) npm i -D lefthook / brew / go install / pip / gem など pip install pre-commit(brew も可)
設定ファイル シェルスクリプト(1フック1ファイル) .husky/<フック名>(1フック1ファイル、シェル) lefthook.yml(1ファイルに全フック。TOML/JSONも可) .pre-commit-config.yaml(1ファイル)
フックの実体 .githooks/ .husky/_/(core.hooksPath を変更) .git/hooks/ にラッパーを書き込む .git/hooks/ にラッパーを書き込む
並列実行 自分で書く なし(lint-staged側の --concurrent はタスク単位) parallel: true / piped: true なし(フック内の並列はツール次第)
ステージ済みファイルだけ対象 git diff --cached を自分で書く lint-staged と組み合わせる {staged_files} が組み込み 既定でステージ済みだけ渡す
自動修正をステージ 自分で git add lint-staged が自動 stage_fixed: true 書き換えは Failed 扱い(再 git add が必要)
言語・ランタイム依存 シェルのみ Node.js 18以上 単一バイナリ(Go製)。Node不要 Python 3.10以上。フック側の言語環境は自動構築
向いているプロジェクト 小規模・ツールを増やしたくない Node.js中心 複数言語のモノレポ、速度重視 Python中心、公開フックを組み合わせたい
スキップ --no-verify --no-verify / HUSKY=0 --no-verify / LEFTHOOK=0 --no-verify / SKIP=<id>

どれを選ぶかの判断表

状況 おすすめ
Node.jsのアプリで、lintとフォーマットをコミット前に走らせたい husky + lint-staged(情報量が多く、既に package.json 中心で運用しているなら追加コストが最小)
フロント(Node)とバックエンド(Go/Ruby/Python)が同じリポジトリ lefthook(1つの lefthook.yml に root: 付きで並べ、並列実行できる)
Pythonプロジェクトで、ruff・mypy・YAMLチェックなど公開フックを組み合わせたい pre-commit(rev でバージョンを固定でき、CI では pre-commit run --all-files を同じ設定で流せる)
ツールを1つも増やしたくない、フックが1〜2個 core.hooksPath + .githooks/(prepare や Makefile で git config を自動化)

よくあるトラブルと対処表

以下はすべてこの記事の検証中に実際に再現した出力です。

症状・出力 原因 対処
hint: The '.git/hooks/pre-commit' hook was ignored because it's not set as executable. 実行権限がない chmod +x .git/hooks/pre-commit(huskyは .husky/_/ を755で生成するので通常は不要)
フックが何も言わずに動かない ファイル名の誤り(precommit、pre-commit.sh)/core.hooksPath が別の場所を指している/Git 2.9未満 git config core.hooksPath で .husky/_ を確認。ls .husky/_ で実体を確認
cloneしたばかりで git config core.hooksPath が空 npm install(prepare)がまだ走っていない npm install または npm run prepare
.git can't be found(npm install 時は > husky の直後に表示) .git がカレントディレクトリにない(サブディレクトリの package.json、またはGit管理外) "prepare": "cd .. && husky frontend/.husky" の形にする。Git管理外なら git init
husky - add command is DEPRECATED(終了コード1) v8以前の手順 echo "コマンド" > .husky/pre-commit
husky - DEPRECATED ... They WILL FAIL in v10.0.0 フック先頭に v8 の2行(shebang と _/husky.sh の読み込み)が残っている その2行を削除
/usr/bin/env: 'sh\r': No such file or directory フックファイルがCRLF(Windowsで編集) LF に変換。.gitattributes に .husky/** text eol=lf と .githooks/** text eol=lf を追加
npm error notarget No matching version found for undefined@lint-staged. .husky/pre-commit がCRLFで、npx lint-staged\r と解釈された 同上(LFに変換)
.husky/pre-commit: 1: npx: not found + husky - command not found in PATH=... GUIクライアント(エディタ内蔵Git・SourceTree等)や nvm 環境で node が PATH にない ~/.config/husky/init.sh に export PATH="/opt/node22/bin:$PATH" や nvm の初期化2行を書く(実測で通ることを確認済み)
sh: 1: husky: not found → npm error code 127(npm ci --omit=dev) devDependencies を入れないのに prepare が走る HUSKY=0 を渡す、"prepare": "husky || true"、または --ignore-scripts
error: failed to push some refs to ...(直前にテスト失敗の出力) pre-push が非ゼロで終了 テストを直す。緊急時は git push --no-verify
huskyをやめた後もフックが動かない core.hooksPath が .husky/_ のまま git config --unset core.hooksPath

~/.config/husky/init.sh は、GUIクライアント対策の要です。nvmなら次の2行を置きます。

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

よくある質問

Q. .husky/_ はコミットすべきですか? A. しません。husky が生成する .husky/_/.gitignore(中身は *)で自動的に無視されます。コミットするのは .husky/pre-commit など自分で書いたファイルだけで、cloneした人は npm install の prepare で .husky/_ が再生成されます。

Q. npx husky init で作られた pre-commit の npm test は消していいですか? A. 構いません。npm test は「フックが動くことを確かめる」ためのサンプルで、実運用では npx lint-staged などに書き換えます。テストは pre-push に置くと、コミットのたびに待たされずに済みます。

Q. yarn や pnpm でも手順は同じですか? A. pnpm は pnpm add -D husky && pnpm exec husky init、bun は bun add --dev husky && bunx husky init で同じです。Yarn 2以降は prepare を自動実行しないため、公式ドキュメントでは postinstall に husky を書き、公開パッケージなら pinst で npm publish 時の実行を抑える方法が案内されています。

Q. フックを一時的に全部止めたいときは? A. 1回なら HUSKY=0 git commit ...、しばらく止めるなら export HUSKY=0(戻すときは unset HUSKY)です。git commit --no-verify は pre-commit と commit-msg だけを飛ばし、prepare-commit-msg は飛ばせない点が違います。

まとめ

  • Git hooksは .git/hooks/ の実行可能ファイル。pre-commit・commit-msg・pre-push は非ゼロ終了で操作を止められ、post-* は止められない(早見表参照)
  • .git/hooks/ はcloneで配られないので、共有には core.hooksPath かツールが必要。husky v9は npm install の prepare で core.hooksPath=.husky/_ を自動設定する
  • husky v9の手順は npm i -D husky && npx husky init の2行。husky install / husky add は非推奨で、フックは .husky/<フック名> にコマンドを直接書く
  • lint-stagedは失敗時に元の状態へ自動で戻し、成功時は整形結果を自動ステージする。tsc は関数形式でファイル名を捨てる
  • commit-msg は npx --no -- commitlint --edit "$1"、pre-push は npm test。止まる時は husky - <フック名> script failed (code N) が出る
  • CIでは HUSKY: 0。npm ci --omit=dev は husky: not found で失敗するので HUSKY=0 か "prepare": "husky || true" にする
  • 複数言語のモノレポはlefthook、Pythonはpre-commitが向く。フックが動かない時は実行権限・core.hooksPath・CRLF・PATH の4点を順に確認する

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Git hooks・husky v9の設定方法|実行結果とlefthook比較」

株式会社RJCでは、Git hooksのようにチームの品質を自動で支える仕組みを、プロジェクトの中で仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?