Git Hooks:バグのあるコードをcommitしないためのワークフロー自動化設定

Git tutorial - IT technology blog
Git tutorial - IT technology blog

Git hooksがない場合に起こること

以前8人チームで働いていたとき、数日おきに誰かがコードをpushしてCIパイプラインが真っ赤になる、ということが繰り返されていた。linterを実行し忘れたり、fixtestaaaaaのようなcommit messageだったりが原因だ。スプリントごとに3〜4回はそういうことがあった。コードレビューで「フォーマットを直してください」とコメントするのは時間の無駄だし、不必要なフリクションを生むだけだ。

Git hooksはまさにその問題を解決する。誰かが指摘しなくても、間違いがリポジトリに入る前に自動的にブロックしてくれる。

Git hooksとは何か、どこにあるのか

Git hooksは通常のスクリプト(bash、Python、Node.jsなど何でも可)で、Gitが特定のタイミングで自動的に呼び出す仕組みだ。commitの前、pushの前、mergeの後など、各イベントに対応するhookが存在する。

すべてのGitリポジトリには.git/hooks/ディレクトリがあり、.sampleというサンプルファイルが含まれている。hookを有効にするには、正しいファイル名(.sample拡張子を除く)でファイルを作成し、実行権限を付与する

ls .git/hooks/
# applypatch-msg.sample  commit-msg.sample  pre-commit.sample  pre-push.sample  ...

# 新しいhookを作成
touch .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit

client-sideとserver-side hooksの比較

この2つのグループはまったく異なるレイヤーで動作する。混同すると、設定しても何も効果がない。

Client-side hooks — 開発者のマシンで実行

各開発者のマシンで、ローカル操作の前後に実行される:

  • pre-commit:commitを作成する前に実行 — lint、format、高速なunit testに使用
  • commit-msg:commit messageを入力した後に実行 — フォーマットの検証(Conventional Commits、Jira ticket IDなど)に使用
  • pre-push:pushの前に実行 — より完全なtest suiteの実行に使用
  • post-commit:commit成功後に実行 — 通知やログに使われることが多い

事前に知っておくべき欠点.git/hooks/はGitで追跡されないため、各開発者はclone後に自分でインストールする必要がある。setupスクリプトがなければ、チームの新メンバーがこの手順を気づかずにスキップしてしまう可能性がある。

Server-side hooks — リモートリポジトリで実行

サーバー上で実行される(GitHub ActionsはhookではないがGitLab/Gitea self-hostedはサポートしている):

  • pre-receive:サーバーがpushを受け取る前に実行 — push全体を拒否できる
  • post-receive:push成功後に実行 — deployや通知によく使われる
  • update:pre-receiveに似ているが、ブランチごとに実行される

Server-side hooksは誰もbypassできない。開発者が--no-verifyを使っても回避できない。その代わり、サーバーへのアクセス権限が必要だ。GitHub/GitLab.comはserver hooksのインストールを許可していないため、代わりにCI/CDを使う必要がある。

Manual hooksとHusky — どちらが適しているか?

初めてセットアップするjunior devからよく受ける質問が「どっちを使えばいい?」だ。答えは個人の好みよりもtech stackに依存することが多い。

Manual hooks:シンプルで依存関係なし

スクリプトを直接.git/hooks/に書くか、リポジトリにhooks/ディレクトリを作成してsymlinkするためのsetup.shスクリプトを用意する:

# hooks/setup.sh
#!/bin/bash
cp hooks/pre-commit .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
echo "Git hooks installed!"

メリット:Node.jsが不要で、追加パッケージへの依存もなく、あらゆる言語(Python、Bash、Rubyなど)で動作する。
デメリット:clone後にsetup.shを実行することを覚えておく必要があり、忘れやすい。

Husky:npm install時に自動インストール

Huskyはnpmパッケージで、hooksをpackage.jsonのlifecycleに統合する。開発者がnpm installを実行すると、hooksが自動的にインストールされる。追加の手順は不要だ。

npm install --save-dev husky
npx husky init

メリット:チームにとってzero-config、hooksがリポジトリ(.husky/)にcommitされるため誰も欠かさない。
デメリット:Node.jsとnpmが必要で、non-JSプロジェクトへの依存追加はやや過剰かもしれない。

私の経験では、JavaScript/TypeScriptプロジェクトにはHuskyを使い、Python/Go/多言語プロジェクトにはMakefileまたはREADMEにsetupスクリプトを置いたmanual hooksを使う。

