atama plus techblog
🥊

モノレポのLefthook設定、どうしてる? 独立性・速度・メンテコストで選ぶ3つの構成パターン

に公開

こんにちは、yubonです。

atama plus Advent Calendar 2025 の10日目になります。
本記事では、モノレポのGitフック運用における「独立性」「速度」「メンテコスト」の課題に焦点を当て、Lefthookを用いた3つの構成パターンと実践事例を紹介します。
Lefthookの基本的な導入記事は多く見かけますが、モノレポ環境でどのように構成するのがよいかについては、まだ情報が多くないと感じたので、何か参考になれば幸いです。

想定読者

  • モノレポでのGitフック運用において、設定の保守コストや実行速度に悩んでいる方
  • プロジェクト規模が大きくなり、既存のHusky + lint-staged運用に限界を感じている方

はじめに:モノレポのGitフックの設定はなぜ難しいのか

モノレポ運用において、地味ながらボディブローのように効いてくるのがGitフックの設定です。
pre-commitを導入してみたけど「パッケージが増えたら遅くなった...」「パッケージ追加したけど、Gitフックの設定を足し忘れてフォーマットが走らない...」みたいなこと、ありますよね。
単一リポジトリならシンプルなのに、モノレポになった途端、以下の要件があちらこちらで衝突し始めるのです。

  1. 独立性: パッケージごとに異なる設定を使いたい(.eslintrc.cjsbiome.jsoncpackage.jsonのスクリプトなど)
  2. 速度: コミット時は「変更されたファイル」だけをチェックして爆速で終わらせたい
  3. メンテコスト: パッケージが増えるたびに設定ファイルを更新したくない

これらをすべて満たす銀の弾丸はなく、プロジェクトの規模やチームの方針によって適切なアプローチは異なります。

そこで本記事では、Gitフックマネージャーの Lefthook を用いた3つの構成パターン(+ボツ案)を整理してみることにしました。

  1. パターン1: rootオプション
    • 小規模向け。一番シンプルで、まずは手軽に導入したい場合に最適。
  2. パターン2: extends
    • 中〜大規模向け。設定をパッケージ単位に分割する標準的な構成。
  3. パターン3: スクリプトによる動的振り分け
    • 応用編。自作スクリプトを用いて、パッケージ追加時の設定メンテを不要にするアプローチ。

「TypeScriptモノレポでのBiomeによるlint & format」を具体例として挙げつつ、先に挙げた「独立性」「速度」「メンテコスト」の観点からそれぞれの特徴を比較していきます。
なお、説明の便宜上pre-commitフックを題材にしますが、同じ構成はpre-pushなど他のGitフックにもそのまま応用できます。

Lefthookとは

Lefthookは、Go言語で開発された高速なGitフックマネージャーです。
従来よく使われている Husky + lint-staged と比較した際の主なメリットとして、以下の2点が挙げられます。

  1. 設定ファイル(YAML)1つで完結する
    • HuskyはGitフックごとのシェルスクリプト、lint-stagedは専用の設定ファイルが必要ですが、Lefthookは lefthook.yml 1つですべての設定を管理できます。
  2. 並列実行による高速化
    • Go言語製で並列実行をネイティブサポートしており、チェック対象が多いモノレポ環境でも高速に動作します。

ここからは、モノレポにおけるLefthookの構成パターンを3つ紹介していきます。
なお、本記事ではLefthookのインストール手順などの基礎的な解説は省略します。

パターン1: rootオプション

最初に紹介するのは、Lefthookの root オプションを使って、コマンドを実行するディレクトリを指定する方法です。
https://lefthook.dev/configuration/root.html
言語に依存せず、最も直感的で導入しやすいアプローチです。従来の lint-staged でパッケージごとの設定を列挙するのと構造的には同じになります。

# lefthook.yml (リポジトリルート)
pre-commit:
  commands:
    ui:
      root: packages/ui/
      glob: "*.{js,ts,jsx,tsx,json,jsonc}"
      run: biome check --write {staged_files}
    web:
      root: apps/web/
      glob: "*.{js,ts,jsx,tsx,json,jsonc}"
      run: biome check --write {staged_files}
  • メリット
    • 直感的に設定でき、何をやっているかが分かりやすい
    • リポジトリルートのlefthook.ymlを見ればすべての設定が把握できる
  • デメリット
    • パッケージが増えるたびにリポジトリルートのlefthook.ymlの追記が必要(忘れられやすい)
    • 記述が冗長になりがち
  • 向いているケース
    • パッケージ数が少ない
    • 最もシンプルな構成で始めたい場合

