GitHub Proなしでmainブランチを守る — ローカルgit hooksでBranch Protectionを代替する

AI主体で執筆

個人開発のPrivateリポジトリにCursorエージェントを並行で走らせていたら、main.cursor/rules/todo.mdの微修正が直接コミットされて、「誰が・いつ・なぜ入れたのか」を後から辿れないコミットが積み重なりました。GitHubのBranch Protectionで止めようと設定画面を開いたら、Freeプランではそもそも使えないと表示されて詰みました。サーバー側で止められないので、ローカルのpre-commitpre-pushとCursor Rulesの条文を組み合わせて、mainへの直コミット/直pushを封じた設計を書き残します。

姉妹記事の Cursorエージェントの「宙ぶらりん作業」を防ぐ3条 が「完了の定義」でルールを守らせる側だったのに対し、こちらはpre-commitpre-pushmainの書き換え自体を物理的に弾きます

Freeプランで何ができないか

GitHubのBranch ProtectionRulesetsは、PrivateリポジトリではProプラン以上が必要です。Publicリポジトリなら無料で設定できますが、個人開発の未公開プロジェクトを無理に公開するのは筋が違います。課金でProに上げれば解決しますが、月額7ドルは「main保護のためだけ」と考えると割に合いません。

結果として、サーバー側でrefs/heads/mainへの直push・force push・PRなしのマージを止める仕組みがありません。自分で気を付けるしかなく、並行で動くエージェントも同じ原則を共有している必要があります。

メモ:Xcode Cloudを使っているなら、Apple Developer Programに含まれる無料枠で実質的なCI保護は得られます。ただしそれは「PRごとにビルドが通るか」の確認であって、mainに何がコミットされるかを止める役割は別物です。

3段構えで代替する

サーバー側で止められない以上、ローカル側で止めるしかありません。ただしローカルフックは--no-verifyで迂回できるので、「止められる層」を重ねて、人間が迂回できる余地は残しつつ、エージェントには迂回自体を禁じる条文をルールに書きました。

1・2は機械、3は宣言です。どれか1層が抜けても残りで救済できる組み方は、姉妹記事の .cursor/rulesだけでスタイルを守ろうとして崩壊した と同じ発想です。

1段目:pre-commitで保護対象ファイルを増やす

もとからscripts/pre-commitはあったのですが、.swiftなどのソースコードしか見ていませんでした。Cursorエージェントが触るのは*.md*.mdc.github/scripts/なので、保護対象をここまで広げます。

#!/usr/bin/env bash
# scripts/pre-commit

set -euo pipefail

current=$(git branch --show-current)
if [[ "${current}" != "main" ]]; then
  exit 0
fi

protected=$(git diff --cached --name-only | grep -E '\.(swift|md|mdc)$|^\.github/|^scripts/' || true)
if [[ -n "${protected}" ]]; then
  echo "✗ main ブランチでは以下のファイルを直接コミットできません:" >&2
  echo "${protected}" | sed 's/^/  /' >&2
  echo "" >&2
  echo "  ブランチを切ってから PR 経由でマージしてください。" >&2
  exit 1
fi

ポイントはscripts/配下も保護対象に入れたことです。エージェントが「フック自体を壊して再インストールすれば抜けられる」という抜け道を塞げます。自己保護としての意味のほうが大きく、見た目はトートロジーです。

2段目:pre-pushでmainへの直pushを拒否する

pre-pushはgitから<local_ref> <local_sha> <remote_ref> <remote_sha>をstdinで受け取ります。remote_refrefs/heads/mainのときだけrejectし、local_shaがall-zerosのとき(ブランチ削除)は通す、という分岐になります。

#!/usr/bin/env bash
# scripts/pre-push

set -euo pipefail

protected_ref="refs/heads/main"
zero="0000000000000000000000000000000000000000"

while read -r local_ref local_sha remote_ref remote_sha; do
  if [[ "${remote_ref}" == "${protected_ref}" ]]; then
    if [[ "${local_sha}" == "${zero}" ]]; then
      continue
    fi
    echo "✗ main への直接 push は禁止です。PR 経由でマージしてください。" >&2
    exit 1
  fi
done

exit 0

これでgit push origin mainは確実に拒否されます。git push --forceでも同じ経路を通るので、force pushも同時に止まります。

インストール:worktreeを跨いで効かせる

フックは共通の.git/hooks/に置かれます。リポジトリのプライマリworktreeと、追加したgit worktree add配下のworktreeは、同じ.gitディレクトリを共有しているので、1回のインストールですべてのworktreeに適用されます。インストール側のスクリプトはgit rev-parse --git-common-dirを使うのがコツです。

#!/usr/bin/env bash
# scripts/install-hooks.sh

set -euo pipefail

hooks_dir="$(git rev-parse --git-common-dir)/hooks"
mkdir -p "${hooks_dir}"

for name in pre-commit pre-push; do
  cp "scripts/${name}" "${hooks_dir}/${name}"
  chmod +x "${hooks_dir}/${name}"
  echo "installed: ${hooks_dir}/${name}"
done

最初は普通にcp scripts/pre-commit .git/hooks/と書いていたのですが、linked worktreeで実行すると.gitディレクトリではなくファイルgitdir: /.../worktrees/<name>と書かれたテキスト)で、Not a directoryエラーで落ちます。git rev-parse --git-common-dirは共通の実体ディレクトリを返してくれるので、プライマリでも追加worktreeでも同じスクリプトが通るようになります。

3段目:--no-verifyをルールで禁じる

ローカルフックにはgit commit --no-verifygit push --no-verifyという正式な迂回路があります。これ自体はレビューのない緊急fixや、フック側のバグで困ったときのために残したい。そこで、人間は使ってもよいが、エージェントには使わせない、という温度差をルールに書きました。

# .cursor/rules/github-workflow.mdc(抜粋)

## --no-verify は使わない

- `git commit --no-verify` / `git push --no-verify` はエージェントからは使用禁止
- フック側のバグで緊急に push したい場合は、人間ユーザーに相談する
- フックが誤検知しているなら、フック本体を直す PR を先に出す

フックは機械的な検出層で、ルールは宣言層です。機械で止めきれなかったケースが来たときに、エージェントが自分で--no-verifyを選ぶのを禁じておくと、機械と宣言の効き目が噛み合います。

運用してみて

3段構えにしてから、mainに知らないコミットが積まれなくなりました。fixやdocsの小さな修正でも、毎回ブランチを切って、PRを作って、マージする、という流れになります。面倒に見えますが、gh pr create --fillgh pr merge --merge --delete-branch --adminが1コマンドずつなので、1タスクあたり30秒の追加コストに収まっています。

副次的な効果として、GitHubの履歴だけで作業の経緯が追えるようになりました。mainのコミットは必ずマージコミットか、squash merge後の単一コミットになるので、PRのdescriptionを読めばなぜその変更が入ったかが分かります。並行エージェント環境では、自分が関与していない変更の理由を後から辿れることが地味に効きます。

残る課題は、フック側のバグが入ったときに壊れた状態で気付きにくい、という点です。scripts/install-hooks.shの冪等性と、CI側でのbash -n scripts/pre-commitのような構文チェックは、別途入れておいたほうが安心できそうだと思っています。

関連記事