Git hooksの使い方: pre-pushでmainへの直接pushを防ぐ設定と、チーム共有(lefthook/husky)

Git hooksの種類と発火タイミングの基礎から、pre-pushでmainへの直接pushを防ぐ実装、.git/hooksが共有されない理由とcore.hooksPath、lefthook/huskyでのチーム共有、GitHub branch protectionとの役割分担までを解説する。

Gitフックは、コミットやプッシュといった特定のGitイベントが発生したタイミングで自動的に実行されるスクリプトである。ローカルの開発ワークフローに独自のルールを組み込める便利な仕組みだが、「そもそもどんなフックがあるのか」「なぜチームに共有されないのか」「サーバー側の保護とどう役割分担すべきか」を理解しないまま使うと、思わぬ落とし穴にはまる。本記事では、pre-pushフックでmainブランチへの直接pushを防ぐ実装を中心に、Gitフックの全体像からチーム共有、GitHubのbranch protectionとの関係までを一通り解説する。

1. Git hooksとは

Gitフックは、リポジトリの.git/hooksディレクトリに置かれた実行可能スクリプトであり、Gitの特定の操作の前後に自動で呼び出される。フックはクライアントサイドフックとサーバーサイドフックに大別され、本記事の前半で扱うpre-pushは前者に属する(サーバーサイドフックは5章で扱う)。

代表的なクライアントサイドフックと、その発火タイミングは次のとおりである。

フック名発火タイミング主な用途
pre-commitgit commit実行時、コミットメッセージ入力前Lint・フォーマット・簡易テストの実行
prepare-commit-msgコミットメッセージエディタが開く前メッセージのテンプレート挿入
commit-msgコミットメッセージ確定後、コミット作成前メッセージ書式(Conventional Commits等)の検証
post-commitコミット作成後通知・後処理
pre-rebasegit rebase実行前特定ブランチでのrebase禁止など
post-checkoutgit checkout/git switch実行後依存パッケージの再インストール案内など
post-mergegit merge実行後マージ後の後処理
pre-pushgit pushがリモートにデータを送信する直前保護ブランチへの直接push禁止、push前テスト

リポジトリを初期化すると、.git/hooksにはpre-commit.sampleのように.sample拡張子の付いたサンプルスクリプトが最初から用意されている。これらは実行権限を持つ拡張子なしのファイル名(pre-commitなど)にリネームし、内容を編集することで有効になる。フックがゼロ以外の終了コードを返すと、対応するGit操作(コミットやプッシュ)は中断される。

2. pre-pushでmainへの直接pushを防ぐ

pre-pushフックは、git pushコマンドがリモートリポジトリにデータを送信する直前に実行される。このフックが0以外の終了コードを返すと、プッシュ操作全体が中断される。

シンプルな実装: 現在のブランチをチェックする

最も手軽な実装は、現在チェックアウトしているブランチ名を調べる方法である。

  1. .git/hooksディレクトリへの移動

    cd .git/hooks
    
  2. pre-pushスクリプトの作成

    pre-pushという名前のファイルを作成し、以下の内容を記述する。

    #!/bin/bash
    
    # 現在のブランチ名を取得
    current_branch=$(git symbolic-ref HEAD --short)
    
    # もし現在のブランチが 'main' であれば、エラーメッセージを表示してプッシュを禁止
    if [ "$current_branch" = "main" ]; then
      echo "Error: Direct push to 'main' branch is not allowed."
      echo "Please create a new branch and open a pull request."
      exit 1 # ゼロ以外の終了コードを返すとプッシュが中断される
    fi
    
    # それ以外のブランチであれば、プッシュを許可
    exit 0
    
    • #!/bin/bash: スクリプトをBashで実行することを指定する。
    • git symbolic-ref HEAD --short: 現在チェックアウトしているブランチ名を取得する。
    • if [ "$current_branch" = "main" ]; then ... fi: 現在のブランチがmainかどうかを判定する。
    • exit 1: 0以外の終了コードを返し、プッシュ操作を中断する。
    • exit 0: プッシュ操作を続行させる。
  3. 実行権限の付与

    chmod +x pre-push
    

これで、mainブランチをチェックアウトした状態でgit pushを実行すると、エラーメッセージが表示されプッシュが拒否される。

より堅牢な実装: プッシュ先のrefをチェックする

上記のシンプルな実装には見落としがある。pre-pushフックには実は、プッシュ対象の情報が標準入力(stdin)経由で渡されており、フック自体は<remote name> <remote URL>という2つの引数付きで呼び出される。標準入力には、プッシュされるref(ブランチ)ごとに次の形式の行が渡される。