パターン2: extends

次に、Lefthookのextends機能を活用する方法です。
GitHub Discussionでもこの構成が推奨されており、各パッケージに設定ファイルを置き、リポジトリルートから読み込みます(参考: GitHub Discussion #1078)。
こちらも言語やhookの種類に依存しない構成で、パッケージ数が増えてきたときの「標準解」に近いパターンです。

# lefthook.yml (リポジトリルート)
extends:
  - packages/*/lefthook.yml # globがサポートされている
  - apps/*/lefthook.yml
# packages/ui/lefthook.yml (各パッケージ)
pre-commit:
  commands:
    check:
      root: packages/ui/
      glob: "*.{js,ts,jsx,tsx,json,jsonc}"
      run: biome check --write {staged_files}
  • メリット
    • Lefthookの並列実行などの恩恵を受けられる
    • 設定ファイルがパッケージごとに分散され見通しが良い
  • デメリット
    • パッケージごとに lefthook.yml を作る手間がある
    • 新しいディレクトリ階層(上記例ではlibs/tools/など)を追加する場合にのみ、リポジトリルートの extends への追記が必要
  • 向いているケース
    • パッケージ数が中規模以上
    • ツール(Lefthook)の標準機能に準拠したい
    • 各パッケージでhookの設定を細かく変えたい場合

パターン3: スクリプトによる動的振り分け

最後に、少し応用的なアプローチを紹介します。Lefthookから全ファイルを受け取り、自作スクリプトでパッケージごとに振り分ける方法です。
「設定ファイルの更新作業をゼロにしたい(メンテ漏れを防ぎたい)」というニーズに特化した構成です。複数言語でも実現可能ですが、モノレポ内のパッケージが同じ言語・同じツールチェインで構成されている場合に、特にスクリプトの保守コストを抑えて運用しやすくなります。

なお、サンプルコードを以下のリポジトリに置いています。

https://github.com/yub0n/monorepo-lefthook-demo

処理の流れ

やっていること自体はシンプルで、以下の3ステップに分解できます。

  1. Lefthookから「ステージされたファイル一覧」をまとめて受け取る
  2. 各ファイルが所属するパッケージ(最寄りの package.json があるディレクトリ)を自動判定してグルーピングする
  3. パッケージごとに、共通のスクリプト(ここでは pnpm run check:staged)を実行する

仕組みの概要(図で見るとこうなります)

実際の動作イメージは以下のようになります。

実際の動作イメージ

実装のコア(Lefthook設定とNodeスクリプト)

一例になりますが、実際にファイルの振り分けとコマンド実行を行うNode.jsスクリプトを以下のように実装します。

https://github.com/yub0n/monorepo-lefthook-demo/blob/5a2e91323f6c7bf14de65664e60c0b9928f74a8b/scripts/dispatch-by-package.js

そして、リポジトリルートの lefthook.yml には以下のように1つだけ定義を書きます。

https://github.com/yub0n/monorepo-lefthook-demo/blob/5a2e91323f6c7bf14de65664e60c0b9928f74a8b/lefthook.yml#L5-L9

あとは各パッケージで{staged_files}を受け取れるようにpackage.jsonにコマンドを用意します。

https://github.com/yub0n/monorepo-lefthook-demo/blob/5a2e91323f6c7bf14de65664e60c0b9928f74a8b/packages/ui/package.json#L4-L6

スクリプト側(dispatch-by-package.js)では、渡されたファイルパスから親ディレクトリの package.json を探し、該当するパッケージのディレクトリで pnpm run check:staged を実行します。
ここではTypeScript/Node.jsモノレポを例にしていますが、Pythonなど他言語でも「最寄りのプロジェクト定義ファイル(例: pyproject.toml)を探して、そのディレクトリで共通コマンドを実行する」という発想自体は同じで、スクリプトを書き換えれば応用可能です。

  • メリット
    • パッケージを追加しても設定ファイルの変更が一切不要
    • 追加の要件があっても自作なので柔軟性が高い
  • デメリット
    • 独自スクリプト(例では150行程度)の保守が必要(特に複数言語・複数ツールチェインの場合に保守コストが大きくなる)
    • Lefthook本来の「設定だけで高速・安全に並列実行できる」というメリットを一部放棄することになる(並列実行やログ出力の制御などを再実装)
  • 向いているケース
    • パッケージ数が非常に多い、または頻繁に増減する
    • 「設定ファイルの更新忘れ」をシステム的に防ぎたい
    • 独自スクリプトの管理コストを許容できる

[番外編] パターン4: Turborepo経由(検討したものの速度面で不採用)

モノレポツール(Turborepoなど)のタスクランナーに任せる方法も検討しました。

# lefthook.yml (リポジトリルート)
pre-commit:
  commands:
    check:
      glob: "*.{js,ts,jsx,tsx,json,jsonc}"
      run: npx turbo run check --filter='[HEAD]'
  • メリット
    • 依存関係やキャッシュを考慮した正確な実行が可能
    • 設定ファイル(turbo.jsonなど)に一元化できる
  • デメリット
    • pre-commitとしてはオーバーヘッド(起動時間)が大きい
    • 「変更されたファイルだけ」をツールに渡す細かい制御が難しい

理論上は美しい構成ですが、実際の開発体験としてはコミットごとの待ち時間が許容範囲を超えてしまうため、今回は採用を見送りました。

3パターンの比較(独立性 / 速度 / メンテコスト)

ここまで紹介した3パターンを、冒頭で挙げた3つの観点と「どんなケースに向いているか」でざっくり比較すると次のようになります。

パターン 独立性 速度 メンテコスト こんな場合におすすめ
1. rootオプション
コマンドはパッケージ単位に分けられるが、設定はリポジトリルート1ファイルに集中

{staged_files} を使って変更ファイルを絞り込める
中〜高
パッケージ追加のたびにリポジトリルートの lefthook.yml へ追記が必要
まずはLefthookを試してみたい / 小規模スタートの場合
2. extends
各パッケージごとに設定ファイルを持てる

Lefthookの並列実行と {staged_files} の恩恵を得られる

パッケージごとに1ファイル用意すればよく、globを工夫すればリポジトリルート側の追記も最小限で済む
各パッケージ単位で設定を管理したい / 中〜大規模な場合
3. スクリプトによる動的振り分け 中〜高
スクリプト次第で柔軟に制御可能

パッケージ単位にグルーピングして必要な範囲だけ実行できる
低〜中
パッケージ追加時の設定変更は不要だが、代わりにスクリプト自体の保守が必要
パッケージ追加時のメンテコストを限りなくゼロに近づけたい場合

私が携わっているプロジェクトの実践例

私が携わっているプロジェクトでは、現在「パターン3: スクリプトによる動的振り分け」を採用して試験運用しています。

  • フェーズ的にパッケージの分割・追加が頻繁に行われており、その都度設定ファイルを追加・更新するのは大変
  • TypeScript/Node.jsパッケージが中心で、どのパッケージにも package.json が存在する前提を置きやすかった
  • LLMによるサポートによって独自スクリプトのメンテが一定しやすくなり、保守コストが下がっている

ただし、これはあくまで現時点での選択で、今後モノレポ内にPythonパッケージ(Ruffなど)が含まれる可能性もあり、そうなるとNode.jsベースの自作スクリプトで管理し続けるのは複雑になりそうです。
その場合は、より標準的で言語に依存しない「パターン2: extends」への移行を検討することになるでしょう。

このように、プロジェクトのフェーズや技術スタックの変化に合わせて柔軟に構成を見直していくことが重要だと考えています。

まとめ

ここまで、pre-commitフックを題材にしながら、モノレポのためのLefthook構成3パターンを見てきました。上の比較表を眺めながら、自分たちのチームがどこにコストを払いたいか(独立性 / 速度 / メンテコスト)をすり合わせておくと、どのパターンを採用すべきかが決めやすくなるはずです。

本記事ではpre-commitを例にしましたが、同様の考え方はpre-pushなど他のGitフックにも応用できます。
モノレポのGitフック設定には、正解となる単一の方法はありません。チームの規模、パッケージの数、そして「何を手間(コスト)と感じるか」に合わせて、最適なアプローチを選ぶのがよさそうです。もっと他に適切なアプローチがあればぜひ教えてください。
将来的にLefthookなどのツール側で動的なパス解決がサポートされれば、パターン3のような工夫も不要になるかもしれません。エコシステムの進化にも期待ですね。

最後までお読みいただきありがとうございました。
明日はQAエンジニア @asato3 さんの "個人の「行動」を組織の「学び」に変える。インプロセスQAチームの共有会設計" です。お楽しみに!

atama plus techblog
atama plus techblog

Discussion