ワークフローを自動化する:Gitクライアントサイドフック実践ガイド

Gitクライアントサイドフックを使用して、高速なローカルチェック、共有セットアップ、コミットメッセージルール、安全なポストマージ自動化を実現します。

ワークフローを自動化する:Gitクライアントサイドフック実践ガイド

Gitクライアントサイドフックは、Gitがワークフローの特定のポイントに達したときにマシン上で実行される小さなスクリプトです。pre-commitフックはコミットが作成される前に実行されます。commit-msgフックはメッセージを書いた後、Gitが受け入れる前に実行されます。post-mergeフックはマージが完了した後に実行されます。適切に使用すれば、フックは退屈なミスを早期にキャッチします:フォーマット忘れ、壊れた生成ファイル、依存関係のインストール漏れ、チームの規約に合わないコミットメッセージなどです。

重要な制限は、クライアントサイドフックがローカルであることです。リポジトリをクローンしても自動的には移動しません。そのため、迅速なフィードバックとローカルの利便性には優れていますが、チームルールの唯一の強制手段としては弱いです。チェックが本当にメインブランチを保護するなら、CIやサーバーサイドルールにも入れてください。

すべてのリポジトリには.git/hooksの下にフックディレクトリがあります:

ls .git/hooks

新しいリポジトリには通常、pre-commit.sampleのようなサンプルファイルが含まれています。サンプルフックは、.sampleサフィックスなしの実行可能ファイルを作成するまで何もしません:

cp .git/hooks/pre-commit.sample .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

フックはシェルスクリプト、Pythonスクリプト、Rubyスクリプト、Nodeスクリプト、またはマシンが実行できるその他のものでも構いません。最初の行はインタプリタを指す必要があります:

#!/usr/bin/env bash

ほとんどのチームにとって、長期的に良いパターンは、すべてのラップトップで.git/hooksを手動編集することではありません。フックスクリプトをリポジトリに保存し、Gitにそのディレクトリを使用するように設定します:

git config core.hooksPath .githooks
mkdir -p .githooks

これで、.githooks/pre-commitにあるフックを通常のプロジェクトコードのようにコミットしてレビューできます。各開発者はまだcore.hooksPath設定が必要ですが、セットアップをブートストラップスクリプトに追加したり、オンボーディングで文書化したりできます。

便利なPre-Commitフック

良いpre-commitフックは高速で焦点を絞るべきです。毎回のコミットに2分かかるなら、人々はgit commit --no-verifyでバイパスし、フックはノイズになります。完全なテストスイートはCIに任せてください。プロジェクトが本当に小規模で迅速でない限り。

以下は、ステージングされたファイルのみをチェックする実用的なシェルフックです。その区別は重要です。作業ツリーにまだテストしたくない未完了の作業があるかもしれません。コミットはステージングされたものによって判断されるべきです。

.githooks/pre-commitを作成:

#!/usr/bin/env bash
set -u

changed_files=$(git diff --cached --name-only --diff-filter=ACMR)

if [ -z "$changed_files" ]; then
  exit 0
fi

if git diff --cached --check; then
  :
else
  echo "Fix whitespace errors before committing."
  exit 1
fi

secret_matches=$(git diff --cached --name-only --diff-filter=ACMR | xargs grep -nE 'AKIA[0-9A-Z]{16}|BEGIN RSA PRIVATE KEY' 2>/dev/null || true)
if [ -n "$secret_matches" ]; then
  echo "Possible secret found in staged files:"
  echo "$secret_matches"
  exit 1
fi

python_files=$(printf '%s\n' "$changed_files" | grep '\.py$' || true)
if [ -n "$python_files" ]; then
  printf '%s\n' "$python_files" | while IFS= read -r file; do
    [ -f "$file" ] || continue
    python3 -m py_compile "$file" || exit 1
  done
fi

exit 0

このフックは3つの控えめなことを行います:Gitに空白エラーを検出させ、ステージングされたファイルに明白なシークレットパターンがないかチェックし、変更されたPythonファイルをコンパイルします。これは本格的なシークレットスキャナーやテストスイートの代わりにはなりません。素早いトリップワイヤーです。

よくある間違いは、ファイルの内容ではなくファイル名に対してgrepを使用することです。この壊れたパターンは、パスにTODOが含まれているかどうかだけをチェックし、ファイルに含まれているかどうかはチェックしません:

git diff --cached --name-only | grep TODO

TODOコメントをブロックしたい場合は、代わりにステージングされた差分を検査します:

if git diff --cached -U0 | grep -E '^\+.*TODO:'; then
  echo "Staged TODO comments found."
  exit 1
fi

それでも注意が必要です。一部のチームはTODOコメントを責任を持って使用しています。すべてのTODOをブロックすることは、役立つよりも迷惑になる可能性があります。

