@whyshouldaicareone AI disaster a day, and the fix

Lock main so no agent can push to it

Claude pushed to main at 2:47 am. This guide: one GitHub rule that blocks direct pushes, plus two local seatbelts.

The fix: a branch ruleset

On GitHub open the repo, click Settings, then under Code, planning, and automation click Rulesets. Click New ruleset > New branch ruleset. Name it and set enforcement to Active (the default is Disabled). Add a target that includes the default branch. Keep Restrict deletions and Block force pushes ticked (both are on by default) and tick Require a pull request before merging. Leave the Bypass list empty, then click Create. Required approvals: a pull request's author cannot approve it. Working alone? Set 0. The pull request is still required, so a direct push is rejected. On a team, set 1 or more.

Same thing from the terminal

Needs the GitHub CLI, logged in, run inside the clone (bash or zsh). The special value ~DEFAULT_BRANCH targets your default branch. On a team, change required_approving_review_count to 1. In PowerShell, save the JSON to a file, quote the URL and use --input file.json.

copy and paste
gh api repos/{owner}/{repo}/rulesets --input - <<'EOF'
{"name":"lock-main","target":"branch","enforcement":"active",
 "conditions":{"ref_name":{"include":["~DEFAULT_BRANCH"],"exclude":[]}},
 "rules":[{"type":"deletion"},{"type":"non_fast_forward"},
  {"type":"pull_request","parameters":{
   "required_approving_review_count":0,
   "dismiss_stale_reviews_on_push":true,
   "require_code_owner_review":false,
   "require_last_push_approval":false,
   "required_review_thread_resolution":false}}]}
EOF

Check it worked

In a clone, commit anything on main and push it. The push must be rejected. A force push must be rejected too. The only way in is a branch and a pull request.

copy and paste
git push origin main

Pro tip: deny it inside Claude Code

Add a deny rule to .claude/settings.json. Deny rules are checked before allow rules, and no allow rule can override them. This blocks every git push, branches included, so use it if you push yourself. Claude Code matches the command text, so a push written as git -C . push is not matched. The GitHub rule is the real lock.

copy and paste
{
  "permissions": {
    "deny": ["Bash(git push *)"]
  }
}

Pro tip: a local pre-push hook

Save as .git/hooks/pre-push and make it executable (hooks without the executable bit are ignored). Git passes one line per ref on stdin. A non-zero exit aborts the push. Replace main with your branch name if needed.

copy and paste
#!/bin/sh
while read l_ref l_sha r_ref r_sha; do
  if [ "$r_ref" = "refs/heads/main" ]; then
    echo "Direct push to main is blocked." >&2
    exit 1
  fi
done

Common mistake

Leaving the door open for admins. A classic branch protection rule does not apply to admins by default; turn on Do not allow bypassing the above settings. In a ruleset, add people to the Bypass list only on purpose. Also: git push --no-verify skips the pre-push hook completely, so the hook is a seatbelt, not a lock.

Official docs

One fix of twenty: the whole Claude Code survival kit.

Follow @whyshouldaicare

A new disaster and its fix every day. Comment the word under a reel to get its guide.