Guard the default branch with a PreToolUse hook
A PreToolUse hook on the file-editing tools that exits 2 when you are on the repository's default branch turns "Claude quietly edited main" into a message Claude reads and acts on — but it is a nudge, not a boundary, since Bash writes bypass it and a timed-out hook fails open.
The official documentation describes the mechanism this relies on. The technique itself is community practice.
What this costs you if it is wrong
This is a nudge, not a boundary: it matches Claude's file tools, so any write that goes through Bash (`sed -i`, `cat >`, `git apply`) passes straight through, and treating it as enforcement is the real risk. It also fails open — outside a git repo, on detached HEAD, or when the hook times out, the edit proceeds. It fires on every single edit, so a repo whose default branch legitimately takes direct commits (docs sites, scratch repos) will find it maddening; scope it to project settings rather than `~/.claude`.
You asked for one small change. Claude read three files, edited two, and by the time you looked up, your default branch had uncommitted work in it that nobody planned. Nothing failed. That is the point — the failure is silent, and you only notice it at git status time.
The community pattern for this is a PreToolUse hook on the file-editing tools that checks which branch you are on and refuses the edit when it is the default branch. Claude reads the refusal and branches instead of stalling.
The hook
Put the logic in a script, not inline in settings.json — quoting a shell pipeline inside JSON inside your shell is how this gets subtly wrong.
#!/usr/bin/env bash
# .claude/hooks/branch-guard.sh
set -uo pipefail
git rev-parse --git-dir >/dev/null 2>&1 || exit 0 # not a repo: nothing to guard
current="$(git branch --show-current 2>/dev/null)"
[ -n "$current" ] || exit 0 # detached HEAD, rebase, bisect
default="$(git symbolic-ref --quiet --short refs/remotes/origin/HEAD 2>/dev/null)"
default="${default#origin/}"
default="${default:-main}"
if [ "$current" = "$default" ]; then
echo "You are on '$current', the default branch. Create a feature branch (git switch -c <name>) before editing files." >&2
exit 2
fi
exit 0{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{ "type": "command", "command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/branch-guard.sh", "timeout": 5 }
]
}
]
}
}chmod +x the script. ${CLAUDE_PROJECT_DIR} is the documented placeholder for the project root, so the hook does not care what directory Claude has cd'd into.
Why exit 2, and why plain stderr
Do not wrap the message in an invented JSON envelope. The hooks reference says a PreToolUse hook that blocks by exiting 2 "routes the same way as deny: Claude sees the stderr message as the denial reason." So plain text on stderr is the channel — that sentence is why the guard teaches rather than just obstructs.
Exit 2 specifically. The same page warns that without valid JSON on stdout, exit code 1 is treated as a non-blocking error and the action proceeds, even though 1 is the conventional Unix failure code.
Two things the pattern usually gets wrong
Hardcoding main. Read the default branch from the remote instead. The catch: refs/remotes/origin/HEAD does not always exist. The errors reference documents this for /security-review — single-branch and CI checkouts fetch too narrow a refspec, and a repo with no origin has no such ref at all. Fix it once with git remote set-head origin --auto, and keep the :-main fallback.
MultiEdit in the matcher. The permissions page calls MultiEdit "the legacy MultiEdit tool," and the PreToolUse matcher list names Edit and Write, not MultiEdit. It is harmless in a matcher, but it is not doing work.
The hole this leaves
The matcher covers Claude's file tools. It does not cover Bash, and Claude can write a file with cat > file, sed -i, tee, or git apply. A branch guard on Edit|Write is a nudge against the common accident, not a boundary. The docs say this about hook filtering generally: "use the permission system rather than a hook to enforce a hard allow or deny."
| Approach | Blocks? | Covers Bash writes? | Cost |
|---|---|---|---|
PreToolUse exit 2 branch guard | Yes, per tool call | No | One git spawn per edit |
permissions.deny path rule | Yes, hard | No — path rules, not branch-aware | Cannot express "only on main" |
Session in a git worktree (--worktree) | N/A — you are never on the default branch | Yes, structurally | New checkout per session |
FileChanged hook | No — runs after the write | Yes, "whatever wrote it" | Detection only |
If the real goal is "Claude never works directly on the default branch," worktrees solve it structurally: the docs say new worktrees branch from the repository's default branch, so the session starts on a fresh branch by construction. The hook is the cheap version for people who do not want a second checkout.
The path-guard variant, corrected
The same shape is often used to refuse edits under vendor/, dist/, or generated directories. Use the if field rather than a script for that, and mind two documented traps:
- In an
ifcondition, a single-segment pattern likeEdit(dist/**)matches onlydistin the working directory. For any depth, writeEdit(**/dist/**). This one reversed, so a rule you inherited from an older config may not mean what its author meant: the hooks reference records that before v2.1.214 the single-segment form "matched a directory namedsrcat any depth under the working directory". A pattern written then to cover the whole tree now covers exactly one directory, silently. - If you reach for a
permissions.denyrule instead, write it asEdit(...). The permissions page states that path rules namingWrite,NotebookEdit,Glob, orMultiEditare accepted and then never consulted — Claude Code warns at startup and silently does not enforce them. That paragraph ends "Requires Claude Code v2.1.210 or later", so on an older build do not expect the startup warning to tell you the rule is inert.Editrules cover all file-editing tools either way.
What is unverified here
The mechanism is solid: every claim above about PreToolUse, exit 2, stderr routing, timeouts, and path-rule syntax comes from the official hooks and permissions references, linked below.
The popularity claim does not. The submission described this as "a converged pattern in public .claude/ configs" and cited exactly one repository's settings.json. One config is an example, not convergence. Nobody has run the hook above end to end, either — it is assembled from documented behavior, not from a transcript.
What would settle both halves: wire the hook into a scratch repo, run claude --debug -p "create scratch.txt" on the default branch and confirm the tool call is denied with your stderr text in the transcript, then repeat on a feature branch and confirm it goes through. For the convergence claim, a GitHub code search across public .claude/settings.json files for git branch --show-current inside a PreToolUse block, with a count, would either support it or kill it.
Checkpoint
-
Why does the guard use
exit 2rather thanexit 1, and where does its message end up?Show answerAnswer
Exit 1 does not block. The hooks reference says that without valid JSON on stdout, exit code 1 is treated as a non-blocking error and the action proceeds — so a guard that exits 1 watches the edit happen, even though 1 is the conventional Unix failure code. Exit 2 blocks the tool call.
The message goes to plain stderr, and that is the channel by design: a
PreToolUsehook blocking by exit 2 "routes the same way asdeny: Claude sees the stderr message as the denial reason." Write the sentence in plain text rather than wrapping it in an invented JSON envelope — that routing is what lets the guard teach ("create a feature branch") instead of just obstructing. -
Your hook shells out to
gitandgithangs. Does the edit get blocked?Show answerAnswer
No — it fails open. A timed-out
commandhook onPreToolUsedoes not block the tool call, and the docs put it bluntly: don't count on a stalled hook to act as a gate. Once the hook times out, the edit proceeds as if the guard had approved it, silently. The default timeout for acommandhook is 600 seconds, which is why the tip setstimeoutexplicitly; five seconds is generous forgit branch --show-current. -
Claude runs
sed -ion a file in your default branch. Does this hook fire?Show answerAnswer
It does not. The matcher is
Edit|Write|NotebookEdit, which covers Claude's file-editing tools only —sed -iis aBashcall and goes straight past it, as docat > file,teeandgit apply. That hole is the point of the tip: this is a nudge against the common accident, not a boundary, and the docs say to use the permission system rather than a hook when you want a hard allow or deny. If the goal really is "never work directly on the default branch," a worktree solves it structurally — new worktrees branch from the repository's default branch, so the session starts on a fresh branch by construction.