@codyswann/lisa 2.195.7 → 2.196.0
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.
- package/all/github-rulesets/base.json +64 -0
- package/all/github-rulesets/prevent-delete.json +25 -0
- package/all/github-rulesets/protect-tags.json +31 -0
- package/cdk/github-rulesets/cdk-validation.json +43 -0
- package/expo/create-only/.github/workflows/deploy.yml +6 -1
- package/expo/github-rulesets/playwright.json +38 -0
- package/package.json +2 -2
- package/plugins/lisa/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-github-repo/SKILL.md +66 -0
- package/plugins/lisa/.codex-plugin/skills/lisa-setup-github-repo/agents/openai.yaml +4 -0
- package/plugins/lisa/commands/lisa/setup/github-repo.md +7 -0
- package/plugins/lisa/skills/lisa-setup-github-repo/SKILL.md +66 -0
- package/plugins/lisa/skills/lisa-setup-github-repo/agents/openai.yaml +4 -0
- package/plugins/lisa-agy/commands/lisa/setup/github-repo.md +7 -0
- package/plugins/lisa-agy/plugin.json +1 -1
- package/plugins/lisa-agy/skills/lisa-setup-github-repo/SKILL.md +66 -0
- package/plugins/lisa-cdk/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-agy/plugin.json +1 -1
- package/plugins/lisa-cdk-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cdk-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-copilot/commands/lisa/setup/github-repo.md +7 -0
- package/plugins/lisa-copilot/skills/lisa-setup-github-repo/SKILL.md +66 -0
- package/plugins/lisa-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-cursor/commands/lisa/setup/github-repo.md +7 -0
- package/plugins/lisa-cursor/skills/lisa-setup-github-repo/SKILL.md +66 -0
- package/plugins/lisa-expo/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-agy/plugin.json +1 -1
- package/plugins/lisa-expo-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-expo-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-agy/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-harper-fabric-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-agy/plugin.json +1 -1
- package/plugins/lisa-nestjs-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-nestjs-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-agy/plugin.json +1 -1
- package/plugins/lisa-openclaw-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-openclaw-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-agy/plugin.json +1 -1
- package/plugins/lisa-phaser-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-phaser-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-agy/plugin.json +1 -1
- package/plugins/lisa-rails-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-rails-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-agy/plugin.json +1 -1
- package/plugins/lisa-typescript-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-typescript-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki/.codex-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-agy/plugin.json +1 -1
- package/plugins/lisa-wiki-copilot/.claude-plugin/plugin.json +1 -1
- package/plugins/lisa-wiki-cursor/.claude-plugin/plugin.json +1 -1
- package/plugins/src/base/commands/lisa/setup/github-repo.md +7 -0
- package/plugins/src/base/skills/lisa-setup-github-repo/SKILL.md +66 -0
- package/rails/github-rulesets/quality-checks.json +51 -0
- package/scripts/cleanup-github-branches.sh +96 -72
- package/scripts/cleanup-local-merged-branches.sh +110 -0
- package/scripts/cleanup-worktrees.sh +222 -0
- package/scripts/lisa-github-repo-settings.sh +152 -0
- package/scripts/lisa-github-repo-setup.sh +63 -0
- package/scripts/lisa-github-rulesets.sh +549 -0
- package/scripts/setup-deploy-key.sh +61 -2
- package/typescript/github-rulesets/quality-checks.json +67 -0
- package/typescript/github-rulesets/base.json +0 -33
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
2
|
#
|
|
3
|
-
# Cleanup GitHub
|
|
3
|
+
# Cleanup GitHub Merged Branches
|
|
4
4
|
#
|
|
5
|
-
# Deletes remote branches that are:
|
|
6
|
-
# - NOT
|
|
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
|
-
#
|
|
10
|
-
# that
|
|
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
|
-
#
|
|
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
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
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_
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
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
|
-
#
|
|
133
|
-
log_info "
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 [[
|
|
143
|
-
log_success "No
|
|
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 $
|
|
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 "
|
|
153
|
-
|
|
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
|
|
193
|
+
# Delete merged branches
|
|
160
194
|
deleted_count=0
|
|
161
195
|
failed_count=0
|
|
162
196
|
|
|
163
|
-
|
|
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
|
|
210
|
+
deleted_count=$((deleted_count + 1))
|
|
181
211
|
else
|
|
182
212
|
echo -e "${RED}failed${NC}"
|
|
183
|
-
((failed_count
|
|
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. $
|
|
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
|