Git 2.9以降はcore.hooksPathというオプションでグローバルまたはローカルのGitフックのディレクトリを指定できるようになっています。 Gitのcore.hooksPathオプションを利用するとhuskysimple-git-hooksのような追加の依存がなくても、Gitの機能だけでGitフックのコードをバージョン管理して、プロジェクトのセットアップ時にプロジェクトごとのGitフックを設定できます。

📝 類似するGitフックを管理するツールとしてpre-commitLefthookもあります。これらのツールはGitフックの管理だけではなく、ファイルの種類ごとに実行するコマンドをわけて書けるようになっています。 つまり、lint-stagedのような機能も含むので、この記事で紹介するアプローチ以上の機能も同梱されています。

Node.jsプロジェクトの例

ここでは具体例として、Node.jsのプロジェクトでの設定方法を書いています。 Node.jsのプロジェクトではPrettierESLintなどをpre-commitフックで実行するパターンをよく扱うためです。 core.hooksPathは、Gitの機能なので特にNode.js(どちらかというnpmといったパッケージマネージャ)に依存した話ではありません。

Node.jsプロジェクトでの設定方法

.githooksディレクトリ(名前は何でも大丈夫です)を作成して、このディレクトリにpre-commitといったGitフックで実行されるファイルを作成します。

具体的には次のような手順でGitフックファイルを作成していきます。

  • 1. .githooksディレクトリを作成する
  • 2. .githooks/pre-commitを作成して、コミットフックの処理を書く

.githooks/pre-commmit:

#!/bin/sh
echo "it is hooks!"

このままでは、この.githooksディレクトリがGitフックのディレクトリとして扱われません。 そのため、次のようにnpm installなどのプロジェクトのインストール時のlife cycle hookでcore.hooksPathオプションを設定します。

  • 3. package.jsonscripts.prepareライフサイクルスクリプトでcore.hooksPathを設定する
  "scripts": {
    "prepare": "git config --local core.hooksPath .githooks"
  },

これで設定は完了です。

あとは、このプロジェクトでnpm installでインストールされると、prepareスクリプトが実行されて、.githooksディレクトリがGitフックとして扱われます。このサンプルでは、コミットするたびに"it is hooks!"という表示が出るだけになります。


📝 Yarn v2以降はprepareをデフォルトではサポートしていないため、プラグインでlifecycleスクリプトを実行できるようにする必要があるそうです。


もっと具体的なサンプルプロジェクトは、次のリポジトリに作成してあります。

このサンプルプロジェクトでは、huskyなどとよく組み合わせて利用されるlint-stagedを使って、実際にコミットに含まれる差分のファイルだけをPrettierで整形しています。 huskyの代わりに、Gitのcore.hooksPathを直接使うイメージになっています。

具体的な.githooks/pre-commitの内容は、インストールされているlint-stagedコマンドをpre-commitフックで叩いているだけです。

.githooks/pre-commit:

#!/bin/sh
npx --no-install lint-staged

次のような手順でGitフックが設定されていることを確認できます。

git clone git@github.com:azu/githook-lint-staged-example.git
cd githook-lint-staged-example
npm install # Gitフックが設定される
# Gitフックが動いてるかを確認
echo "const v =[1, 2,3 ]" > sample.js
git add .
git commit -m up
✔ Preparing...
✔ Running tasks...
✔ Applying modifications...
✔ Cleaning up...
[master 9074895] up
 1 file changed, 1 insertion(+)
 create mode 100644 sample.js

メリット

依存が少なくなる

プロジェクトにGitフックを設定するために追加の依存(husky、simple-git-hooks)が不要になる点です。

huskyも内部的にはcore.hooksPathを使っているので、huskyはcore.hooksPathをラップしているツールになっています。

pre-commitフックなどは差分ファイルのみを対象としたいため、引き続きlint-stagedなどと組み合わせて利用すると思います。

デメリット

グローバルフックとローカルフックが両立しにくい

pre-commitsimple-git-hooksは独自のGitフックディレクトリを作るのではなく、.git/hooksのディレクトリにフックファイルを書き込む仕組みになっています。(husukyもv4まではこの挙動でしたが、v5core.hooksPathを使う方法に変わりました)

.git/hooksを直接変更するメリットは、グローバルのGitフックとローカル(プロジェクト)のGitフックを両立しやすい点です。

Gitでは、git config --global core.hooksPath ~/.githooksのようにすれば、Gitフックが設定されてない場合に実行されるグローバルなGitフックを設定できます。

これを利用すると、プロジェクト関係なくグローバルフックを使ってsecretlintでコミット内容をチェックして、ローカルのGitフックも実行するのが簡単にできます。

しかし、Git(2.29.2で確認)では、core.hooksPathのローカルのフックが設定されていると、グローバルのフックは実行されないという挙動になります。

git config --global core.hooksPath ~/.githooks
cd path/to/project
git config --local core.hooksPath ./.githooks
# このプロジェクトではローカルのGitフックが常に優先されるため、グローバルのGitフックは実行されない

この制限があるため、グローバルのフックとローカルのフックを同時に実行にするには、ちょっとしたハックが必要になります。

自分のグローバルフックのリポジトリZsh Integrationに書いていますが、Zshでコマンド実行前にプロジェクトのcore.hooksPathをグローバルの方を参照し直すように書き換えるという無理やりな手順がひつようになります。

# Source: https://github.com/azu/git-hooks
# Override <project>/.githook → <global>/git-hooks/hooks/
function preexec_git_global_hooks() {
  inside_git_repo="$(git rev-parse --is-inside-work-tree 2>/dev/null)"
  if [ "$inside_git_repo" ]; then
      githooksDir=$(git rev-parse --show-toplevel)"/.githooks"
      if [ -d "${githooksDir}" ]; then
        git config --local core.hooksPath "/path/to/global-git-hooks/hooks"
      fi;
  fi
}
autoload -Uz add-zsh-hook
add-zsh-hook preexec preexec_git_global_hooks

この方法は.githooksというディレクトリ名に依存しているのと余計な処理という感じがあるので、もっとクリーンな方法あるといいなと思います。

このデメリット?は、core.hooksPathによるものなのでhuskyも同様です。

その他

このcore.hooksPathを使ったプロジェクトのGitフック管理は、基本的にはHuskyと同じやり方なので、ハマりどころなどはHuskyのドキュメントが参考になります。

Huskyとの違いとしては、husky addのようなコマンドがなかったり、HUSKY_*の環境変数がないなどの違いがあります。 (基本的にlint-stagedpre-commitで実行するぐらいにしかGitフックを使ってないので、あんまり違いを感じるケースがありませんでした)

また、npm installはするが.gitディレクトリがない環境というのもたまに存在します。 具体的にはCloudflare PagesHerokuなどは、gitリポジトリをcloneはしますが.gitディレクトリがない状態なのでgit configコマンドが叩けません。

この場合に、npm install時のprepare life cycleでgit configが実行されると次のようにGitリポジトリではないというエラーが出ます。

fatal: Not a git repository

このようなケースは、git configの設定ができないのを、単純に無視することで回避しています。

  "scripts": {
-    "prepare": "git config --local core.hooksPath .githooks"
+    "prepare": "git config --local core.hooksPath .githooks || echo 'Can not set git hooks'"
  },

おわりに

Git 2.9以降はcore.hooksPathオプションでグローバルまたはローカルのGitフックのディレクトリを指定できるので、Huskyなど使わなくてGitフックを管理しやすくなっています。 また、言語ではなくGitの機能なので、別の言語でも同じアプローチが使えて良い気はしています。

最近の自分のプロジェクトでは、この記事で書いたような方法でGit core.hooksPath + lint-stagedで、Gitフックの処理を動かしています。

サンプルプロジェクトは次のリポジトリにおいてあります。

参考: