Skip to content

Instantly share code, notes, and snippets.

@branneman
Created July 21, 2026 22:09
Show Gist options
  • Select an option

  • Save branneman/6f4620a14b9b2bad17862efa1dcc463e to your computer and use it in GitHub Desktop.

Select an option

Save branneman/6f4620a14b9b2bad17862efa1dcc463e to your computer and use it in GitHub Desktop.
Claude Code worktree path guard

Claude Code worktree path guard

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).

The problem this solves

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.

What it does

A PreToolUse hook script compares git checkout boundaries:

  • Edit / Write / NotebookEdit: resolves git rev-parse --show-toplevel for 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 specific cd /path/to/main-checkout && git commit ... leak pattern, while still allowing read-only lookups like cd /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.

Files

~/.claude/hooks/worktree-path-guard.sh

#!/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 0

Needs to be executable: chmod +x ~/.claude/hooks/worktree-path-guard.sh.

~/.claude/settings.jsonhooks block

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..."
          }
        ]
      }
    ]
  }
}

Re-setup on a new machine / lost homedir

  1. Reinstall Claude Code, sign in.
  2. Recreate ~/.claude/hooks/worktree-path-guard.sh with the script content above, then chmod +x it.
  3. Merge the hooks.PreToolUse block 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).
  4. 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's EnterWorktree), try an Edit whose path points outside the current checkout — it should be denied with a message naming both checkout roots.
  5. jq -e '.hooks.PreToolUse[] | select(.matcher == "Bash") | .hooks[] | select(.type == "command") | .command' ~/.claude/settings.json should exit 0 and print the script path — quick syntax/schema sanity check if something seems off.

Design notes / things considered and rejected

  • 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 (just git 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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment