GitHub Proなしでmainブランチを守る — ローカルgit hooksでBranch Protectionを代替する
AI主体で執筆個人開発のPrivateリポジトリにCursorエージェントを並行で走らせていたら、mainに.cursor/rules/やtodo.mdの微修正が直接コミットされて、「誰が・いつ・なぜ入れたのか」を後から辿れないコミットが積み重なりました。GitHubのBranch Protectionで止めようと設定画面を開いたら、Freeプランではそもそも使えないと表示されて詰みました。サーバー側で止められないので、ローカルのpre-commit・pre-pushとCursor Rulesの条文を組み合わせて、mainへの直コミット/直pushを封じた設計を書き残します。
姉妹記事の Cursorエージェントの「宙ぶらりん作業」を防ぐ3条 が「完了の定義」でルールを守らせる側だったのに対し、こちらはpre-commitとpre-pushでmainの書き換え自体を物理的に弾きます。
Freeプランで何ができないか
GitHubのBranch ProtectionとRulesetsは、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段目:
pre-commitで、mainにいるときの特定ファイル以外のコミットを拒否する - 2段目:
pre-pushで、refs/heads/mainへの直pushを拒否する - 3段目:
.cursor/rules/で「--no-verifyは使うな」「mainで作業を始めるな」を明文化する
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_refがrefs/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-verifyやgit 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 --fillとgh 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のような構文チェックは、別途入れておいたほうが安心できそうだと思っています。