<local ref> SP <local sha1> SP <remote ref> SP <remote sha1> LF

現在のブランチ名だけをチェックする実装では、たとえばfeatureブランチにいながらgit push origin feature:mainのように明示的な参照先(refspec)を指定してmainへプッシュするケースを見逃してしまう。プッシュ先の情報を直接読み取る、より堅牢な実装は次のようになる。

#!/bin/sh

protected_branch='refs/heads/main'

while read local_ref local_sha remote_ref remote_sha
do
  if [ "$remote_ref" = "$protected_branch" ]; then
    echo "Error: Direct push to 'main' is not allowed."
    echo "Please create a new branch and open a pull request."
    exit 1
  fi
done

exit 0

while readループで標準入力の各行を読み取り、プッシュ先の参照($remote_ref)がrefs/heads/mainと一致するかどうかを直接判定している。現在のローカルブランチが何であっても、実際にmainへ向けてプッシュしようとしている操作だけを正確に検知できる点が、シンプルな実装との違いである。

3. .git/hooksの限界とcore.hooksPath

ここまでの実装には共通の弱点がある。.git/hooksディレクトリはGitリポジトリのメタデータ領域であり、コミット対象にはならない。そのため、git cloneしても.git/hooksの中身は複製されず、他の開発者の手元には*.sampleファイルしか存在しない状態になる。つまり、.git/hooksに直接書いたフックは、原理的にチームで共有できない。

この制約を回避する方法の1つが、core.hooksPath設定である。これはGitに対して「フックの参照先ディレクトリを.git/hooks以外の場所に変更してよい」と伝える設定で、Git 2.9以降で利用できる。リポジトリ内の(コミット対象になる)ディレクトリをフックの置き場所に指定すれば、フック自体をバージョン管理下に置ける。

mkdir .githooks
mv .git/hooks/pre-push .githooks/pre-push
git config core.hooksPath .githooks
git add .githooks
git commit -m "Add shared pre-push hook"

ただしcore.hooksPathにも限界がある。この設定自体は各開発者のローカルなGit設定(.git/config)に保存されるため、git config core.hooksPath .githooksというコマンドを、clone後に開発者それぞれが手動で実行する必要がある。「フックの中身は共有できるが、有効化する一手間はチームメンバー全員に残る」というのがcore.hooksPath単体運用の実情である。この「有効化の手間」を自動化するのが、次に紹介するlefthookやhuskyといったツールの役割になる。

4. チームで共有する: lefthook/husky

core.hooksPathを手動で設定する手間をなくし、npm installや初回セットアップのタイミングでフックを自動的に有効化してくれるのが、lefthookやhuskyといったGitフック管理ツールである。

lefthook

lefthookはGo製のGitフック管理ツールで、YAML形式の設定ファイル(lefthook.yml)にコマンドを列挙するだけでフックを定義できる。lefthook installを実行する(あるいはpackage.jsonのprepareスクリプトに組み込んでおく)と、設定内容がリポジトリの実際のGitフックとして展開される。

pre-push:
  commands:
    protect-main:
      run: |
        branch=$(git symbolic-ref HEAD --short)
        if [ "$branch" = "main" ]; then
          echo "Error: Direct push to 'main' branch is not allowed."
          exit 1
        fi

実は本ブログ自体もlefthookを採用しており、pre-commitでPrettierによる自動整形、pre-pushでHugoのビルド確認を行うようにしている。設定ファイルをコミットしておくだけで、リポジトリをcloneした全員が同じチェックを共有できる点が、.git/hooks直書きとの最大の違いである。

husky

huskyはNode.jsエコシステムで広く使われているGitフック管理ツールである。npx husky initを実行すると、.huskyディレクトリと、package.jsonへのprepareスクリプトの追加が行われる。以降は.husky配下にフック名のファイルを作成するだけでよい。

#!/usr/bin/env sh

branch=$(git symbolic-ref HEAD --short)

if [ "$branch" = "main" ]; then
  echo "Error: Direct push to 'main' branch is not allowed."
  exit 1
fi

prepareスクリプトはnpm install実行時に自動的に走るため、リポジトリをcloneして依存パッケージをインストールするだけで、チームの全員に同じフックが行き渡る。lefthookとhuskyはいずれも「フックの中身をリポジトリにコミットし、インストール時に自動で有効化する」という設計思想は共通しており、既存のツールチェイン(Go系ツールに寄せたいか、Node.jsのエコシステムに寄せたいか)に応じて選べばよい。

