worktree-guard.sh 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. #!/bin/bash
  2. # hooks/worktree-guard.sh
  3. # PreToolUse hook (matcher: Bash) — enforce rules/worktree-boundaries.md.
  4. #
  5. # `.claude/worktrees/` is the private state of whichever agent, session, or
  6. # human spawned it. Worktrees that look orphaned often aren't (active sessions,
  7. # uncommitted work). This hook catches the command shapes that have actually
  8. # caused damage (2026-04-19 incident: `git add -A` staged worktree gitlinks,
  9. # then `rm -rf .claude/worktrees/` deleted live agent state):
  10. #
  11. # 1. rm targeting a path containing .claude/worktrees
  12. # 2. git worktree remove <path containing .claude/worktrees>
  13. # 3. git worktree prune — when the cwd's repo has a .claude/worktrees dir
  14. # 4. git rm targeting .claude/worktrees paths
  15. # 5. git add -A / --all / . — when cwd has a .claude/worktrees dir
  16. # 6. git clean -ff (double-force) — when cwd has a .claude/worktrees dir.
  17. # Single `-f` git clean SKIPS nested worktrees ("Skipping repository …");
  18. # only a DOUBLE force (`-ff` / `--force --force`) deletes them — taking any
  19. # UNCOMMITTED lane work with it (committed work survives in the object store).
  20. #
  21. # On (3) and (5) we use directory existence ([ -d cwd/.claude/worktrees ]) as
  22. # the cheap proxy. True gitlink detection would need `git status --porcelain`
  23. # parsing — a subprocess against a possibly-large repo on EVERY Bash call, and
  24. # gitlinks only show once recorded — too slow/unreliable for a hook, so we
  25. # accept slight over-warning (advisory anyway).
  26. #
  27. # Own-worktree exemption: a session whose payload cwd is INSIDE
  28. # .claude/worktrees/<name> is operating in its own worktree and may touch
  29. # itself — it is exempted entirely (tradeoff: such a session is also not
  30. # guarded against touching sibling worktrees; acceptable, the rule targets
  31. # outside sessions doing "cleanup").
  32. #
  33. # Stdin: PreToolUse JSON ({tool_input:{command}, cwd, …}); $1 = command fallback.
  34. #
  35. # Behaviour (silent on clean):
  36. # no violation → no output, exit 0
  37. # violation → ADVISORY warning naming the rule, exit 0
  38. # + WORKTREE_GUARD_BLOCK=1 → HARD DENY: stderr + exit 2 (tool call prevented)
  39. set -uo pipefail
  40. CMD="${1:-}"; CWD=""
  41. if [[ -z "$CMD" && ! -t 0 ]]; then
  42. RAW="$(cat 2>/dev/null)"
  43. if [[ -n "${RAW:-}" ]] && command -v jq >/dev/null 2>&1; then
  44. CMD="$(printf '%s' "$RAW" | jq -r '.tool_input.command // empty' 2>/dev/null)"
  45. CWD="$(printf '%s' "$RAW" | jq -r '.cwd // empty' 2>/dev/null)"
  46. fi
  47. fi
  48. [[ -z "$CMD" ]] && exit 0
  49. [[ -z "$CWD" ]] && CWD="${CLAUDE_PROJECT_DIR:-$PWD}"
  50. # Own-worktree session → exempt (see header).
  51. case "$CWD" in
  52. *.claude/worktrees/*|*.claude\\worktrees\\*) exit 0 ;;
  53. esac
  54. WT='\.claude[/\\]worktrees' # matches forward or back slashes
  55. VIOLATION=""
  56. if printf '%s' "$CMD" | grep -qE "\bgit\b.*\bworktree[[:space:]]+remove\b[^;|&]*$WT"; then
  57. VIOLATION="git worktree remove on .claude/worktrees"
  58. elif printf '%s' "$CMD" | grep -qE "\bgit\b.*\bworktree[[:space:]]+prune\b" \
  59. && [[ -d "$CWD/.claude/worktrees" ]]; then
  60. VIOLATION="git worktree prune in a repo with .claude/worktrees"
  61. elif printf '%s' "$CMD" | grep -qE "\bgit\b[^;|&]*\brm\b[^;|&]*$WT"; then
  62. VIOLATION="git rm on .claude/worktrees paths"
  63. elif printf '%s' "$CMD" | grep -qE "\brm\b[^;|&]*$WT"; then
  64. VIOLATION="rm targeting .claude/worktrees"
  65. elif printf '%s' "$CMD" | grep -qE '\bgit\b.*\badd[[:space:]]+([^;|&]*[[:space:]])?(-A|--all|\.)([[:space:]]|$|;)' \
  66. && [[ -d "$CWD/.claude/worktrees" ]]; then
  67. VIOLATION="git add -A/. in a repo with .claude/worktrees (may stage worktree gitlinks)"
  68. elif printf '%s' "$CMD" | grep -qE '\bgit\b[^;|&]*\bclean\b' \
  69. && [[ -d "$CWD/.claude/worktrees" ]]; then
  70. # git deletes a NESTED worktree only at force level >= 2 (a single -f safely
  71. # "Skipping repository …"). Force level = count of --force PLUS the count of
  72. # 'f' chars inside short bundles, so -ff, -f -f, -fd -fx and --force --force
  73. # all read as 2, while a safe -fdx reads as 1. Bound to the clean invocation.
  74. SEG=$(printf '%s' "$CMD" | grep -oE '\bclean\b[^;|&]*' | head -1)
  75. # NB: count OCCURRENCES (grep -o | wc -l), not matching lines (grep -c counts
  76. # lines, so two --force on one line would wrongly read as 1).
  77. NLONG=$(printf '%s' "$SEG" | grep -oE -- '--force' | wc -l | tr -d ' ')
  78. NSHORT=$(printf '%s' "$SEG" | grep -oE -- '(^|[[:space:]])-[A-Za-z]+' | grep -v -- '--' | tr -cd 'fF' | wc -c | tr -d ' ')
  79. if [[ $(( ${NLONG:-0} + ${NSHORT:-0} )) -ge 2 ]]; then
  80. VIOLATION="git clean double-force in a repo with .claude/worktrees (force-removes live lanes + their uncommitted work)"
  81. fi
  82. fi
  83. [[ -z "$VIOLATION" ]] && exit 0 # clean → silent
  84. if [[ "${WORKTREE_GUARD_BLOCK:-0}" == "1" ]]; then
  85. {
  86. echo "WORKTREE GUARD: blocked — $VIOLATION."
  87. echo "rules/worktree-boundaries.md: worktrees are another session's private state."
  88. echo "Use explicit file paths with git add; never delete .claude/worktrees without"
  89. echo "asking the user. Unset WORKTREE_GUARD_BLOCK only after they confirm."
  90. } >&2
  91. exit 2
  92. fi
  93. echo "WORKTREE GUARD: $VIOLATION."
  94. echo "rules/worktree-boundaries.md: .claude/worktrees/ is another session's private"
  95. echo "state — it may look orphaned and isn't. Use explicit paths with git add; ask"
  96. echo "the user before removing any worktree. (WORKTREE_GUARD_BLOCK=1 to hard-deny.)"
  97. exit 0