|
| 1 | +#!/usr/bin/env bash |
| 2 | +# Blocking PreToolUse hook: the primary checkout is shared by multiple Claude |
| 3 | +# Code agents. Branch-mutating git commands (checkout/switch/merge/rebase/ |
| 4 | +# reset/cherry-pick/stash) run there can switch the branch or rewrite history |
| 5 | +# out from under another agent, and `git stash` silently pockets other agents' |
| 6 | +# uncommitted work. This hook blocks those commands when they target the |
| 7 | +# primary checkout, and allows them when they target a linked `git worktree`, |
| 8 | +# where mutating branch state only affects that worktree. |
| 9 | +# |
| 10 | +# Precision caveat: a PreToolUse hook does NOT run in the Bash tool's |
| 11 | +# persistent working directory — it runs in the project dir. So this guard is |
| 12 | +# "cwd + -C/--git-dir aware, not shell-state aware": if an agent ran |
| 13 | +# `cd <worktree>` in an earlier Bash call and then runs a bare `git switch`, |
| 14 | +# we evaluate against the primary checkout and block it. That is the safe |
| 15 | +# direction to fail (annoying, not dangerous) — use `git -C <worktree> ...`, |
| 16 | +# or `cd <worktree> && git ...` in the same command, to be recognised. |
| 17 | +# |
| 18 | +# Exit 2 = blocking error (Claude sees stderr and can course-correct). |
| 19 | +# Exit 0 = allow. Fails open (exit 0) on any parsing problem — never break |
| 20 | +# the user's shell over a malformed hook payload or a missing `jq`. |
| 21 | + |
| 22 | +set -euo pipefail |
| 23 | + |
| 24 | +payload="$(cat)" |
| 25 | + |
| 26 | +command -v jq >/dev/null 2>&1 || exit 0 |
| 27 | + |
| 28 | +command="$(echo "$payload" | jq -r '.tool_input.command // empty' 2>/dev/null || true)" |
| 29 | +[ -n "$command" ] || exit 0 |
| 30 | + |
| 31 | +# Self-filter: only act on git commands that could mutate branch/worktree |
| 32 | +# state. The settings-level "if" matcher is not honoured by all Claude Code |
| 33 | +# versions, so replicate the filter here. Global options such as `-C <path>`, |
| 34 | +# `--git-dir=<path>` or `-c foo=bar` may sit between `git` and the verb, so |
| 35 | +# allow a run of option words (each optionally followed by its value) first. |
| 36 | +verb_re='\bgit[[:space:]]+(-[^[:space:]]*([[:space:]]+[^-[:space:];&|][^[:space:]]*)?[[:space:]]+)*(checkout|switch|merge|rebase|reset|cherry-pick|stash)\b' |
| 37 | +echo "$command" | grep -qE "$verb_re" || exit 0 |
| 38 | + |
| 39 | +# `git checkout -- <path>` / `git checkout <ref> -- <path>` only restore |
| 40 | +# files from the index/a ref; they never move HEAD or the branch pointer. |
| 41 | +# Allow those explicitly even though they match the "checkout" verb above. |
| 42 | +if echo "$command" | grep -qE '\bcheckout\b[^;&|]*[[:space:]]--[[:space:]]'; then |
| 43 | + exit 0 |
| 44 | +fi |
| 45 | + |
| 46 | +project_dir="${CLAUDE_PROJECT_DIR:-$(pwd)}" |
| 47 | + |
| 48 | +# Work out which directory the git command actually targets, in priority |
| 49 | +# order: an explicit `-C <path>` / `--git-dir=<path>` on the git invocation |
| 50 | +# (the idiomatic way to drive another worktree without cd-ing), else a `cd` |
| 51 | +# earlier in the same command (same idea as pre-commit-check.sh's target |
| 52 | +# check), else the directory this hook runs in. |
| 53 | +target="" |
| 54 | +if echo "$command" | grep -qE '[[:space:]]-C[[:space:]]'; then |
| 55 | + target="$(echo "$command" | grep -oE '[[:space:]]-C[[:space:]]+[^[:space:];&|]+' | head -n1 | sed -E 's/^[[:space:]]*-C[[:space:]]+//')" |
| 56 | +elif echo "$command" | grep -qE '\-\-git-dir[=[:space:]]'; then |
| 57 | + target="$(echo "$command" | grep -oE '\-\-git-dir[=[:space:]][^[:space:];&|]+' | head -n1 | sed -E 's/^--git-dir[=[:space:]]//')" |
| 58 | +elif echo "$command" | grep -qE '(^|[;&|])[[:space:]]*cd[[:space:]]+'; then |
| 59 | + target="$(echo "$command" | grep -oE '(^|[;&|])[[:space:]]*cd[[:space:]]+[^;&|]+' | tail -n1 | sed -E 's/^[;&|]?[[:space:]]*cd[[:space:]]+//')" |
| 60 | +fi |
| 61 | + |
| 62 | +# Strip surrounding quotes/whitespace and expand ~ where we safely can. Do the |
| 63 | +# expansion in a subshell with `set +u` so an unbound variable can't abort us. |
| 64 | +target="$(echo "$target" | sed -E "s/^[[:space:]]*['\"]?//; s/['\"]?[[:space:]]*$//")" |
| 65 | +if [ -n "$target" ]; then |
| 66 | + case "$target" in |
| 67 | + *['$~']*) |
| 68 | + # The path needs shell expansion. Only `~` is safe to expand here: a |
| 69 | + # variable like `-C "$WT"` was set in an earlier Bash call, whose state |
| 70 | + # this hook cannot see, so we cannot tell which checkout it points at. |
| 71 | + expanded="$(set +u; eval "echo $target" 2>/dev/null || true)" |
| 72 | + if [ -n "$expanded" ] && [ -d "$expanded" ]; then |
| 73 | + target="$expanded" |
| 74 | + else |
| 75 | + # Block rather than guess — the safe direction, same as a bare |
| 76 | + # `git switch` whose cwd we cannot see. |
| 77 | + unresolved="$target" |
| 78 | + target="$project_dir" |
| 79 | + fi |
| 80 | + ;; |
| 81 | + esac |
| 82 | +fi |
| 83 | + |
| 84 | +if [ -n "$target" ]; then |
| 85 | + # An absolute target outside the project dir entirely isn't our business. |
| 86 | + case "$target" in |
| 87 | + "$project_dir"|"$project_dir"/*) : ;; |
| 88 | + /*) exit 0 ;; |
| 89 | + esac |
| 90 | +else |
| 91 | + target="$(pwd)" |
| 92 | +fi |
| 93 | + |
| 94 | +[ -d "$target" ] || exit 0 |
| 95 | + |
| 96 | +if [ -n "${unresolved:-}" ]; then |
| 97 | + cat >&2 <<EOF |
| 98 | +Blocked: cannot verify where this git command points. Its target path |
| 99 | +($unresolved) uses a shell variable this hook cannot see — a PreToolUse hook |
| 100 | +does not share your shell's state, so it may well be the shared primary |
| 101 | +checkout, where branch switching/merging clobbers other agents' sessions. |
| 102 | +
|
| 103 | +Re-run it with a literal path, which this hook can check: |
| 104 | + git -C /absolute/path/to/worktree <verb> ... |
| 105 | +EOF |
| 106 | + exit 2 |
| 107 | +fi |
| 108 | + |
| 109 | +# Only guard when the target is inside a git repo at all. |
| 110 | +git_dir="$(git -C "$target" rev-parse --git-dir 2>/dev/null || true)" |
| 111 | +common_dir="$(git -C "$target" rev-parse --git-common-dir 2>/dev/null || true)" |
| 112 | +{ [ -n "$git_dir" ] && [ -n "$common_dir" ]; } || exit 0 |
| 113 | + |
| 114 | +# Normalize to absolute paths (git may return them relative to the target). |
| 115 | +abspath() { |
| 116 | + local p="$1" |
| 117 | + case "$p" in |
| 118 | + /*) : ;; |
| 119 | + *) p="$target/$p" ;; |
| 120 | + esac |
| 121 | + (cd "$(dirname "$p")" 2>/dev/null && echo "$(pwd)/$(basename "$p")") || echo "$p" |
| 122 | +} |
| 123 | +git_dir_abs="$(abspath "$git_dir")" |
| 124 | +common_dir_abs="$(abspath "$common_dir")" |
| 125 | + |
| 126 | +# In a linked worktree, --git-dir (.../.git/worktrees/<name>) differs from |
| 127 | +# --git-common-dir (.../.git). In the primary checkout they're the same. |
| 128 | +# Mutating git state is safe in a linked worktree, so allow it there. |
| 129 | +if [ "$git_dir_abs" != "$common_dir_abs" ]; then |
| 130 | + exit 0 |
| 131 | +fi |
| 132 | + |
| 133 | +cat >&2 <<'EOF' |
| 134 | +Blocked: this git command targets the shared primary checkout — other agents |
| 135 | +may be working there right now. Do not switch branches, merge, rebase, reset |
| 136 | +or cherry-pick in it: that yanks the branch out from under another agent's |
| 137 | +session and litters the tree with leftovers. `git stash` is blocked for the |
| 138 | +same reason — in a shared checkout it silently pockets *other agents'* |
| 139 | +uncommitted work. |
| 140 | +
|
| 141 | +Create your own worktree and work there instead, e.g.: |
| 142 | + git worktree add --detach <scratchpad>/<name> <ref> |
| 143 | + # or, for a new branch: |
| 144 | + git worktree add -b <branch> <dir> <base> |
| 145 | +
|
| 146 | +Then target it explicitly — both of these forms are recognised: |
| 147 | + git -C <worktree> switch <branch> |
| 148 | + cd <worktree> && git merge main |
| 149 | +
|
| 150 | +Note: this hook cannot see your shell's persistent working directory, so a |
| 151 | +bare `git switch` is always judged against the primary checkout even if you |
| 152 | +cd'd into a worktree in an earlier call. Use `git -C <worktree> ...`. |
| 153 | +
|
| 154 | +Read-only git commands (status, log, diff, show, fetch, worktree, rev-parse, |
| 155 | +branch listing, etc.) and file-only `git checkout -- <path>` restores are |
| 156 | +fine to run here. |
| 157 | +EOF |
| 158 | +exit 2 |
0 commit comments