I use Claude Code as an active collaborator on a few of my repos, including this site. That’s convenient, but it also means an AI agent has the same git push access I do. I wanted a rule: Claude branches off main and opens an MR. It never commits or pushes to main directly. Discovering that I have done all my changes to my protected main only when I try to push my changes is a little too late for my taste.
GitLab branch protection - a good practice
When running git push, regardless of the credential or key being used, it is a good practice to have a protected branch when using pipelines. Protecting main server-side provides the gate for automated testing and validation that I was looking for. Not every project wants this, but in my case, it helps me protect my projects from AI robot runaway.
Note: Gitlab is moving “protected branches” to “branch rules”, but the concept is the same.
The solution: a PreToolUse hook
Claude Code supports hooks: commands that run at specific points in its lifecycle, including right before it executes a tool call. A PreToolUse hook on the Bash tool can inspect the command Claude is about to run and block it before it happens.
The hook:
- Only fires on
Bashtool calls Claude Code itself makes. My own terminal or direct editing through VSCode is unchanged. - Lets read-only/status git commands through without interference.
- Blocks
git commitandgit pushspecifically when the current branch ismain. - Returns a clear message telling Claude to branch off first.
.claude/hooks/block-main-git.sh
#!/bin/bash
# Blocks Claude from running `git commit`/`git push` via the Bash tool while
# on the `main` branch. Only intercepts this Claude Code session's tool
# calls — the user's own terminal is untouched.
cmd=$(jq -r '.tool_input.command // empty')
if echo "$cmd" | grep -qE '(^|[;&|]|&&)\s*git\s+(commit|push)\b'; then
branch=$(git rev-parse --abbrev-ref HEAD 2>/dev/null)
if [ "$branch" = "main" ]; then
jq -n '{
hookSpecificOutput: {
hookEventName: "PreToolUse",
permissionDecision: "deny",
permissionDecisionReason: "Blocked: git commit/push on main. Create or checkout a feature branch first (e.g. `git checkout -b feat/<slug>`), then retry."
}
}'
exit 0
fi
fi
exit 0
Claude Code hooks receive the tool call as JSON on stdin; jq -r '.tool_input.command' pulls out the shell command that’s about to run. If it matches git commit/git push and the current branch is main, the script prints a permissionDecision: "deny" response and Claude Code blocks the call, surfacing the permissionDecisionReason back to Claude.
Make it executable:
chmod +x .claude/hooks/block-main-git.sh
.claude/settings.json
This wires the script in as a PreToolUse hook on the Bash tool:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/hooks/block-main-git.sh"
}
]
}
]
}
}
Both files live in the project’s .claude/ directory and get committed to the repo, which is what makes this a project-level guardrail rather than a personal preference.
Testing it
Piping a synthetic tool call straight into the script is the fastest way to check the logic without actually risking a commit:
echo '{"tool_input":{"command":"git commit -m \"test\""}}' | .claude/hooks/block-main-git.sh
On main, that should print the deny JSON. On any other branch, or for a command like git status, it should print nothing, meaning the call is allowed through.
The one gotcha: it needs a fresh session
Claude Code watches directories for settings changes, but that watch is set up on session start. If .claude/settings.json didn’t exist yet when the session began, creating it mid-session isn’t enough; the hook won’t actually fire until Claude Code is restarted. A /hooks reload wasn’t sufficient in my case either; a full restart was what picked it up.
Once restarted, it works exactly as intended: attempting git commit on main gets denied outright, with a message telling Claude to branch off first. My own terminal, using the same git credentials, is completely unaffected.
Rounding it out with CLAUDE.md
The hook stops the action, but documenting the expected workflow matters too, so Claude (and future-me) knows why it’s there:
## Branch strategy
`main` triggers deploys on every push, so treat it as deploy-on-push.
- Before editing anything, branch off `main` (e.g. `feat/<slug>`, `fix/<slug>`).
- Push the branch and open an MR — never commit or push to `main` directly.
- A project-level hook (`.claude/hooks/block-main-git.sh`, wired in
`.claude/settings.json`) blocks `git commit`/`git push` while on `main`
for this Claude Code session. It only guards Claude Code tool calls,
not the user's own terminal.
Caveats
This is a guardrail enforcing habit and avoiding mistakes, not a security boundary. A couple of things it doesn’t cover:
- Anyone with write access to the repo (including Claude, if asked directly) could edit or delete
.claude/settings.jsonto remove the hook. - It only protects sessions run through Claude Code. Other tools, or a human at a terminal, aren’t affected. In my case, that was the point.
My use case is narrow: keeping an AI collaborator from directly editing main. That’s the right amount of enforcement I needed.
Related approaches
This hook isn’t the only way to guard main. A few established options worth knowing about, depending on what you’re actually trying to enforce:
-
VS Code’s built-in
git.branchProtectionsetting — prompts or blocks a commit on a protected branch directly from the Source Control panel. Editor-level, per-user, and bypassed by anything outside VS Code (a terminal, another editor, an agent). - Husky and the pre-commit framework — install real git hooks that are shareable and versioned with the repo, so every contributor and every client gets them, not just one tool.
-
core.hooksPath: git’s own native mechanism for pointing at a committed hooks directory, no extra dependency required. Covers every git client on the machine, not just Claude Code, unlike the tool-specific hook above. I’ve written up a practical implementation of this for this site: Blocking Commits and Pushes to Main With Native Git Hooks . - GitLab protected branches / branch rules and GitHub rulesets — server-side enforcement, independent of any local tool. As covered above, these can’t distinguish a human from an agent sharing the same credentials; that requires giving the agent its own identity.