実践的な実装:最も重要な3つのhooks

1. pre-commit:commitの前にバグのあるコードをブロック

#!/bin/bash
# .git/hooks/pre-commit

echo "Running pre-commit checks..."

# stagingされているPythonファイルの一覧を取得
STAGED_PY=$(git diff --cached --name-only --diff-filter=ACM | grep '\.py$')

if [ -n "$STAGED_PY" ]; then
  # flake8 lintを実行
  echo "Linting Python files..."
  flake8 $STAGED_PY
  if [ $? -ne 0 ]; then
    echo "Linting failed. Fix errors before committing."
    exit 1
  fi

  # blackのフォーマットチェックを実行
  black --check $STAGED_PY
  if [ $? -ne 0 ]; then
    echo "Format check failed. Run 'black .' to fix."
    exit 1
  fi
fi

echo "All checks passed!"
exit 0

このhookはリポジトリ全体ではなく、stagingされているファイルのみをチェックするため、通常1〜2秒で完了し、commitのフローへの影響は最小限だ。

2. commit-msg:commit messageのフォーマットを強制

Conventional Commits(feat:fix:docs:など)はchangelogの自動生成と履歴の読みやすさを向上させる。このhookはパターンを検証する:

#!/bin/bash
# .git/hooks/commit-msg

MSG_FILE=$1
MSG=$(cat $MSG_FILE)

# Pattern: type(scope): description
# 例:feat(auth): add OAuth2 login
PATTERN='^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .{1,72}$'

if ! echo "$MSG" | grep -qE "$PATTERN"; then
  echo "ERROR: commit messageのフォーマットが正しくありません!"
  echo "Expected: feat|fix|docs|style|refactor|test|chore(scope): description"
  echo "Example:  feat(auth): add OAuth2 login"
  echo ""
  echo "Your message: $MSG"
  exit 1
fi

exit 0

3. pre-push:pushの前にテストを実行

#!/bin/bash
# .git/hooks/pre-push

BRANCH=$(git symbolic-ref HEAD | sed -e 's,.*/\(.*\),\1,')

# mainまたはdevelopへのpush時のみfull testを実行
if [ "$BRANCH" = "main" ] || [ "$BRANCH" = "develop" ]; then
  echo "Running test suite before push to $BRANCH..."
  python -m pytest tests/ -q --tb=short
  if [ $? -ne 0 ]; then
    echo "Tests failed. Push aborted."
    exit 1
  fi
fi

exit 0

Husky + lint-stagedのセットアップ(JSプロジェクト向け)

npm install --save-dev husky lint-staged
npx husky init

package.jsonに追加:

{
  "lint-staged": {
    "*.{js,ts,tsx}": ["eslint --fix", "prettier --write"],
    "*.{css,md}": "prettier --write"
  },
  "scripts": {
    "prepare": "husky"
  }
}

.husky/pre-commitファイル:

#!/bin/sh
npx lint-staged

hooksが負担にならないためのヒント

  • stagingされたファイルのみチェックし、リポジトリ全体はチェックしないgit diff --cached --name-onlyを使用。hookが5秒以上かかると開発者はbypassしようとする。
  • 緊急時の逃げ道を用意するgit commit --no-verify -m "hotfix: emergency"。急いでpushして後から修正する必要がある場合もある。完全にロックすると、助けになるよりも逆効果になることが多い。
  • 失敗時は明確なメッセージを表示するexit 1だけでなく、具体的なエラーと修正するためのコマンドを出力する。
  • hooks/をリポジトリにcommitしinstall-hooks.shスクリプトと一緒に管理する。新メンバーのオンボーディング時に追加の手順を聞かずに済む。

pre-commit lintとcommit-msg validationをそのチームに適用した後、PRの「format/lintを修正してください」コメントが1PR当たり4〜5件からほぼゼロに減少した。レビューがロジックやアーキテクチャに集中できるようになった。mergeの前にコードが統一されたフォーマットになっているため、merge conflictも減った。

Git hooksはCI/CDの代わりにはならない。integration testやend-to-endテストは引き続きpipelineで実行する必要がある。しかし、数秒で完了するシンプルなチェックであれば、client-side hookに置いておく方が、5〜10分待ってからエラーを知るよりもはるかに時間の節約になる。

Share: