コンテンツにスキップ

lefthook を使って Git hook を管理する

はじめに

Git hook 管理ツールのlefthookで、コミット時の静的検査やフォーマットを自動化しました。どのプロジェクトでも同じように検査できるよう、共通設定を配る方法と、任意のタイミングで実行する方法を紹介します。

まず、Git hook 管理ツールのlefthookを選んだ理由と、基本的な設定方法を説明します。続いて、共通の設定を各プロジェクトへ配るラッパー関数lhと、設定の過程で気づいたlefthookの挙動を紹介します。

lefthook の採用理由

Git hook は、コミットなどの Git の操作をトリガーにスクリプトを実行する仕組みです。たとえば.git/hooks/pre-commitに検査用のスクリプトを置けば、コミットのたびに自動で検査が走ります。自前でスクリプトを書くこともできますが、pre-commitやhusky、lefthookなどの管理ツールを使えば、チームや個人の間で設定を共有しやすくなります。

複数の Git hook 管理ツールを比較検討した結果、次の理由からlefthookを採用しました。

  • シングルバイナリで提供され、実行環境への依存が少ない
  • コマンドの並列実行に対応し、検証時間を短縮できる
  • 設定を YAML で記述でき、GitHub Actions や Kubernetes マニフェストと同じ感覚で書ける
  • プロジェクト共通設定を個人固有の設定(.lefthook-local.yml)で上書きできる
  • Git hook に限らず任意のタスクランナーとしても実行できる

lefthook の基本設定

lefthookの設定は、プロジェクトルートの.lefthook.ymlに記述します。また、.lefthook-local.ymlを置けば個人の設定で上書きでき、マシン内の別のパスや Web 上の設定ファイルを読み込むこともできます。

pre-commit、commit-msg、pre-pushなどのフックごとに、実行するコマンドを設定ファイルへ定義します。フック以外の任意の名前でもタスクを定義でき、lefthook run <task>で実行できます。次の例ではtestがタスクです。

.lefthook.yml の例
pre-commit:
  parallel: true
  commands:
    rubocop:
      glob: "app/**/*.rb"
      run: "bundle exec rubocop"
    eslint:
      glob: "app/javascript/**/*.js"
      run: "yarn eslint"
test:
  parallel: true
  commands:
    rspec:
      run: "bundle exec rspec"

共通設定の配布

どの開発環境でも同じ検査を適用できるように、dotfiles の lefthook 設定を用意しました。対応させた言語とファイル形式は、Python、Terraform、Dockerfile、シェルスクリプト、Kubernetes マニフェストなどです。Git hook として動かすだけでなくlintやtestなどの名前のタスクとしても実行でき、--all-filesオプションを付ければステージしていないファイルを含む全ファイルが対象になります。

lh コマンドの作成

本当はプロジェクトごとに設定ファイルを作らず、1 か所に置いた設定ファイルをすべてのプロジェクトから参照したいと考えていました。しかし調べたところ、lefthookもほかの Git hook 管理ツールも、設定ファイルはプロジェクトごとに置く前提でした。そこでシェル関数lhをdotfiles の zsh/04func.zshに用意し、設定ファイルが無ければ作成してからlefthookを実行するようにしました。

lefthookを実行する前に、lhはプロジェクトのルートで次の 2 つを確認します。

  • lefthookの設定ファイルが無ければ、空の.lefthook.ymlを作る
  • 個人用の.lefthook-local.ymlが無ければ、先ほどの dotfiles の設定ファイルをextendsに指定して作る

共通設定を読み込むのは個人用のファイルなので、プロジェクトで共有する.lefthook.ymlの中身を書き換える必要はありません。

設定ファイルを用意したあとで引数をそのままlefthookへ渡すため、lhはlefthookのエイリアスのようにも使えます。次の 2 つのコマンドは、どちらもpre-commitフックを実行します。

lefthook run pre-commit
lh run pre-commit

lh コマンドの補完

コマンドの補完にはargc-completionsを使っています。argc-completionsに含まれるlefthook用の補完スクリプトを複製してlh向けに編集し、lhでもlefthookと同じ補完が効くようにしました。

実運用で留意すべき挙動

設定を構築する過程で、lefthookの挙動について気になった点が 3 つありました。

extends プロパティにおける相対パス解決

extendsプロパティを使うと、マシン内にある別の設定ファイルを読み込めます。そこで次の図のように、.lefthook.ymlから設定ファイル A を読み込み、さらに設定ファイル A から設定ファイル B を読み込んでみました。

graph TD
  root[.lefthook.yml] -->|extends| fileA[設定ファイル A]
  fileA -->|extends| fileB[設定ファイル B]

設定ファイル A から B を相対パスで読み込む場合、設定ファイル A ではなく読み込み元の.lefthook.ymlを起点にパスを書く必要がありました。そのため、設定ファイル A をさまざまな場所から読み込んで共通で使い回すのは難しいように感じました。複数のファイルを経由せず、必要な設定ファイルを直接読み込むのがよさそうです。

group プロパティと個別コマンド実行の制限

groupプロパティを使うと、複数のコマンドを論理的にまとめ、並列実行や直接実行を制御しやすくなります。ただし、グループに含めたコマンドは個別に実行できません。

groupを使わなければ、lefthook run pre-commit --commands rubocopのようにrubocopだけを実行できます(v2.0.0 以降はオプション名が--command)。一方でrubocopをグループに含めると、lefthook run pre-commitでグループごと実行するしかありません。

files プロパティと --all-files オプションの差異

filesに書いたコマンドが返すファイルをglobとexcludeで絞り込んだものが、実行対象になります。filesの結果にディレクトリを含めることはできず、ファイルだけを返す必要があります。

一方で--all-filesオプションを付けると、filesの設定を考慮せず全ファイルが対象になるため、filesでの細かい絞り込みが効かなくなります。オプションの有無で挙動が変わらないように、細かい絞り込みはfilesではなくrunのコマンド側で行うのがよさそうです。

まとめ

lefthookの設定ファイルはプロジェクトごとに置く前提のため、次の 3 つを組み合わせて、どのプロジェクトにも同じ検査を適用できるようにしました。

  • dotfiles に置いた、言語やファイル形式ごとの共通設定
  • 共通設定をextendsで読み込む、個人用の.lefthook-local.yml
  • 設定ファイルが無ければ作成してからlefthookを実行するラッパー関数lh

extendsの多段継承、groupによる個別実行の制限、--all-filesとfilesの関係の 3 点には注意が必要です。共通の設定ファイルを多段に継承せず直接読み込み、細かい絞り込みをrun側で行っておけば、実行方法による挙動の差を抑えやすくなります。