5. サーバーサイドの保護: GitHub branch protectionとの役割分担

ここまで紹介してきたフックには、共通の限界がある。git push --no-verifyオプションを使えば、フックそのものをスキップしてプッシュできてしまう。また、フックが正しくインストールされていない環境からのプッシュを防ぐこともできない。つまり、クライアントサイドフックはあくまで「開発者自身のうっかりミスを、ローカルの時点で早期に検知する」ための補助的な仕組みであり、強制力を持つルールではない。

強制力を持たせたい場合は、サーバー側での保護が必要になる。GitHubであれば、リポジトリのSettings > Branches(またはより新しいRulesets機能)からmainブランチに対する保護ルールを設定できる。代表的な設定には次のようなものがある。

  • プルリクエストを経由しないマージを禁止する(直接pushの禁止)
  • マージ前に特定のステータスチェック(CI)の成功を必須にする
  • force pushを禁止する
  • プッシュできるユーザー・チームを制限する

branch protectionはGitHubのサーバー側で強制されるため、--no-verifyのようなクライアント側の操作では回避できない。したがって役割分担としては、「クライアントサイドフックは開発者の手元での早期フィードバック、branch protectionはリポジトリ全体に対する最終的な強制力」と整理するのが実務的である。フックだけに頼って保護ブランチへの直接pushを完全に防ごうとするのではなく、両方を組み合わせて使うのが望ましい。

なお、GitHubへの接続自体(SSH鍵の設定)がまだの場合は、先に GitHubにSSH接続する設定方法 を済ませておくと、ここで紹介したpushまわりの設定がスムーズに進む。

6. まとめ

Gitフックは、コミットやプッシュといったイベントに合わせて自動実行されるスクリプトであり、pre-pushフックを使えばmainブランチへの直接pushをローカルの時点で防止できる。ただし.git/hooksはリポジトリのコミット対象にならないため、そのままではチームに共有されない。core.hooksPathを使えばフックの中身自体はバージョン管理下に置けるが、有効化の手間は各開発者に残る。lefthookやhuskyといったツールを導入すれば、インストール時にフックが自動的に有効化され、チーム全体で同じルールを共有できる。そして、フックはあくまで開発者の手元での早期フィードバックであり、強制力を持つ保護をかけたい場合はGitHubのbranch protectionのようなサーバーサイドの仕組みと組み合わせる必要がある。

よくある質問(FAQ)

Q. hooksがgit cloneで共有されないのはなぜ?

.git/hooksディレクトリはGitリポジトリの管理用メタデータが置かれる領域であり、コミットの対象となるファイル(ワーキングツリーの内容)には含まれない。git cloneすると.gitディレクトリ自体は新規に作られるが、その中のhooksサブディレクトリには最初から*.sampleファイルしか用意されていない。実際に有効なフックスクリプトを.git/hooksに置いても、それはローカルな設定ファイルの一種として扱われるため、他の開発者が同じリポジトリをcloneしても複製されない。この制約を回避するには、core.hooksPathでコミット対象のディレクトリをフックの置き場所に指定するか、lefthookやhuskyのようなツールを使う。

Q. フックを一時的にスキップするには?

git push --no-verifyのように--no-verifyオプションを付けると、pre-push(あるいはgit commit --no-verifypre-commitcommit-msg)フックをスキップしてコマンドを実行できる。動作確認や緊急対応など、フックの検証が不要な場面で意図的に使う分には問題ないが、mainへの直接push禁止のようなルールもこのオプションで簡単に回避できてしまう点には注意が必要である。チーム全体でルールを強制したい場合は、フックだけに頼らずサーバーサイドの保護と組み合わせる必要がある。

Q. branch protectionがあればフックは不要?

不要ではない。branch protectionはリポジトリ全体に対する最終的な強制力を持つが、あくまでプッシュ後(サーバーに届いた時点)での判定になる。フックはプッシュする前のローカル環境でエラーに気づける、CIの実行を待たずに済む、オフラインでも機能するといった、branch protectionにはない即時性のメリットを持つ。両者は代替関係ではなく補完関係にあるため、フックによる早期フィードバックとbranch protectionによる強制力を組み合わせて使うのが望ましい。

関連記事


関連ツール

関連書籍

Gitの基礎からチーム開発でのワークフローまでを腰を据えて学び直したい場合は、次の書籍が参考になる。

いちばんやさしいGit&GitHubの教本 第3版(横田紋奈・宇賀神みずき、インプレス)

他の分野の定番書は エンジニアにおすすめの技術書10選 にまとめている。