Personal setup notes + re-setup guide. This documents a PreToolUse hook
added to global Claude Code settings on 2026-07-21 (in a session working on
the bloomwatch repo) to stop a recurring class of mistake: work meant for a
git worktree silently landing in the main checkout instead (or vice versa).
When Claude Code works inside a git worktree (via the native EnterWorktree
tool), the shell's cwd moves into the worktree — but Edit/Write/
NotebookEdit take an explicit absolute file_path argument that is never
inferred from cwd. In practice this meant a stale main-checkout path (typed
from habit, hardcoded in a plan file handed to a subagent, or leaked in via a
cd <main-checkout> && ... chain used for a legitimate read-only lookup)
would silently commit or edit files in the wrong checkout. This happened
five separate times across past sessions in the bloomwatch project
before this guard was built — each one only caught after the fact by manually
diffing git status/git log in both locations.
The fix moves enforcement from "the model has to remember" to a mechanical check that runs on every relevant tool call, regardless of how long the session has been going or whether a subagent is involved.
A PreToolUse hook script compares git checkout boundaries:
- Edit / Write / NotebookEdit: resolves
git rev-parse --show-toplevelfor the target file's directory and for the session's own cwd. If they differ, the call is denied — this catches "I'm in a worktree but this path points at the main checkout" (and the reverse). - Bash: if the session is inside a worktree, and the command both
references the main checkout's path and contains a git-mutating verb
(
commit,push,add,merge,rebase), the call is denied. This targets the specificcd /path/to/main-checkout && git commit ...leak pattern, while still allowing read-only lookups likecd /path/to/main-checkout && ls some-gitignored-dir/.
It's generic — no project-specific paths are hardcoded, it works for any repo
using worktrees this way, so it was installed at user scope
(~/.claude/settings.json), not inside any one project's .claude/settings.json.
Known caveat (confirmed live): hooks are loaded at session start, not
hot-reloaded from a mid-session settings edit. If you add or change this hook
while a session is already running, that session won't pick it up — start a
fresh session (or run /hooks) afterward.
#!/bin/bash
# PreToolUse guard: block Edit/Write/NotebookEdit/Bash calls that would land
# in a different git checkout than the current session (main checkout vs. a
# worktree, or vice versa).
set -euo pipefail
input=$(cat)
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // empty')
session_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
[ -z "$session_root" ] && exit 0
deny() {
reason="$1"
jq -n --arg reason "$reason" \
'{hookSpecificOutput:{hookEventName:"PreToolUse",permissionDecision:"deny",permissionDecisionReason:$reason}}'
exit 0
}
case "$tool_name" in
Edit|Write|NotebookEdit)
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')
[ -z "$file_path" ] && exit 0
file_dir=$(dirname "$file_path")
file_root=$(git -C "$file_dir" rev-parse --show-toplevel 2>/dev/null || true)
if [ -n "$file_root" ] && [ "$file_root" != "$session_root" ]; then
deny "This $tool_name targets $file_path, which resolves to a different git checkout ($file_root) than the current session's checkout ($session_root). If you're working in a worktree, use a path inside $session_root instead."
fi
;;
Bash)
command=$(printf '%s' "$input" | jq -r '.tool_input.command // empty')
[ -z "$command" ] && exit 0
main_root=$(git worktree list 2>/dev/null | head -1 | awk '{print $1}')
if [ -n "$main_root" ] && [ "$session_root" != "$main_root" ]; then
if printf '%s' "$command" | grep -qF "$main_root" && printf '%s' "$command" | grep -qE 'git (commit|push|add|merge|rebase)'; then
deny "This Bash command references the main checkout ($main_root) and also runs a git-mutating command, while the session is in a worktree ($session_root). This is the exact pattern that has landed worktree commits on main before. Use 'git -C \"$main_root\"' for read-only lookups only, and run mutating git commands with no cd/path prefix so they target $session_root."
fi
fi
;;
esac
exit 0Needs to be executable: chmod +x ~/.claude/hooks/worktree-path-guard.sh.
Merge this into the existing settings file (don't replace the whole file —
this repo's ~/.claude/settings.json also carries personal permissions,
env, attribution, worktree.baseRef, etc. that aren't specific to this
guard):
{
"hooks": {
"PreToolUse": [
{
"matcher": "Edit|Write|NotebookEdit",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/worktree-path-guard.sh",
"statusMessage": "Checking worktree path..."
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "~/.claude/hooks/worktree-path-guard.sh",
"statusMessage": "Checking worktree path..."
}
]
}
]
}
}- Reinstall Claude Code, sign in.
- Recreate
~/.claude/hooks/worktree-path-guard.shwith the script content above, thenchmod +xit. - Merge the
hooks.PreToolUseblock above into~/.claude/settings.json(create the file with just that block if starting fresh — add back your other personal settings — permissions allowlist,attribution, etc. — separately, they aren't captured here since they're unrelated to this guard). - Start a new session (mandatory — see the caveat above) and sanity-check it
fires: from inside a git repo with at least one worktree
(
git worktree add ...or Claude Code'sEnterWorktree), try an Edit whose path points outside the current checkout — it should be denied with a message naming both checkout roots. jq -e '.hooks.PreToolUse[] | select(.matcher == "Bash") | .hooks[] | select(.type == "command") | .command' ~/.claude/settings.jsonshould exit 0 and print the script path — quick syntax/schema sanity check if something seems off.
- Project-level (
.claude/settings.json, committed) instead of user-level: considered, since it would auto-protect any fresh clone of a given repo. Rejected in favor of user scope because the check logic is fully repo-agnostic (justgit rev-parse/git worktree list, no hardcoded paths) — user scope protects every repo worked in, not just one. - Warn instead of block: rejected. The failure mode had already recurred five times under a "remember to be careful" regime; a soft warning doesn't change agent behavior mid-session the way a hard deny does.
- Bash guard blocking on any main-checkout path reference: narrowed to only fire when combined with a git-mutating verb, specifically to avoid blocking legitimate read-only lookups against main-checkout-only data (e.g. a gitignored scratch/cache directory that only exists there).