Git フックを Lefthook に移行した — 依存ゼロのシェル1本から、並列フックへ
ブランチ保護の監査で「後回し」に切り分けていた Lefthook 移行を、実際に倒した記録。依存ゼロのシェル製 pre-commit(main 直コミット拒否)1本を、Go 製の Lefthook(lefthook.yml 一本)へ移し、pre-commit=軽いガード+整形(直列・guard 優先で fail-fast)・pre-push=typecheck / lint(並列)という構成にした。既存ガードレール(clone・worktree への自動適用と自己修復、main ガード)は壊さず温存。途中で踏んだ「lefthook の postinstall が prepare より先に走り、旧 core.hooksPath を見て追跡対象の .githooks/ を汚す」という順序の罠と、その塞ぎ方まで、実際のコードと一次情報で残します。
なぜ今、移行したか
前回のガードレール監査(ブランチ保護と worktree 運用を固めた回)では、Lefthook への移行は「後回し」に切り分けていた。理由は明快で、当時のフックは依存ゼロのシェル製 pre-commit が1本だけ——main への直コミットを弾くだけのガードには、これで必要十分だったからだ。監査の結論は「フックが増えて直列実行の待ち時間が気になり始めたら移す」。
今回それを前倒しした。動機は着手条件そのものにある。フック時のチェックを増やしたくなった——lint・型チェック・整形を、コミットや push のタイミングで機械的に回したい。これをシェルで直列に足していくと、フックが1本増えるたびに待ち時間が積み上がる。増える前に、lefthook.yml 一本で宣言的に管理でき、しかも並列実行できる Go 製の Lefthook へ土台を移しておく、という判断だ。コミット毎に Node / Python ランタイムを起動するコストもない。
大事なのは、すでにあるガードレールを一切壊さないこと。この repo のフックには、素朴な「便利機能」以上の意味が乗っている。
main直コミットの拒否(git commit --no-verifyでしか越えられない手戻り防止)pnpm installのたびに設定され、全 clone・全 git worktree に自動で効くという自己インストール性- worktree でツールが設定を上書きしてもフックが復活する自己修復性
移行はこの3つを温存したまま、実行エンジンだけ差し替える作業になる。
設計 — pre-commit と pre-push で役割を分ける
フックは2段に分けた。判断基準は「どれくらいの頻度で走り、どれくらい重いか」だ。
- pre-commit(毎コミット・軽量・直列) … コミットは1日に何度も打つ。ここが重いと開発が詰まる。だから速いものだけを置く:
mainガードと、staged されたapps/videoの TS/TSX だけを対象にした Prettier の整形チェック。root: apps/video/と{staged_files}で「いまコミットしようとしているファイル」だけを渡す(apps/video全体は検査しない)。glob は**/*.{ts,tsx}でsrc/**などの入れ子も拾う。piped: trueで guard を先に・fail-fast にした——main上のコミットは guard が即座に弾き、Prettier 待ちを発生させない(guard はほぼ即時なので、直列にしても通常コミットの体感は変わらない)。 - pre-push(push 前・並列) … push は頻度が低いので、多少重くてよい。プロジェクト全体の
pnpm typecheckとpnpm lintをまとめて並列で回す。AGENTS.mdに「push 前に typecheck / lint を通す」と書いていた手順を、そのまま機械化した格好だ。
lefthook.yml はこうなった。
pre-commit:
piped: true # guard → format の順に直列実行。guard 失敗で以降を止める
commands:
guard-branch:
priority: 1
run: bash .githooks/guard-branch.sh
format:
priority: 2
root: "apps/video/"
glob: "**/*.{ts,tsx}"
run: pnpm exec prettier --check {staged_files}
pre-push:
parallel: true
commands:
typecheck:
run: pnpm typecheck
lint:
run: pnpm lint
main ガードのロジックは、YAML に埋め込まずシェルスクリプトのまま残した。テストしやすく、迂回時の挙動も読みやすい。旧 .githooks/pre-commit を .githooks/guard-branch.sh にリネームし(中身は不変)、Lefthook の pre-commit から呼ぶだけにしている。
やってみたこと — 実装
1. インストール経路を prepare に一本化する
いちばんの肝は prepare スクリプトだ。旧構成は core.hooksPath を .githooks に向けていた。
// Before
"prepare": "... && git config core.hooksPath .githooks && git config extensions.worktreeConfig true ..."
Lefthook はフックを .git/hooks に設置する。そこで prepare は core.hooksPath を実 hooks ディレクトリの絶対パス(git rev-parse --path-format=absolute --git-path hooks = 全 worktree 共通の .git/hooks)に貼り直し、extensions.worktreeConfig を立て直してから lefthook install する形にした。
// After
"prepare": "git rev-parse --git-dir >/dev/null 2>&1 && git config core.hooksPath \"$(git rev-parse --path-format=absolute --git-path hooks)\" && { git config extensions.worktreeConfig true || true; } && lefthook install || true"
ここは一発では決まらず、PR レビューで2つの穴を指摘され順に塞いだ。最初は「旧 .githooks 指定を unset するだけ」の素朴な形だった。
- unset だと global 設定に負ける。 開発者が
~/.gitconfigにcore.hooksPathを持っていると、ローカルを unset した瞬間その global 値が有効化され、.git/hooksの Lefthook フックが無効になりうる。→ unset をやめ、実 hooks ディレクトリを指す値をローカルに固定した。ローカルは global に勝ち、旧.githooks指定も上書きできる。絶対パスは repo 移動で腐るが、prepareが毎pnpm installで再計算するので自己修復する。 - 設定失敗を握りつぶすと危ない。
core.hooksPathの設定を|| trueで握りつぶすと、設定できていないのにlefthook installが走り、フックを誤った場所に置く/不発にする状態を静かに作れる。→|| trueを外し、&&チェーンで設定が成功したときだけ install する形にした(設定に失敗すれば短絡して install しない。末尾の|| trueは残すのでprepare自体はpnpm installを止めない)。
各 worktree でも pnpm install は必ず走るので、フックの自動適用(全 clone・全 worktree)と自己修復は、経路を Lefthook に替えてもそのまま成立する。git rev-parse --git-path hooks はどの worktree から引いても共通の .git/hooks を返すため、絶対パス1本で全 worktree を賄える。
2. 依存を固定する
lefthook を devDependencies に固定(2.1.9)。Go バイナリはプラットフォーム別の optionalDependencies で配られるので、追加のビルドは要らない。
ハマったところ — postinstall が prepare より先に走る
ここで順序の罠を踏んだ。pnpm install を回すと、意図しないファイルが増える。
$ git status --short
?? .githooks/pre-commit
?? .githooks/pre-push
追跡対象の .githooks/ に、Lefthook 生成のフックが書き込まれていた。 インストールのログを読むと理由が分かる。
lefthook postinstall: │ core.hooksPath is set locally to '.githooks'
lefthook postinstall: │ Installing hooks anyway in '.githooks'
lefthook npm パッケージには自前の postinstall があり、インストール時に自動で lefthook install を走らせる。そしてこれは依存インストールの最中=ルートの prepare より前に走る。その時点では core.hooksPath はまだ .githooks を指しているので、Lefthook はご丁寧に .githooks/ の中へフックを書いてしまう。直後に prepare が core.hooksPath を .git/hooks に貼り直して入れ直すが、.githooks/ に残ったゴミは追跡対象なので、放っておけばコミットに紛れ込む。
なぜ prepare 側の設定では間に合わないのか
npm ライフサイクルの順序が理由。依存パッケージの postinstall(ここでは lefthook のもの)は、ルートプロジェクトの prepare よりも前に実行される。だから「prepare で先に core.hooksPath を直しておく」では手が届かない——直す前に、もう postinstall が走り終えている。
順序を変えて追いかけるより、postinstall そのものを止めるほうが素直だ。
対処は、Lefthook の auto-install を無効化すること。pnpm 10 はデフォルトで依存のビルドスクリプトをブロックするので、pnpm-workspace.yaml の ignoredBuiltDependencies に lefthook を明示的に足した。バイナリはビルド不要なので、これで困らない。フックの設置は prepare の lefthook install に一本化される。
ignoredBuiltDependencies:
- sharp
- unrs-resolver
- lefthook # postinstall の auto-install を止め、設置は prepare に一本化
.githooks/ に紛れ込んだ2ファイルを消してからクリーンに入れ直すと、.githooks/ は guard-branch.sh だけの綺麗な状態に戻り、フックは .git/hooks にだけ入るようになった。
あわせて、個人ごとのフック上書き用に lefthook-local.yml を .gitignore へ追加した(Lefthook はこのファイルがあればマージして読む)。
検証
「フックが本当に配線されているか」と「ガードが従来どおり弾くか」を、手を動かして確かめた。
# 配線: Lefthook が各コマンドを起動するか
lefthook run pre-commit # guard 通過・format は staged に video 無しでスキップ
lefthook run pre-push # typecheck・lint が並列で走り、どちらもパス
# main ガードが従来どおり弾くか(作業ツリーを汚さず HEAD だけ main に向けて検証)
git symbolic-ref HEAD refs/heads/main
bash .githooks/guard-branch.sh; echo $? # → メッセージを出して exit 1(ブロック)
git symbolic-ref HEAD refs/heads/<作業ブランチ> # すぐ戻す
さらに、この移行コミット自体が Lefthook の pre-commit を通り、push が pre-push(typecheck / lint 並列)を通ることで、ドッグフーディングとして実動作を確認できた。pnpm install 後に .githooks/ が汚れないこと、ビルドスクリプト警告が出ないことも確認済み。
レビュー対応で入れた強化も実機で確かめた——入れ子の staged ファイル(apps/video/src/**)が整形チェックに乗ること、bogus な global core.hooksPath を設定してもローカル固定が勝ってフックが発火することを、実ファイルと実 config で検証した。
学び
- 移行は「実行エンジンの差し替え」に絞れると安全。 フックに乗っていた性質(自動適用・自己修復・main ガード)を先に言語化し、それを壊さないことを制約に置いた。おかげで「便利になった」ではなく「同じ保証を、より速い土台で」に着地できた。
- npm ライフサイクルの順序は、たまに牙をむく。 依存の
postinstallは自分のprepareより前に走る。ツールが「親切に」自動セットアップしてくれる挙動が、こちらの前提(まだ旧設定が残っている状態)とぶつかった。追いかけて上書きするより、余計な自動処理を止めて経路を一本化するほうが読みやすい。 - 後回しにした一手は、着手条件で管理する。 前回の監査で「フックが増えたら」と条件を書いておいたから、今回は迷いなく倒せた。TODO を寝かせるときは、期日ではなくトリガーを残しておくと、前倒しの判断がぶれない。
- ガードレールを作るコードこそ、機械レビューで固まる。 移行後の PR で Copilot が「整形が staged でなく
apps/video全体を見ている」「core.hooksPathを unset すると global 設定に負ける」「設定失敗を握りつぶしている」「pre-commit を並列にすると guard 失敗時に無駄な整形待ちが出る(=直列・fail-fast にすべき)」と、人手では見落としがちなシェル/設定の穴を次々に指摘してきた。守る側のコードほど機械の目で詰めておくと効く——dev-guardrailsの「仕組みで守る」思想と地続きの学びだ。




