はじめに
こんにちは!株式会社C&Pの武智でございます。
この記事は Git Pre-push hookを活用して、CI/CDの前に自動Lintチェック の続編です。今すぐ導入できるセットアップ手順は前編をご参照ください。
今回はpre-pushのgithookを選んだ設計判断の背景を掘り下げます。
具体的には「なぜpre-commit githookではなく、pre-push githookなのか、そしてCI/CDだけに任せないのか」を説明します。
なぜpre-push githookなのか
- Lintの手動実行漏れや、自動実行の場合はGitHub Actions等のCIで初めて気づく、という状況を回避できます(不要なActionsを回して順番待ちを発生させたくないこともあります)
- エラー通知を設定している場合、ノイズログの発生も抑えることができます
- フックは
~/.githooks/にグローバルで配置するため、マシン上のすべてのリポジトリに自動で適用されるため、新しいプロジェクトをgit cloneした瞬間から、何も設定せずにpre-push githookが使用できます - ローカルで完結しつつ、自動実行できる
- pre-commitフックの場合みたいに、新しいLintツールを追加するたびに各ツールの挙動を考えなくて良い(※後述)
Makefileと何が違うのか
「Makefileに定義したLintコマンドをまとめて実行するショートカットを書けばいいのでは?」という声もあると思います。こちらも比較します。
Makefile (make lint) |
pre-push githook | |
|---|---|---|
| 実行タイミング | 手動で make lint を叩いたとき |
git push のたびに自動 |
| スコープ | グローバル化は可能だが競合リスクがある | グローバル設定で全リポジトリに一括適用 |
| 強制力 | 打ち忘れても push できる | エラーがあれば push がブロックされる |
| git | git とは無関係のシェルタスク | git ワークフローに直接組み込まれる |
| スキップ方法 | そもそも実行しなければいい |
LINT=0 git push で意図的にスキップできる |
Makefileの lint タスクは「実行する習慣がある人」には有効ですが、ヒューマンエラーもあるので習慣に頼らず強制するという点でpre-push githookには遠く及びません。
なぜpre-commit githookにしなかったのか
最初はpre-commit githookとして実装しようとしました。
コミット直後にエラーが出れば、フィードバックが最速になるからです。
しかし調べていくうちに、サクッと構築するなら今回使用するPHPのLintツール群とは構造的に相性が悪いと感じました。
3つのツールすべてがステージングエリアを読めず、ワーキングディレクトリを見る
pre-commit githookでLintを正しく動かすには、「コミットに含まれる内容(ステージされたスナップショット)」をチェックする必要があります。しかし実際には:
Laravel Pint
--staged に相当するフラグが存在しない。よく見られる回避策は git diff --cached --name-only(ステージングエリアに登録されているファイルの「名前一覧」を取得するコマンド)でファイルパスを列挙し、それをコマンドライン引数としてPintに渡す方法だが、Pintはその引数のファイルパスをもとにディスク上のファイル、つまりワーキングディレクトリ版を読む。ステージした変更と未ステージ変更が同じファイルにある場合、コミットに含まれないコードまでLintされる。
PHP_CodeSniffer
--filter=GitStaged オプションでチェック対象ファイルを絞れるが、公式リポジトリのissue(#3379)で議論されている通り、このフィルターは「どのファイルを対象にするか」を絞るだけで、ファイルの読み込み元はあくまでワーキングディレクトリである。これはバグではなくPHPCSの設計上の動作であり、ステージされたスナップショットを直接読む機能は持っていない。
PHPStan / Larastan
ステージングエリアを認識せず、ワーキングディレクトリ上のファイルをそのままスキャンするため、ステージ済み・未ステージを区別せず解析してしまう。
同一ファイルにステージ済みと未ステージの変更が混在している場合、3つのツールすべてが「コミットに含まれない変更」を含む状態でLintを行う。チェックが通ってもコミットの中身は別物、あるいはチェックが失敗してもコミット自体はクリーンという状況が起こり得る。
git stash --keep-index による回避策にもリスクがある
「ステージ以外の変更を一時的に退避してからLintを走らせる」という git stash --keep-index アプローチは存在するが、これ自体に複数のリスクがある:
同一ファイルへの競合
ステージ済みと未ステージの変更が同じ行に触れている場合、git stash pop 時にマージコンフリクトが発生する。git-scm.comの公式ドキュメントにある通り、コンフリクトが起きるとstashエントリは自動削除されず、ワーキングディレクトリは競合状態のまま残される。
未ステージ変更の消失リスク
githookが異常終了した場合、git stash pop が実行されないまま終わる。退避した未ステージ変更がstashに取り残され、気づかなければ作業内容が見えなくなる。
削除ファイルの"復活"
ファイル削除をステージしている場合、git stash --keep-index によってそのファイルがワーキングディレクトリに復活することがある。削除コミットのつもりがLint対象に含まれるという混乱が生じる。
意図しない別stashのpop
エッジケースだが、退避するものが何もない状態で git stash pop を実行すると、以前の別のstashエントリが意図せず復元される。
これらを正しく回避するには、より複雑なstashによる管理、hookの終了コードハンドリング、pop時の --index フラグの使い分けなど、スクリプトの複雑さが急増する割に、保証できる信頼性も低い。
PHPStan / Larastanはコミットのたびに全スキャンするには重い
PHPStanはパスの指定なしでは動作せず、phpstan.neon の paths 設定で解析対象を明示する必要がある(phpstan.org公式ドキュメント)。Laravelプロジェクトで app/ 全体を設定すると、十数秒かかることが珍しくありません。
「急いでいるから・さっき大丈夫だったから・面倒だからOFFにしよう...💀」
チーム開発の場合、実行コストがそのままhook無効化の動機になることも懸念すべきでしょう。
以上を踏まえて、commit中の作業には干渉せず、pushの直前に一度だけチェックし、かつローカルで完結する設計でpre-pushを選びました。
| pre-commit githook | pre-push githook | |
|---|---|---|
| 実行頻度 | コミットのたびに毎回 | pushのときのみ |
| ステージング精度 | Pint・PHPCS・PHPStanはすべてワーキングディレクトリを読む。コミット対象のコードだけをチェックできない | デフォルトはプロジェクト全体を各ツールのconfigに従ってスキャン。LINT_NEW=1 オプションでpush範囲のみに絞れる(※PHPstan/Larastan以外)。ただしツールはディスク上のファイルを読むため、未コミットのPHP変更がある場合はフックが中断される |
git stash --keep-index 回避策 |
競合・消失・削除ファイル復活など複数の破壊的リスクあり | stashは不要。ただし未コミットのPHP変更がある場合はpushを中断するガードが必要 |
| PHPStan/Larastanの実行コスト | WIPコミットのたびにプロジェクト全スキャンが走る | push前の1回なら許容範囲 |
| フィードバックの早さ | 最速(commit直後) | pushまで気づけない場合がある |
| スキップされやすさ | 遅い・煩わしいと --no-verify が常態化しやすい |
頻度が低い分、スキップへの誘惑も少ない |
おわりに
pre-push hookの魅力は「commitには一切干渉しない」という点だと思います。
開発中のWIPコミットも、fixも、自由に打てる。
そしてチームへ共有する直前の一度だけ、静かにLintが走る。
AIを使用したワークフロー見たいな派手さはないですが、軽量で開発リズムを壊さずに品質を守れるバランスが気に入っています。
セットアップ手順は前編をご参照ください。
最後まで読んでいただきありがとうございました!
それではまた次回にお会いしましょう!