コミットメッセージフック

commit-msgフックは、最初の引数として一時的なコミットメッセージファイルへのパスを受け取ります。これは、「すべてのコミットはチケットIDで始まる必要がある」や「Conventional Commitsを使用する」などのルールに便利です。

小さな例:

#!/usr/bin/env bash
set -u

message_file="$1"
first_line=$(head -n 1 "$message_file")

if printf '%s' "$first_line" | grep -Eq '^(feat|fix|docs|test|refactor|chore)(\(.+\))?: .+'; then
  exit 0
fi

echo "Commit message should look like: fix(api): handle empty token"
exit 1

これは、リリースノートやチェンジログがコミットから生成される場合に役立ちます。チームがスカッシュマージを行い、PRタイトルを書き換える場合にはあまり役立ちません。実際に使用しているワークフローにフックを合わせてください。

Post-Mergeフック

post-mergeフックは、作業ツリーが変更された後のローカルクリーンアップに最適です。古典的な例は、ロックファイルが変更された後に依存関係をリフレッシュすることです。

#!/usr/bin/env bash
set -u

previous_head="HEAD@{1}"

if git diff --name-only "$previous_head" HEAD | grep -Eq '(^package-lock\.json$|^pnpm-lock\.yaml$|^yarn\.lock$)' ; then
  if command -v npm >/dev/null 2>&1 && [ -f package-lock.json ]; then
    echo "Lockfile changed; running npm install."
    npm install
  fi
fi

if git diff --name-only "$previous_head" HEAD | grep -q '^\.gitmodules$'; then
  echo "Submodule config changed; syncing submodules."
  git submodule sync --recursive
  git submodule update --init --recursive
fi

このフックは驚くような変更を加えるべきではありません。依存関係をインストールする場合は、何をしているかを出力してください。インストールが失敗した場合は、開発者に回復方法を伝えてください。作業ツリーを静かに変更するフックは信頼しにくいです。

フックを混乱なく共有する

フックを共有する一般的な方法は3つあります。

最も簡単なのはcore.hooksPathで、リポジトリに.githooks/が含まれ、セットアップでGitがそれを使用するように設定します。これは透過的で、別のパッケージマネージャーを必要としません。

JavaScriptプロジェクトでは、Huskyがよく使われます。これはnpmpnpm、またはyarnのインストールフローと統合されるためです。すべてのコントリビューターがNodeツールチェーンを使用している場合に適しています。

多くの混合言語チームはpre-commitフレームワークを使用します。これは.pre-commit-config.yamlで定義されたフックをインストールして実行し、フォーマッター、リンター、ファイルチェックなどのツールのバージョンを固定します。別のツールが追加されますが、「同じフックをどこにでもインストールする方法」問題をwikiページよりも解決します。

私が避けるのは、大きなスクリプトを手動で.git/hooksにコピーすることです。誰もレビューせず、どのバージョンがインストールされているか誰も知らず、デバッグは個人的な考古学になります。

フックのデバッグ

フックが実行されない場合、次の順序で確認します:

git config --get core.hooksPath
ls -l .git/hooks .githooks 2>/dev/null

core.hooksPathが設定されている場合、Gitは.git/hooksを無視し、設定されたディレクトリを使用します。macOSまたはLinuxでフックファイルが実行可能でない場合、Gitは実行しません:

chmod +x .githooks/pre-commit

フックが実行されるが謎の失敗をする場合、一時的なトレースを追加します:

set -x
pwd
env | sort

通常のGit使用ではフックはリポジトリルートから実行されますが、GUIクライアントやIDEはパスや環境の違いを引き起こす可能性があります。リンターやパッケージマネージャーが利用可能であると仮定する前に、フック内でcommand -v toolnameを使用してください。

また、バイパススイッチを覚えておいてください:

git commit --no-verify

これはそれ自体がセキュリティホールではありません。Gitの動作方法です。本格的な強制はCIや保護ブランチルールに属するもう一つの理由です。

賢明なフックポリシー

高速で決定論的で説明が簡単なチェックにフックを使用してください。ステージングされたファイルのフォーマット、空白エラーのキャッチ、コミットメッセージの検証、依存関係のインストールを開発者に促すことは良い候補です。ネットワークアクセスが必要なフック、時間がかかるフック、または壊れやすいローカル状態に依存するフックは避けてください。

フックがコミットをブロックする場合、そのメッセージは何が失敗したか、どのように修正するかを正確に示すべきです。「フックが失敗しました」だけでは不十分です。マージやプロダクションホットフィックスの最中の開発者には明確な次のコマンドが必要です。

クライアントサイドGitフックは、役立つガードレールのように感じられ、ローカルの官僚機構のように感じられないときに最も効果的です。小さく保ち、バージョン管理し、最終的な権限はCIに残してください。

緊急時にはフックを親しみやすく保つ

フックは通常の作業中に役立つべきですが、緊急修正中に誰かを閉じ込めてはいけません。つまり、すべてのブロッキングフックには明確な失敗メッセージと現実的な脱出ハッチが必要です。Gitはすでにコミットおよびプッシュフック用の--no-verifyを提供していますが、チームはバイパスが許容される場合を決定する必要があります。プロダクションホットフィックスは、開発者が急いでいるためにフォーマットをスキップするのとは異なります。

良いフックメッセージは、何が失敗したか、どこで失敗したか、次に何を実行するかを示します:

echo "ESLint failed on staged JavaScript files."
echo "Run: npm run lint -- --fix"
exit 1

悪いメッセージはfailedだけを表示するか、コンテキストなしでツール出力のページをダンプします。人々はそのようなフックを無視することを学びます。

フックがファイルを変更する場合は、特に注意してください。フォーマッターはpre-commitで役立つことがありますが、ファイルのステージングされていない部分を変更するときに混乱を引き起こす可能性があります。多くのチームはフックでフォーマットをチェックし、開発者が手動でフォーマッターを実行することを好みます。他のチームはステージングされたハンクのみをフォーマットするツールを使用します。1つの動作を選択し、消えるチャットスレッドではなくリポジトリに文書化してください。

チームの場合、フックの変更をアプリケーションコードのようにレビューしてください。フックはすべてのコミットを遅くしたり、環境の詳細をログに漏らしたり、Bashのみの動作を想定している場合にWindowsのコントリビューターを壊したりする可能性があります。プロジェクトにWindowsのコントリビューターがいる場合、Git Bashでフックをテストするか、クロスプラットフォームのフックランナーを使用してください。プロジェクトにコンテナや開発シェルがある場合、アプリと同じ環境内でフックを実行して、全員が同じツールバージョンを使用するように検討してください。

最良のフックは、すべてが正常なときはほとんど見えず、何かが間違っているときは非常に具体的です。それが目指すべき基準です。

フックを製品コードのようにバージョン管理する

フックスクリプトは開発者体験の一部になります。壊れると、すべてのコントリビューターが感じます。スクリプトを小さく保ち、ヘルパー関数に明確な名前を付け、簡単なコマンドで済む場合は巧妙なシェルトリックを避けてください。フックが1画面か2画面を超えて成長した場合は、実際のロジックをテスト済みのプロジェクトスクリプトに移動し、フックがそのスクリプトを呼び出すようにします。

例えば、長いリントルーチンを.githooks/pre-commitに埋め込む代わりに、次のように呼び出します:

./scripts/check-staged-files.sh

そのスクリプトは開発者、フック、CIによって実行できます。また、開発者はコミットを装わずに失敗を再現できます。再現性は、役立つフックと謎のローカル障害の違いです。

可能な場合はツールのバージョンを固定してください。PATHの最初にあるblackeslint、またはprettierを呼び出すフックは、マシンによって動作が異なる場合があります。プロジェクトローカルの依存関係、ロックファイル、コンテナ、またはバージョンマネージャーにより、フックの出力がより予測可能になります。

最後に、フックをリポジトリに限定してください。グローバルフックは便利に聞こえますが、数ヶ月後に関連のないリポジトリが古い個人ルールのために失敗し始めると驚かされることがよくあります。グローバルフックは本当に個人の好みにのみ使用し、チームポリシーには使用しないでください。

もう1つの実用的なルール:フックをコマンドが存在する唯一の場所にしないでください。フックがステージングされたPythonファイルをチェックする場合、そのコマンドをスクリプトやタスクランナーにも保持してください。開発者は、Gitが中断する前に、同じチェックを意図的に実行できるべきです。

オープンソースプロジェクトの場合、コントリビューターがまだ完全なツールチェーンをインストールしていない可能性があると想定してください。フレンドリーなセットアップメッセージで失敗するフックは問題ありません。欠落したローカルバイナリからスタックトレースをスローするフックは壊れているように感じられます。より重いコマンドを実行する前に前提条件をチェックし、プロジェクトで使用されるセットアップコマンドを人々に示してください。

また、部分コミットについて考えてください。多くの経験豊富な開発者はファイルの一部のみをステージングします。ファイル全体をフォーマットするフックは、誤ってステージングされていない作業をコミットに引き込む可能性があります。チームが部分コミットを頻繁に使用する場合、ステージングされた差分を読み取るチェックやステージングされたコンテンツ用に設計されたツールを優先してください。

フックが頻繁にバイパスされる場合、それをフィードバックとして扱ってください。チェックが遅すぎるか、失敗メッセージが不明瞭か、ルールがローカルコミットパスではなくCIに属しているかのいずれかです。Gitが提供するバイパスを使用する開発者を非難するのではなく、摩擦を修正してください。