@codyswann/lisa 2.195.8 → 2.196.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/all/github-rulesets/base.json +64 -0
  2. package/all/github-rulesets/prevent-delete.json +25 -0
  3. package/all/github-rulesets/protect-tags.json +31 -0
  4. package/expo/github-rulesets/playwright.json +38 -0
  5. package/package.json +2 -2
  6. package/plugins/lisa/.claude-plugin/plugin.json +1 -1
  7. package/plugins/lisa/.codex-plugin/plugin.json +1 -1
  8. package/plugins/lisa/.codex-plugin/skills/lisa-setup-github-repo/SKILL.md +66 -0
  9. package/plugins/lisa/.codex-plugin/skills/lisa-setup-github-repo/agents/openai.yaml +4 -0
  10. package/plugins/lisa/commands/lisa/setup/github-repo.md +7 -0
  11. package/plugins/lisa/skills/lisa-setup-github-repo/SKILL.md +66 -0
  12. package/plugins/lisa/skills/lisa-setup-github-repo/agents/openai.yaml +4 -0
  13. package/plugins/lisa-agy/commands/lisa/setup/github-repo.md +7 -0
  14. package/plugins/lisa-agy/plugin.json +1 -1
  15. package/plugins/lisa-agy/skills/lisa-setup-github-repo/SKILL.md +66 -0
  16. package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
  17. package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
  18. package/plugins/lisa-cdk-agy/plugin.json +1 -1
  19. package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
  20. package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
  21. package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
  22. package/plugins/lisa-copilot/commands/lisa/setup/github-repo.md +7 -0
  23. package/plugins/lisa-copilot/skills/lisa-setup-github-repo/SKILL.md +66 -0
  24. package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
  25. package/plugins/lisa-cursor/commands/lisa/setup/github-repo.md +7 -0
  26. package/plugins/lisa-cursor/skills/lisa-setup-github-repo/SKILL.md +66 -0
  27. package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
  28. package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
  29. package/plugins/lisa-expo-agy/plugin.json +1 -1
  30. package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
  31. package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
  32. package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
  33. package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
  34. package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
  35. package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
  36. package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
  37. package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
  38. package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
  39. package/plugins/lisa-nestjs-agy/plugin.json +1 -1
  40. package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
  41. package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
  42. package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
  43. package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
  44. package/plugins/lisa-openclaw-agy/plugin.json +1 -1
  45. package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
  46. package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
  47. package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
  48. package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
  49. package/plugins/lisa-phaser-agy/plugin.json +1 -1
  50. package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
  51. package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
  52. package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
  53. package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
  54. package/plugins/lisa-rails-agy/plugin.json +1 -1
  55. package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
  56. package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
  57. package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
  58. package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
  59. package/plugins/lisa-typescript-agy/plugin.json +1 -1
  60. package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
  61. package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
  62. package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
  63. package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
  64. package/plugins/lisa-wiki-agy/plugin.json +1 -1
  65. package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
  66. package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
  67. package/plugins/src/base/commands/lisa/setup/github-repo.md +7 -0
  68. package/plugins/src/base/skills/lisa-setup-github-repo/SKILL.md +66 -0
  69. package/rails/github-rulesets/quality-checks.json +51 -0
  70. package/scripts/cleanup-github-branches.sh +96 -72
  71. package/scripts/cleanup-local-merged-branches.sh +110 -0
  72. package/scripts/cleanup-worktrees.sh +222 -0
  73. package/scripts/lisa-github-repo-settings.sh +152 -0
  74. package/scripts/lisa-github-repo-setup.sh +73 -0
  75. package/scripts/lisa-github-rulesets.sh +612 -0
  76. package/scripts/setup-deploy-key.sh +62 -2
  77. package/typescript/github-rulesets/quality-checks.json +67 -0
  78. package/typescript/github-rulesets/base.json +0 -33
@@ -1,16 +1,19 @@
1
1
  #!/bin/bash
2
2
  #
3
- # Cleanup GitHub Stale Branches
3
+ # Cleanup GitHub Merged Branches
4
4
  #
5
- # Deletes remote branches that are:
6
- # - NOT protected branches (main, dev, staging)
5
+ # Deletes remote branches that have been MERGED and are safe to remove:
6
+ # - NOT environment branches (main, dev, staging) or the default branch
7
7
  # - NOT associated with an open pull request
8
+ # - Provably merged: either a merged PR exists for the branch, or the
9
+ # branch is fully contained in an environment branch (compare status
10
+ # "behind"/"identical")
8
11
  #
9
- # This helps keep the repository clean by removing stale feature branches
10
- # that have been merged or abandoned.
12
+ # Abandoned-but-unmerged branches are reported but never deleted they may
13
+ # hold real work that was simply never PR'd.
11
14
  #
12
15
  # Usage:
13
- # ./scripts/cleanup-github-branches.sh <owner/repo>
16
+ # ./scripts/cleanup-github-branches.sh <owner/repo> [--dry-run]
14
17
  #
15
18
  # Example:
16
19
  # ./scripts/cleanup-github-branches.sh repo-org/repo-name
@@ -23,7 +26,7 @@
23
26
 
24
27
  set -euo pipefail
25
28
 
26
- # Protected branches that should never be deleted
29
+ # Environment branches that must never be deleted
27
30
  PROTECTED_BRANCHES=("main" "dev" "staging")
28
31
 
29
32
  # Colors for output
@@ -40,32 +43,14 @@ usage() {
40
43
  echo "Arguments:"
41
44
  echo " owner/repo GitHub repository in format 'owner/repo'"
42
45
  echo " --dry-run Show what would be deleted without actually deleting"
43
- echo ""
44
- echo "Example:"
45
- echo " $0 repo-org/repo-name"
46
- echo " $0 repo-org/repo-name --dry-run"
47
46
  exit 1
48
47
  }
49
48
 
50
- log_info() {
51
- echo -e "${BLUE}[INFO]${NC} $1"
52
- }
53
-
54
- log_success() {
55
- echo -e "${GREEN}[SUCCESS]${NC} $1"
56
- }
57
-
58
- log_warning() {
59
- echo -e "${YELLOW}[WARNING]${NC} $1"
60
- }
61
-
62
- log_error() {
63
- echo -e "${RED}[ERROR]${NC} $1"
64
- }
65
-
66
- log_dry_run() {
67
- echo -e "${CYAN}[DRY-RUN]${NC} Would delete: $1"
68
- }
49
+ log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
50
+ log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
51
+ log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
52
+ log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
53
+ log_dry_run() { echo -e "${CYAN}[DRY-RUN]${NC} Would delete: $1"; }
69
54
 
