Stops Claude from editing files or running mutating git commands on main or master, and points it at a worktree instead.

Stops Claude from editing files, or running mutating git commands, while the repo is on a protected branch (default main, master). You get one question; Claude gets an instruction to work in a worktree instead.
A tool.call guard that asks first ($.ui.ask) and fails closed. State is a module-level Set; git is read through $.process.run.
Headless claude -p in a throwaway repo on main, asked to write notes.txt. -p has no one to ask, so the ask rejects and the write was refused, with no file created. Claude relayed:
Branch Guard: the repo is on protected branch "main", so this change was not made. Do not edit files or commit on main. Create a worktree on a new branch and work there: git worktree add ../bg-repo-<slug> -b cc-feature/<slug> (use cc-fix/ or cc-ui/ instead of cc-feature/ when it fits), where <slug> is a short kebab-case name for the task. Then make this change in that worktree. If the user explicitly wants to edit on main, ask them to approve it.
In an interactive session the question is "You're on main. Claude is about to edit files here." with Create worktree (recommended), Edit anyway, Refuse. Not seen live here: the interactive dialog is covered by tests only.
One tool.call hook on Write, Edit, MultiEdit and Bash. For Bash it only acts when the command matches git commit|push|merge|rebase|reset|cherry-pick|revert|am. It then runs git rev-parse --show-toplevel (no repo: allow) and git branch --show-current in $.session.cwd(). If the branch is protected and Edit anyway was not already chosen for that repo and branch this session, it asks. Edit anyway allows and is remembered; every other answer, a dismissed dialog, or -p denies. The hook never creates the worktree.
Setting protected_branches (comma-separated, default main,master) is prompted for when the mod is enabled and editable in /config.
What claude plugin validate reports:
hooks: tool.call{tool=Write|Edit|MultiEdit|Bash}
calls: $.process.run, $.session.cwd, $.ui.ask
Requires Claude Code 2.1.289 or later and git on PATH.
claude --plugin-dir ./mods/branch-guard # one session
claude plugin marketplace add justmalhar/awesome-claude-mods
claude plugin install branch-guard@awesome-claude-mods --scope user
sh -c "git commit", command git, git --git-dir=x commit, and scripts that call git. It can also match the words inside a quoted string./tmp vs /private/tmp) can be misjudged.AbovePrompt band use.None.
hooks/branch-guard.mjs 84 lines1// Branch Guard: on a protected branch (default main, master), asks before Claude
2// edits files or runs mutating git, and otherwise tells it to use a worktree.
3//
4// tool.call (Write, Edit, MultiEdit, Bash): read the branch with git, and if it
5// is protected ask once. "Edit anyway" is remembered for repo+branch until the
6// mod reloads. Anything else, or no one to ask (-p, dismissed), denies with the
7// worktree instruction. The hook never creates the worktree itself.
8//
9// The host reads `on(...)` and `$.noun.method(...)` from source, so they are
10// spelled literally.
11
12const FILE_TOOLS = new Set(["Write", "Edit", "MultiEdit"]);
13// ponytail: regex on the command text. Misses `git` behind a shell alias, `sh -c "git commit"`,
14// `command git`, `git --git-dir=x commit`, and scripts that call git. Upgrade: parse argv properly.
15const MUTATING_GIT = /\bgit\s+(?:-[Cc]\s+\S+\s+|-\S+\s+)*(?:commit|push|merge|rebase|reset|cherry-pick|revert|am)\b/;
16const DEFAULT_PROTECTED = "main,master";
17const ALLOW = "Edit anyway";
18const CREATE = "Create worktree (recommended)";
19const REFUSE = "Refuse";
20
21// ponytail: module-level, so it resets on reload and is not shared across sessions. Use $.store to persist.
22const allowed = new Set();
23
24export function register(on, options) {
25 const protectedBranches = String(options?.protected_branches ?? DEFAULT_PROTECTED)
26 .split(",")
27 .map((b) => b.trim())
28 .filter((b) => b !== "");
29
30 on("tool.call", { tool: ["Write", "Edit", "MultiEdit", "Bash"] }, async ($, e, next) => {
31 const isFile = FILE_TOOLS.has(e.tool);
32 if (!isFile && !MUTATING_GIT.test(String(e.command ?? ""))) {
33 return next(e);
34 }
35 const cwd = await $.session.cwd();
36 const top = await $.process.run(["git", "rev-parse", "--show-toplevel"], { cwd });
37 if (top.exitCode !== 0) {
38 return next(e); // not a git repo
39 }
40 const root = top.stdout.trim();
41 // ponytail: only the session repo is checked. A file in another repo, or reached through a
42 // symlink (/tmp vs /private/tmp), is judged by the session repo's branch or skipped.
43 const path = String(e.file_path ?? "");
44 if (isFile && path.startsWith("/") && !path.startsWith(`${root}/`)) {
45 return next(e); // outside the repo
46 }
47 const head = await $.process.run(["git", "branch", "--show-current"], { cwd });
48 const branch = head.stdout.trim();
49 // Detached HEAD (empty) is not protected: nothing named main or master can be moved from there.
50 if (head.exitCode !== 0 || !protectedBranches.includes(branch)) {
51 return next(e);
52 }
53 const key = `${root}\0${branch}`;
54 if (allowed.has(key)) {
55 return next(e);
56 }
57 let answer = REFUSE;
58 try {
59 answer = await $.ui.ask(`You're on ${branch}. Claude is about to edit files here.`, [CREATE, ALLOW, REFUSE]);
60 } catch {
61 // dismissed, or `claude -p`: no one said yes, so the answer is no
62 }
63 if (answer === ALLOW) {
64 allowed.add(key);
65 return next(e);
66 }
67 return { deny: denyText(root, branch) };
68 }).catch(async () => ({
69 // fail closed: a skipped guard would let the edit through
70 deny: "Branch Guard failed while checking the branch, so this call was not run. Do not retry it unless the user asks you to.",
71 }));
72}
73
74function denyText(root, branch) {
75 const repo = root.split("/").pop();
76 return (
77 `Branch Guard: the repo is on protected branch "${branch}", so this change was not made. ` +
78 `Do not edit files or commit on ${branch}. Create a worktree on a new branch and work there: ` +
79 `git worktree add ../${repo}-<slug> -b cc-feature/<slug> ` +
80 `(use cc-fix/ or cc-ui/ instead of cc-feature/ when it fits), where <slug> is a short kebab-case name for the task. ` +
81 `Then make this change in that worktree. If the user explicitly wants to edit on ${branch}, ask them to approve it.`
82 );
83}
84