# 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/ を汚す」という順序の罠と、その塞ぎ方まで、実際のコードと一次情報で残します。

- 出典: SUZ-LAB（実験ログ / 一次情報）
- URL: https://suz-lab.co.jp/logs/migrating-git-hooks-to-lefthook
- 公開: 2026-07-08
- 更新: 2026-07-08
- 著者: Hiro（主任研究員・https://suz-lab.co.jp/researchers/hiro・X: https://x.com/suz_lab_hiro）
- 柱: テクノロジー
- タグ: #Git #Lefthook #pnpm #モノレポ #フック #worktree #AI
- カバー画像: https://suz-lab.co.jp/assets/logs/migrating-git-hooks-to-lefthook.png?v=031ec45e

---


## なぜ今、移行したか

前回のガードレール監査（[ブランチ保護と worktree 運用を固めた回](/logs/hardening-branch-protection-and-worktrees)）では、**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` はこうなった。

```yaml
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` に向けていた。

```jsonc
// 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` する形にした。

```jsonc
// 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` を回すと、意図しないファイルが増える。

```text
$ git status --short
?? .githooks/pre-commit
?? .githooks/pre-push
```

**追跡対象の `.githooks/` に、Lefthook 生成のフックが書き込まれていた。** インストールのログを読むと理由が分かる。

```text
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/` に残ったゴミは追跡対象なので、放っておけばコミットに紛れ込む。

<Details summary="なぜ prepare 側の設定では間に合わないのか">

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

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

</Details>

対処は、Lefthook の auto-install を**無効化**すること。pnpm 10 はデフォルトで依存のビルドスクリプトをブロックするので、`pnpm-workspace.yaml` の `ignoredBuiltDependencies` に `lefthook` を明示的に足した。バイナリはビルド不要なので、これで困らない。フックの設置は `prepare` の `lefthook install` に一本化される。

```yaml
ignoredBuiltDependencies:
  - sharp
  - unrs-resolver
  - lefthook   # postinstall の auto-install を止め、設置は prepare に一本化
```

`.githooks/` に紛れ込んだ2ファイルを消してからクリーンに入れ直すと、`.githooks/` は `guard-branch.sh` だけの綺麗な状態に戻り、フックは `.git/hooks` にだけ入るようになった。

あわせて、個人ごとのフック上書き用に `lefthook-local.yml` を `.gitignore` へ追加した（Lefthook はこのファイルがあればマージして読む）。

## 検証

「フックが本当に配線されているか」と「ガードが従来どおり弾くか」を、手を動かして確かめた。

```bash
# 配線: 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` の「仕組みで守る」思想と地続きの学びだ。

