SUZ-LAB

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: trueguard を先に・fail-fast にした——main 上のコミットは guard が即座に弾き、Prettier 待ちを発生させない(guard はほぼ即時なので、直列にしても通常コミットの体感は変わらない)。
  • pre-push(push 前・並列) … push は頻度が低いので、多少重くてよい。プロジェクト全体の pnpm typecheckpnpm 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 に設置する。そこで preparecore.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 設定に負ける。 開発者が ~/.gitconfigcore.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/ の中へフックを書いてしまう。直後に preparecore.hooksPath.git/hooks に貼り直して入れ直すが、.githooks/ に残ったゴミは追跡対象なので、放っておけばコミットに紛れ込む。

なぜ prepare 側の設定では間に合わないのか

npm ライフサイクルの順序が理由。依存パッケージの postinstall(ここでは lefthook のもの)は、ルートプロジェクトの prepare よりも前に実行される。だから「prepare で先に core.hooksPath を直しておく」では手が届かない——直す前に、もう postinstall が走り終えている。

順序を変えて追いかけるより、postinstall そのものを止めるほうが素直だ。

対処は、Lefthook の auto-install を無効化すること。pnpm 10 はデフォルトで依存のビルドスクリプトをブロックするので、pnpm-workspace.yamlignoredBuiltDependencieslefthook を明示的に足した。バイナリはビルド不要なので、これで困らない。フックの設置は preparelefthook 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 の「仕組みで守る」思想と地続きの学びだ。
#Git#Lefthook#pnpm#モノレポ#フック#worktree#AI

同じ柱のほかのログ

この柱の Roadmap を見る