CCLearn

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.

docs corroborate

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.

bash
#!/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
json
{
  "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."

ApproachBlocks?Covers Bash writes?Cost
PreToolUse exit 2 branch guardYes, per tool callNoOne git spawn per edit
permissions.deny path ruleYes, hardNo — path rules, not branch-awareCannot express "only on main"
Session in a git worktree (--worktree)N/A — you are never on the default branchYes, structurallyNew checkout per session
FileChanged hookNo — runs after the writeYes, "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 if condition, a single-segment pattern like Edit(dist/**) matches only dist in the working directory. For any depth, write Edit(**/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 named src at 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.deny rule instead, write it as Edit(...). The permissions page states that path rules naming Write, NotebookEdit, Glob, or MultiEdit are 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. Edit rules 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 2 rather than exit 1, and where does its message end up?

    Show answer

    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 PreToolUse hook blocking by exit 2 "routes the same way as deny: 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 git and git hangs. Does the edit get blocked?

    Show answer

    No — it fails open. A timed-out command hook on PreToolUse does 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 a command hook is 600 seconds, which is why the tip sets timeout explicitly; five seconds is generous for git branch --show-current.

  • Claude runs sed -i on a file in your default branch. Does this hook fire?

    Show answer

    It does not. The matcher is Edit|Write|NotebookEdit, which covers Claude's file-editing tools only — sed -i is a Bash call and goes straight past it, as do cat > file, tee and git 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.

Where this came from

Official pages that back this