70
55
  is_protected_branch() {
71
56
  local branch="$1"
@@ -77,6 +62,30 @@ is_protected_branch() {
77
62
  return 1
78
63
  }
79
64
 
65
+ # A branch is merged when a merged PR exists for it, or its tip is fully
66
+ # contained in an environment branch.
67
+ is_merged_branch() {
68
+ local branch="$1"
69
+
70
+ local merged_pr
71
+ merged_pr=$(gh pr list --repo "$REPO" --state merged --head "$branch" \
72
+ --limit 1 --json number --jq 'length' 2>/dev/null || echo 0)
73
+ if [[ "$merged_pr" -gt 0 ]]; then
74
+ return 0
75
+ fi
76
+
77
+ for base in "${EXISTING_ENV_BRANCHES[@]}"; do
78
+ local status
79
+ status=$(gh api "repos/$REPO/compare/$base...$branch" \
80
+ --jq '.status' 2>/dev/null || echo "unknown")
81
+ if [[ "$status" == "behind" || "$status" == "identical" ]]; then
82
+ return 0
83
+ fi
84
+ done
85
+
86
+ return 1
87
+ }
88
+
80
89
  # Check arguments
81
90
  if [[ $# -lt 1 ]]; then
82
91
  usage
@@ -92,7 +101,7 @@ if [[ $# -ge 2 && "$2" == "--dry-run" ]]; then
92
101
  fi
93
102
 
94
103
  # Validate repo format
95
- if [[ ! "$REPO" =~ ^[a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+$ ]]; then
104
+ if [[ ! "$REPO" =~ ^[a-zA-Z0-9_.-]+/[a-zA-Z0-9_.-]+$ ]]; then
96
105
  log_error "Invalid repository format. Expected 'owner/repo', got: $REPO"
97
106
  exit 1
98
107
  fi
@@ -106,65 +115,86 @@ fi
106
115
  log_success "GitHub CLI authenticated"
107
116
  echo ""
108
117
 
109
- # Verify repository access
118
+ # Verify repository access + resolve default branch
110
119
  log_info "Verifying access to repository: $REPO"
111
- if ! gh repo view "$REPO" > /dev/null 2>&1; then
120
+ DEFAULT_BRANCH=$(gh repo view "$REPO" --json defaultBranchRef --jq '.defaultBranchRef.name' 2>/dev/null) || {
112
121
  log_error "Cannot access repository: $REPO"
113
- log_error "Please check the repository name and your permissions."
114
122
  exit 1
115
- fi
116
- log_success "Repository access confirmed"
123
+ }
124
+ PROTECTED_BRANCHES+=("$DEFAULT_BRANCH")
125
+ log_success "Repository access confirmed (default branch: $DEFAULT_BRANCH)"
117
126
  echo ""
118
127
 
119
- # Get branches with open PRs
120
- log_info "Fetching open pull requests..."
121
- open_pr_branches=$(gh pr list --repo "$REPO" --state open --json headRefName --jq '.[].headRefName' | sort)
122
- open_pr_count=$(echo "$open_pr_branches" | grep -c . || true)
123
- log_info "Found $open_pr_count branches with open PRs"
124
-
125
128
  # Get all remote branches
126
129
  log_info "Fetching all remote branches..."
127
- all_branches=$(gh api "repos/$REPO/branches" --paginate --jq '.[].name' | sort)
130
+ all_branches=$(gh api "repos/$REPO/branches" --paginate --jq '.[].name' | sort -u)
128
131
  total_branch_count=$(echo "$all_branches" | grep -c . || true)
129
132
  log_info "Found $total_branch_count total branches"
133
+
134
+ # Environment branches that exist in this repo (containment bases)
135
+ EXISTING_ENV_BRANCHES=()
136
+ for env_branch in main dev staging; do
137
+ if echo "$all_branches" | grep -qxF "$env_branch"; then
138
+ EXISTING_ENV_BRANCHES+=("$env_branch")
139
+ fi
140
+ done
141
+
142
+ # Get branches with open PRs
143
+ log_info "Fetching open pull requests..."
144
+ open_pr_branches=$(gh pr list --repo "$REPO" --state open --limit 500 --json headRefName --jq '.[].headRefName' | sort -u)
145
+ open_pr_count=$(echo "$open_pr_branches" | grep -c . || true)
146
+ log_info "Found $open_pr_count branches with open PRs"
130
147
  echo ""
131
148
 
132
- # Find stale branches (not protected, no open PR)
133
- log_info "Identifying stale branches..."
134
- stale_branches=$(comm -23 <(echo "$all_branches") <(echo "$open_pr_branches") | while read -r branch; do
135
- if ! is_protected_branch "$branch"; then
136
- echo "$branch"
149
+ # Classify branches
150
+ log_info "Classifying branches (merged vs unmerged)..."
151
+ merged_branches=()
152
+ unmerged_branches=()
153
+
154
+ while IFS= read -r branch; do
155
+ [[ -z "$branch" ]] && continue
156
+ if is_protected_branch "$branch"; then
157
+ continue
158
+ fi
159
+ if echo "$open_pr_branches" | grep -qxF "$branch"; then
160
+ continue
137
161
  fi
138
- done)
162
+ if is_merged_branch "$branch"; then
163
+ merged_branches+=("$branch")
164
+ else
165
+ unmerged_branches+=("$branch")
166
+ fi
167
+ done <<< "$all_branches"
139
168
 
140
- stale_count=$(echo "$stale_branches" | grep -c . || true)
169
+ if [[ ${#unmerged_branches[@]} -gt 0 ]]; then
170
+ log_warning "Skipping ${#unmerged_branches[@]} unmerged branch(es) (may hold unshipped work):"
171
+ for branch in "${unmerged_branches[@]}"; do
172
+ echo " - $branch"
173
+ done
174
+ echo ""
175
+ fi
141
176
 
142
- if [[ "$stale_count" -eq 0 || -z "$stale_branches" ]]; then
143
- log_success "No stale branches found. Repository is clean!"
177
+ if [[ ${#merged_branches[@]} -eq 0 ]]; then
178
+ log_success "No merged branches found. Repository is clean!"
144
179
  exit 0
145
180
  fi
146
181
 
147
- log_warning "Found $stale_count stale branch(es) to delete"
182
+ log_warning "Found ${#merged_branches[@]} merged branch(es) to delete"
148
183
  echo ""
149
184
 
150
- # Display stale branches
151
185
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
152
- echo "Stale branches:"
153
- echo "$stale_branches" | while read -r branch; do
186
+ echo "Merged branches:"
187
+ for branch in "${merged_branches[@]}"; do
154
188
  echo " - $branch"
155
189
  done
156
190
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
157
191
  echo ""
158
192
 
159
- # Delete stale branches
193
+ # Delete merged branches
160
194
  deleted_count=0
161
195
  failed_count=0
162
196
 
163
- echo "$stale_branches" | while read -r branch; do
164
- if [[ -z "$branch" ]]; then
165
- continue
166
- fi
167
-
197
+ for branch in "${merged_branches[@]}"; do
168
198
  # Safety check - never delete protected branches
169
199
  if is_protected_branch "$branch"; then
170
200
  log_error "Attempted to delete protected branch: $branch - SKIPPING"
@@ -177,10 +207,10 @@ echo "$stale_branches" | while read -r branch; do
177
207
  echo -n "Deleting: $branch ... "
178
208
  if gh api -X DELETE "repos/$REPO/git/refs/heads/$branch" > /dev/null 2>&1; then
179
209
  echo -e "${GREEN}done${NC}"
180
- ((deleted_count++)) || true
210
+ deleted_count=$((deleted_count + 1))
181
211
  else
182
212
  echo -e "${RED}failed${NC}"
183
- ((failed_count++)) || true
213
+ failed_count=$((failed_count + 1))
184
214
  fi
185
215
  fi
186
216
  done
@@ -189,14 +219,8 @@ echo ""
189
219
  echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━"
190
220
 
191
221
  if [[ "$DRY_RUN" == true ]]; then
192
- log_info "Dry run complete. $stale_count branch(es) would be deleted."
222
+ log_info "Dry run complete. ${#merged_branches[@]} branch(es) would be deleted."
193
223
  else
224
+ log_success "Deleted $deleted_count branch(es); $failed_count failed."
194
225
  log_success "Branch cleanup complete for $REPO"
195
- echo ""
196
-
197
- # Show remaining branches
198
- log_info "Remaining branches:"
199
- gh api "repos/$REPO/branches" --paginate --jq '.[].name' | sort | while read -r branch; do
200
- echo " - $branch"
201
- done
202
226
  fi
@@ -0,0 +1,110 @@
1
+ #!/bin/bash
2
+ #
3
+ # Cleanup Local Merged Branches
4
+ #
5
+ # Deletes local branches that are safe to remove:
6
+ # - Branches whose upstream was deleted on the remote ("gone" — the usual
7
+ # aftermath of delete-branch-on-merge), AND
8
+ # - Branches fully merged into an environment branch (main/dev/staging)
9
+ #
10
+ # Never touches:
11
+ # - The currently checked-out branch
12
+ # - Environment branches (main, dev, staging) or the default branch
13
+ # - Branches checked out in a linked worktree (git refuses; reported)
14
+ # - Unmerged branches (uses safe `git branch -d`)
15
+ #
16
+ # Usage:
17
+ # ./scripts/cleanup-local-merged-branches.sh [--dry-run] [repo-path]
18
+ #
19
+ set -euo pipefail
20
+
21
+ PROTECTED_BRANCHES=("main" "dev" "staging")
22
+
23
+ GREEN='\033[0;32m'
24
+ YELLOW='\033[1;33m'
25
+ BLUE='\033[0;34m'
26
+ CYAN='\033[0;36m'
27
+ RED='\033[0;31m'
28
+ NC='\033[0m'
29
+
30
+ log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
31
+ log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; }
32
+ log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; }
33
+ log_dry_run() { echo -e "${CYAN}[DRY-RUN]${NC} Would delete: $1"; }
34
+
35
+ DRY_RUN=false
36
+ REPO_PATH="."
37
+ for arg in "$@"; do
38
+ case "$arg" in
39
+ --dry-run) DRY_RUN=true ;;
40
+ -h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
41
+ *) REPO_PATH="$arg" ;;
42
+ esac
43
+ done
44
+
45
+ cd "$REPO_PATH"
46
+ if ! git rev-parse --git-dir > /dev/null 2>&1; then
47
+ echo -e "${RED}[ERROR]${NC} Not a git repository: $REPO_PATH" >&2
48
+ exit 1
49
+ fi
50
+
51
+ CURRENT_BRANCH=$(git branch --show-current || echo "")
52
+ DEFAULT_BRANCH=$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null | sed 's#^origin/##' || echo "main")
53
+ PROTECTED_BRANCHES+=("$DEFAULT_BRANCH")
54
+
55
+ is_protected() {
56
+ local branch="$1"
57
+ [[ "$branch" == "$CURRENT_BRANCH" ]] && return 0
58
+ for protected in "${PROTECTED_BRANCHES[@]}"; do
59
+ [[ "$branch" == "$protected" ]] && return 0
60
+ done
61
+ return 1
62
+ }
63
+
64
+ log_info "Fetching and pruning remote-tracking references..."
65
+ git fetch --prune --quiet
66
+
67
+ # Candidates, deduped: gone-upstream branches + branches merged into any
68
+ # environment branch that exists on the remote.
69
+ candidates=$(
70
+ {
71
+ git branch --format '%(refname:short) %(upstream:track)' \
72
+ | awk '$2 == "[gone]" {print $1}'
73
+ for env_branch in main dev staging; do
74
+ if git show-ref --verify --quiet "refs/remotes/origin/$env_branch"; then
75
+ git branch --merged "origin/$env_branch" --format '%(refname:short)'
76
+ fi
77
+ done
78
+ } | sort -u
79
+ )
80
+
81
+ deleted=0
82
+ kept=0
83
+ while IFS= read -r branch; do
84
+ [[ -z "$branch" ]] && continue
85
+ if is_protected "$branch"; then
86
+ continue
87
+ fi
88
+
89
+ if [[ "$DRY_RUN" == true ]]; then
90
+ log_dry_run "$branch"
91
+ deleted=$((deleted + 1))
92
+ continue
93
+ fi
94
+
95
+ # Safe delete only: -d refuses unmerged branches and worktree checkouts.
96
+ if git branch -d "$branch" > /dev/null 2>&1; then
97
+ log_success "Deleted: $branch"
98
+ deleted=$((deleted + 1))
99
+ else
100
+ log_warning "Kept (unmerged or checked out in a worktree): $branch"
101
+ kept=$((kept + 1))
102
+ fi
103
+ done <<< "$candidates"
104
+
105
+ echo ""
106
+ if [[ "$DRY_RUN" == true ]]; then
107
+ log_info "Dry run complete. $deleted branch(es) would be deleted."
108
+ else
109
+ log_success "Deleted $deleted branch(es); kept $kept."
110
+ fi
@@ -0,0 +1,222 @@
1
+ #!/bin/bash
2
+ #
3
+ # cleanup-worktrees.sh — safely remove merged/abandoned linked git worktrees
4
+ # for ONE repository (the repo-level counterpart of a workspace-wide
5
+ # agent-worktree sweep).
6
+ #
7
+ # SAFETY MODEL — a worktree is only removed when ALL of these hold:
8
+ # 1. It is a LINKED worktree (never the main checkout).
9
+ # 2. Its path matches a known agent-worktree location (.claude/worktrees,
10
+ # ~/.codex/worktrees, .lisa-worktrees, /tmp) OR its branch matches an
11
+ # agent naming pattern (claude/*, worktree-*, codex/*) OR its branch is
12
+ # fully merged into an environment branch (main/dev/staging).
13
+ # 3. No modified/staged TRACKED files ("real work" is never deleted).
14
+ # 4. Its HEAD commit is reachable from some remote ref (nothing unpushed).
15
+ # 5. It is older than --min-age-days (default 7) by directory mtime.
16
+ # Untracked-only dirt (node_modules, .env.local, build output) blocks
17
+ # removal by default; pass --force-untracked to treat it as junk.
18
+ #
19
+ # DRY-RUN BY DEFAULT. Nothing is deleted until you pass --apply.
20
+ #
21
+ # Usage:
22
+ # cleanup-worktrees.sh [options] [repo-path]
23
+ # --apply actually remove (default: dry-run report)
24
+ # --force-untracked also remove worktrees whose only dirt is untracked files
25
+ # --min-age-days N minimum age (dir mtime) to touch anything (default 7)
26
+ # --delete-branches after removing a worktree, delete its branch when
27
+ # its commits are on a remote
28
+ #
29
+ set -uo pipefail
30
+
31
+ APPLY=0
32
+ FORCE_UNTRACKED=0
33
+ MIN_AGE_DAYS=7
34
+ DELETE_BRANCHES=0
35
+ REPO="."
36
+
37
+ while [ $# -gt 0 ]; do
38
+ case "$1" in
39
+ --apply) APPLY=1 ;;
40
+ --force-untracked) FORCE_UNTRACKED=1 ;;
41
+ --min-age-days)
42
+ shift
43
+ if [ $# -eq 0 ]; then
44
+ echo "ERROR: --min-age-days requires a value" >&2
45
+ exit 1
46
+ fi
47
+ MIN_AGE_DAYS="$1" ;;
48
+ --delete-branches) DELETE_BRANCHES=1 ;;
49
+ -h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
50
+ *) REPO="$1" ;;
51
+ esac
52
+ shift
53
+ done
54
+
55
+ REPO="$(cd "$REPO" && pwd)"
56
+ if ! git -C "$REPO" rev-parse --git-dir > /dev/null 2>&1; then
57
+ echo "ERROR: not a git repository: $REPO" >&2
58
+ exit 1
59
+ fi
60
+
61
+ NOW=$(date +%s)
62
+ MIN_AGE_SECS=$((MIN_AGE_DAYS * 86400))
63
+ REMOVED=0 KEPT_DIRTY=0 KEPT_UNPUSHED=0 KEPT_YOUNG=0 KEPT_UNTRACKED=0 ORPHANS=0 ERRORS=0
64
+
65
+ log() { printf '%s\n' "$*"; }
66
+ act() { if [ "$APPLY" = 1 ]; then log "REMOVE $*"; else log "WOULD-REMOVE $*"; fi; }
67
+
68
+ is_agent_path() {
69
+ case "$1" in
70
+ */.claude/worktrees/*|"$HOME"/.codex/worktrees/*|*/.lisa-worktrees/*|*/.lisa-update-*/*|/tmp/*|/private/tmp/*) return 0 ;;
71
+ *) return 1 ;;
72
+ esac
73
+ }
74
+
75
+ is_agent_branch() {
76
+ case "$1" in
77
+ claude/*|worktree-*|codex/*) return 0 ;;
78
+ *) return 1 ;;
79
+ esac
80
+ }
81
+
82
+ is_env_branch() {
83
+ case "$1" in
84
+ main|dev|staging) return 0 ;;
85
+ *) return 1 ;;
86
+ esac
87
+ }
88
+
89
+ # Branch is fully merged into an environment branch on the remote.
90
+ branch_is_merged() {
91
+ local branch="$1" env_branch
92
+ [ -z "$branch" ] && return 1
93
+ for env_branch in main dev staging; do
94
+ if git -C "$REPO" show-ref --verify --quiet "refs/remotes/origin/$env_branch"; then
95
+ if git -C "$REPO" merge-base --is-ancestor "refs/heads/$branch" "refs/remotes/origin/$env_branch" 2>/dev/null; then
96
+ return 0
97
+ fi
98
+ fi
99
+ done
100
+ return 1
101
+ }
102
+
103
+ head_is_pushed() {
104
+ local wt="$1" sha
105
+ sha=$(git -C "$wt" rev-parse HEAD 2>/dev/null) || return 1
106
+ [ -n "$(git -C "$wt" branch -r --contains "$sha" 2>/dev/null | head -1)" ]
107
+ }
108
+
109
+ dir_age_ok() {
110
+ # GNU stat first: on Linux, `stat -f %m` succeeds but prints the MOUNT POINT
111
+ # (filesystem mode), which would poison the mtime arithmetic. BSD/macOS stat
112
+ # has no -c, so it falls through to -f %m (mtime there).
113
+ local mtime
114
+ mtime=$(stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null) || return 1
115
+ case "$mtime" in
116
+ ''|*[!0-9]*) return 1 ;;
117
+ esac
118
+ [ $((NOW - mtime)) -ge "$MIN_AGE_SECS" ]
119
+ }
120
+
121
+ process_worktree() {
122
+ local wt="$1" branch="$2"
123
+
124
+ # Never remove a worktree sitting on an environment branch.
125
+ if [ -n "$branch" ] && is_env_branch "$branch"; then
126
+ return
127
+ fi
128
+
129
+ # Eligibility gate: agent location, agent branch, or a merged branch.
130
+ if ! is_agent_path "$wt" && ! is_agent_branch "$branch" && ! branch_is_merged "$branch"; then
131
+ return
132
+ fi
133
+
134
+ if ! dir_age_ok "$wt"; then
135
+ KEPT_YOUNG=$((KEPT_YOUNG + 1))
136
+ log "KEEP (younger than ${MIN_AGE_DAYS}d) $wt"
137
+ return
138
+ fi
139
+
140
+ if [ -n "$(git -C "$wt" status --porcelain --untracked-files=no 2>/dev/null)" ]; then
141
+ KEPT_DIRTY=$((KEPT_DIRTY + 1))
142
+ log "KEEP (modified tracked files) $wt"
143
+ return
144
+ fi
145
+
146
+ if ! head_is_pushed "$wt"; then
147
+ KEPT_UNPUSHED=$((KEPT_UNPUSHED + 1))
148
+ log "KEEP (HEAD not on any remote — possible unpushed work) $wt"
149
+ return
150
+ fi
151
+
152
+ local force_flag=()
153
+ if [ -n "$(git -C "$wt" status --porcelain 2>/dev/null)" ]; then
154
+ if [ "$FORCE_UNTRACKED" = 1 ]; then
155
+ force_flag=(--force)
156
+ else
157
+ KEPT_UNTRACKED=$((KEPT_UNTRACKED + 1))
158
+ log "KEEP (untracked files only — rerun with --force-untracked) $wt"
159
+ return
160
+ fi
161
+ fi
162
+
163
+ act "$wt [branch: ${branch:-detached}]"
164
+ if [ "$APPLY" = 1 ]; then
165
+ if git -C "$REPO" worktree remove "${force_flag[@]}" "$wt" 2>/dev/null; then
166
+ REMOVED=$((REMOVED + 1))
167
+ if [ "$DELETE_BRANCHES" = 1 ] && [ -n "$branch" ] && ! is_env_branch "$branch"; then
168
+ git -C "$REPO" branch -D "$branch" >/dev/null 2>&1 || true
169
+ fi
170
+ else
171
+ ERRORS=$((ERRORS + 1))
172
+ log "ERROR removing $wt"
173
+ fi
174
+ else
175
+ REMOVED=$((REMOVED + 1))
176
+ fi
177
+ }
178
+
179
+ log "=== cleanup-worktrees: $([ "$APPLY" = 1 ] && echo APPLY || echo DRY-RUN) (repo: $REPO, min age ${MIN_AGE_DAYS}d) ==="
180
+
181
+ git -C "$REPO" fetch --prune --quiet 2>/dev/null || true
182
+ git -C "$REPO" worktree prune 2>/dev/null
183
+
184
+ MAIN_WT=$(git -C "$REPO" worktree list --porcelain | head -1 | sed 's/^worktree //')
185
+
186
+ wt="" branch="" first=1
187
+ while IFS= read -r line; do
188
+ case "$line" in
189
+ worktree\ *)
190
+ wt="${line#worktree }" branch="" ;;
191
+ branch\ *)
192
+ branch="${line#branch refs/heads/}" ;;
193
+ "")
194
+ if [ "$first" = 1 ]; then first=0; else
195
+ [ -d "$wt" ] && [ "$wt" != "$MAIN_WT" ] && process_worktree "$wt" "$branch"
196
+ fi
197
+ wt="" ;;
198
+ esac
199
+ done < <(git -C "$REPO" worktree list --porcelain; echo)
200
+
201
+ # Orphan directories under .claude/worktrees that git no longer tracks.
202
+ wtroot="$REPO/.claude/worktrees"
203
+ if [ -d "$wtroot" ]; then
204
+ for d in "$wtroot"/*/; do
205
+ d=${d%/}
206
+ [ -d "$d" ] || continue
207
+ if ! git -C "$REPO" worktree list --porcelain | grep -qF "worktree $d"; then
208
+ if dir_age_ok "$d"; then
209
+ ORPHANS=$((ORPHANS + 1))
210
+ act "(orphan dir, not a registered worktree) $d"
211
+ [ "$APPLY" = 1 ] && rm -rf "$d"
212
+ fi
213
+ fi
214
+ done
215
+ fi
216
+
217
+ log ""
218
+ log "=== summary ==="
219
+ log "removed (or would remove): $REMOVED orphan dirs: $ORPHANS"
220
+ log "kept: $KEPT_DIRTY modified-tracked, $KEPT_UNPUSHED unpushed, $KEPT_UNTRACKED untracked-only, $KEPT_YOUNG too-young errors: $ERRORS"
221
+ [ "$APPLY" = 0 ] && log "(dry run — rerun with --apply to delete)"
222
+ exit 0