worktree-survey.sh 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192
  1. #!/bin/bash
  2. # worktree-survey.sh - Read-only worktree survey + triage
  3. #
  4. # Enumerates registered worktrees, cross-references with .claude/worktrees/
  5. # filesystem entries, classifies each, and emits a table + summary.
  6. #
  7. # NEVER mutates. Respects rules/worktree-boundaries.md.
  8. #
  9. # Usage:
  10. # bash worktree-survey.sh # survey current repo
  11. # bash worktree-survey.sh <repo-path> # survey explicit repo
  12. #
  13. # Exit codes:
  14. # 0 All worktrees healthy (no ghosts, orphans, or prunable)
  15. # 1 Attention needed (ghosts, orphans, or prunable candidates found)
  16. # 2 Not a git repo
  17. set -u
  18. REPO="${1:-$PWD}"
  19. if ! git -C "$REPO" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
  20. echo "not-a-repo: $REPO"
  21. exit 2
  22. fi
  23. REPO_ROOT=$(git -C "$REPO" rev-parse --show-toplevel)
  24. cd "$REPO_ROOT" || exit 2
  25. # Detect trunk branch
  26. TRUNK="main"
  27. if ! git rev-parse --verify main >/dev/null 2>&1; then
  28. if git rev-parse --verify master >/dev/null 2>&1; then
  29. TRUNK="master"
  30. fi
  31. fi
  32. # Parse `git worktree list --porcelain` into TSV: path \t branch \t head
  33. TMP_REG=$(mktemp)
  34. git worktree list --porcelain 2>/dev/null | awk '
  35. /^worktree / { p=substr($0,10); b=""; h=""; next }
  36. /^HEAD / { h=substr($0,6); next }
  37. /^branch / { b=substr($0, 19); next } # skip "branch refs/heads/" (18 chars)
  38. /^detached/ { b="(detached)"; next }
  39. /^$/ { if (p != "") { print p"\t"b"\t"h } p=""; b=""; h=""; next }
  40. END { if (p != "") print p"\t"b"\t"h }
  41. ' > "$TMP_REG"
  42. WT_COUNT=$(wc -l < "$TMP_REG" | tr -d ' ')
  43. # Enumerate filesystem entries in .claude/worktrees/ (canonical absolute paths)
  44. TMP_FS=$(mktemp)
  45. if [ -d .claude/worktrees ]; then
  46. while IFS= read -r d; do
  47. [ -z "$d" ] && continue
  48. (cd "$d" 2>/dev/null && pwd -P)
  49. done < <(find .claude/worktrees -maxdepth 1 -mindepth 1 -type d 2>/dev/null) > "$TMP_FS"
  50. fi
  51. FS_COUNT=$(wc -l < "$TMP_FS" | tr -d ' ')
  52. # Counters
  53. GHOSTS=0
  54. PRUNABLE=0
  55. WIP=0
  56. UNPUSHED=0
  57. ORPHANS=0
  58. # Header
  59. printf "%-40s %-20s %-22s %-12s %s\n" "PATH" "BRANCH" "STATE" "AGE" "VERDICT"
  60. echo "──────────────────────────────────────────────────────────────────────────────────────────────"
  61. # --- Process each registered worktree ---
  62. while IFS=$'\t' read -r path branch head; do
  63. [ -z "$path" ] && continue
  64. # Canonical absolute (for orphan comparison)
  65. canon_path=$( (cd "$path" 2>/dev/null && pwd -P) || echo "$path" )
  66. # Display path
  67. if [ "$path" = "$REPO_ROOT" ]; then
  68. disp="<trunk>"
  69. else
  70. disp="${path#$REPO_ROOT/}"
  71. [ ${#disp} -gt 38 ] && disp="...${disp: -35}"
  72. fi
  73. # Ghost: registered but filesystem gone
  74. if [ ! -d "$path" ]; then
  75. printf "%-40s %-20s %-22s %-12s %s\n" "<$disp>" "$branch" "FILESYSTEM GONE" "?" "git worktree prune"
  76. GHOSTS=$((GHOSTS+1))
  77. continue
  78. fi
  79. # Tree state
  80. staged=$(git -C "$path" diff --cached --name-only 2>/dev/null | wc -l | tr -d ' ')
  81. unstaged=$(git -C "$path" diff --name-only 2>/dev/null | wc -l | tr -d ' ')
  82. untracked=$(git -C "$path" ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ')
  83. # Upstream sync
  84. ahead=0
  85. behind=0
  86. if [ "$branch" != "(detached)" ] && git -C "$path" rev-parse '@{u}' >/dev/null 2>&1; then
  87. ahead=$(git -C "$path" rev-list --count '@{u}..HEAD' 2>/dev/null || echo 0)
  88. behind=$(git -C "$path" rev-list --count 'HEAD..@{u}' 2>/dev/null || echo 0)
  89. fi
  90. # Age
  91. age="?"
  92. if [ -n "$head" ]; then
  93. age=$(git log -1 --format='%ar' "$head" 2>/dev/null | sed 's/ ago//')
  94. fi
  95. # Merged into trunk?
  96. merged=false
  97. if [ -n "$head" ] && [ "$branch" != "$TRUNK" ] && \
  98. git rev-parse --verify "$TRUNK" >/dev/null 2>&1 && \
  99. git merge-base --is-ancestor "$head" "$TRUNK" 2>/dev/null; then
  100. merged=true
  101. fi
  102. # Build state string
  103. state=""
  104. dirty=false
  105. [ "$staged" -gt 0 ] && state="${state} ${staged}s" && dirty=true
  106. [ "$unstaged" -gt 0 ] && state="${state} ${unstaged}u" && dirty=true
  107. [ "$untracked" -gt 0 ] && state="${state} ${untracked}?" && dirty=true
  108. [ "$ahead" -gt 0 ] && state="${state} +${ahead}"
  109. [ "$behind" -gt 0 ] && state="${state} -${behind}"
  110. state="${state# }"
  111. [ -z "$state" ] && state="clean"
  112. [ "$merged" = true ] && state="$state (merged)"
  113. # Verdict
  114. if [ "$branch" = "$TRUNK" ]; then
  115. verdict="(trunk)"
  116. elif [ "$dirty" = true ]; then
  117. verdict="has WIP"
  118. WIP=$((WIP+1))
  119. elif [ "$ahead" -gt 0 ]; then
  120. verdict="unpushed"
  121. UNPUSHED=$((UNPUSHED+1))
  122. elif [ "$merged" = true ]; then
  123. verdict="PRUNABLE"
  124. PRUNABLE=$((PRUNABLE+1))
  125. else
  126. verdict="in-flight"
  127. fi
  128. printf "%-40s %-20s %-22s %-12s %s\n" "$disp" "$branch" "$state" "$age" "$verdict"
  129. done < "$TMP_REG"
  130. # --- Orphans: filesystem entries in .claude/worktrees/ with no registration ---
  131. while IFS= read -r fs_path; do
  132. [ -z "$fs_path" ] && continue
  133. registered=false
  134. while IFS=$'\t' read -r reg_path _ _; do
  135. reg_canon=$( (cd "$reg_path" 2>/dev/null && pwd -P) || echo "$reg_path" )
  136. if [ "$reg_canon" = "$fs_path" ]; then
  137. registered=true
  138. break
  139. fi
  140. done < "$TMP_REG"
  141. if [ "$registered" = false ]; then
  142. disp="${fs_path#$REPO_ROOT/}"
  143. printf "%-40s %-20s %-22s %-12s %s\n" "$disp" "?" "UNREGISTERED" "?" "manual review (DO NOT touch)"
  144. ORPHANS=$((ORPHANS+1))
  145. fi
  146. done < "$TMP_FS"
  147. rm -f "$TMP_REG" "$TMP_FS"
  148. # --- Summary ---
  149. echo ""
  150. echo "Summary: $WT_COUNT registered / $FS_COUNT in .claude/worktrees / $ORPHANS orphan"
  151. echo " PRUNABLE (merged, clean, linked): $PRUNABLE"
  152. echo " WIP (uncommitted changes): $WIP"
  153. echo " Unpushed (ahead of upstream): $UNPUSHED"
  154. echo " Ghost (registered, FS missing): $GHOSTS"
  155. echo " Orphan (FS exists, unregistered): $ORPHANS ← read-only, never rm without review"
  156. # Legend note (shown only if abbreviations appear in output)
  157. if [ "$WIP" -gt 0 ] || [ "$UNPUSHED" -gt 0 ]; then
  158. echo ""
  159. echo " STATE legend: Ns=staged, Nu=unstaged, N?=untracked, +N=ahead, -N=behind"
  160. fi
  161. # Exit 1 if anything needs attention
  162. if [ "$GHOSTS" -gt 0 ] || [ "$ORPHANS" -gt 0 ] || [ "$PRUNABLE" -gt 0 ]; then
  163. exit 1
  164. fi
  165. exit 0