-
Notifications
You must be signed in to change notification settings - Fork 7
feat: cross-repo worktree cleanup with squash-merge detection #2616
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -534,29 +534,66 @@ ${state_content} | |||||
| } | ||||||
|
|
||||||
| ####################################### | ||||||
| # Clean up worktrees for merged/closed PRs | ||||||
| # Clean up worktrees for merged/closed PRs across ALL managed repos | ||||||
| # | ||||||
| # Runs worktree-helper.sh clean --auto to remove worktrees whose | ||||||
| # branches have been merged or deleted on the remote. This prevents | ||||||
| # stale worktrees from accumulating on disk after PR merges. | ||||||
| # Iterates repos.json and runs worktree-helper.sh clean --auto --force-merged | ||||||
| # in each repo directory. This prevents stale worktrees from accumulating | ||||||
| # on disk after PR merges — including squash merges that git branch --merged | ||||||
| # cannot detect. | ||||||
| # | ||||||
| # Safety: skips worktrees with uncommitted changes or owned by | ||||||
| # active sessions (handled by worktree-helper.sh). | ||||||
| # --force-merged: uses gh pr list to detect squash merges and force-removes | ||||||
| # dirty worktrees when the PR is confirmed merged (dirty state = abandoned WIP). | ||||||
| # | ||||||
| # Safety: skips worktrees owned by active sessions (handled by worktree-helper.sh). | ||||||
| ####################################### | ||||||
| cleanup_worktrees() { | ||||||
| local helper="${HOME}/.aidevops/agents/scripts/worktree-helper.sh" | ||||||
| if [[ ! -x "$helper" ]]; then | ||||||
| return 0 | ||||||
| fi | ||||||
|
|
||||||
| # Run from the main repo directory (worktree-helper needs a git context) | ||||||
| local cleaned_output | ||||||
| cleaned_output=$(bash "$helper" clean --auto 2>&1) || true | ||||||
| local repos_json="${HOME}/.config/aidevops/repos.json" | ||||||
| local total_removed=0 | ||||||
|
|
||||||
| if [[ -f "$repos_json" ]] && command -v jq &>/dev/null; then | ||||||
| # Iterate all repos, skip local_only (no GitHub remote for PR detection) | ||||||
| local repo_paths | ||||||
| repo_paths=$(jq -r '.[] | select(.local_only != true) | .path' "$repos_json" 2>/dev/null || echo "") | ||||||
|
|
||||||
| local repo_path | ||||||
| while IFS= read -r repo_path; do | ||||||
| [[ -z "$repo_path" ]] && continue | ||||||
| [[ ! -d "$repo_path/.git" ]] && continue | ||||||
|
|
||||||
| local cleaned_output | ||||||
| cleaned_output=$(git -C "$repo_path" worktree list 2>/dev/null | wc -l) | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid redirecting stderr to
Suggested change
References
|
||||||
| # Skip repos with only 1 worktree (the main one) — nothing to clean | ||||||
| if [[ "$cleaned_output" -le 1 ]]; then | ||||||
| continue | ||||||
| fi | ||||||
|
|
||||||
| # Log only if something was cleaned | ||||||
| if echo "$cleaned_output" | grep -q "Removing\|removed"; then | ||||||
| echo "[pulse-wrapper] Worktree cleanup: $(echo "$cleaned_output" | grep -c 'Removing') worktree(s) removed" >>"$LOGFILE" | ||||||
| # Run helper in a subshell cd'd to the repo (it uses git rev-parse --show-toplevel) | ||||||
| local clean_result | ||||||
| clean_result=$(cd "$repo_path" && bash "$helper" clean --auto --force-merged 2>&1) || true | ||||||
|
|
||||||
| local count | ||||||
| count=$(echo "$clean_result" | grep -c 'Removing' || echo "0") | ||||||
| if [[ "$count" -gt 0 ]]; then | ||||||
| local repo_name | ||||||
| repo_name=$(basename "$repo_path") | ||||||
| echo "[pulse-wrapper] Worktree cleanup ($repo_name): $count worktree(s) removed" >>"$LOGFILE" | ||||||
| total_removed=$((total_removed + count)) | ||||||
| fi | ||||||
| done <<<"$repo_paths" | ||||||
| else | ||||||
| # Fallback: just clean the current repo (legacy behaviour) | ||||||
| local cleaned_output | ||||||
| cleaned_output=$(bash "$helper" clean --auto --force-merged 2>&1) || true | ||||||
| if echo "$cleaned_output" | grep -q "Removing\|removed"; then | ||||||
| echo "[pulse-wrapper] Worktree cleanup: $(echo "$cleaned_output" | grep -c 'Removing') worktree(s) removed" >>"$LOGFILE" | ||||||
| fi | ||||||
| fi | ||||||
|
|
||||||
| return 0 | ||||||
| } | ||||||
|
|
||||||
|
|
||||||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -17,7 +17,7 @@ | |||||
| # remove <path|branch> Remove a worktree | ||||||
| # status Show current worktree info | ||||||
| # switch <branch> Open/create worktree for branch (prints path) | ||||||
| # clean [--auto] Remove worktrees for merged branches | ||||||
| # clean [--auto] [--force-merged] Remove worktrees for merged branches | ||||||
| # help Show this help | ||||||
| # | ||||||
| # Examples: | ||||||
|
|
@@ -680,10 +680,15 @@ cmd_switch() { | |||||
|
|
||||||
| cmd_clean() { | ||||||
| local auto_mode=false | ||||||
| if [[ "${1:-}" == "--auto" ]]; then | ||||||
| auto_mode=true | ||||||
| local force_merged=false | ||||||
| while [[ $# -gt 0 ]]; do | ||||||
| case "${1:-}" in | ||||||
| --auto) auto_mode=true ;; | ||||||
| --force-merged) force_merged=true ;; | ||||||
| *) break ;; | ||||||
| esac | ||||||
| shift | ||||||
| fi | ||||||
| done | ||||||
|
|
||||||
| echo -e "${BOLD}Checking for worktrees with merged branches...${NC}" | ||||||
| echo "" | ||||||
|
|
@@ -698,6 +703,13 @@ cmd_clean() { | |||||
| # Fetch to get current remote branch state (detects deleted branches) | ||||||
| git fetch --prune origin 2>/dev/null || true | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid redirecting stderr to
Suggested change
References
|
||||||
|
|
||||||
| # Build a lookup of merged PR branches for squash-merge detection. | ||||||
| # gh pr list only returns squash-merged PRs that git branch --merged misses. | ||||||
| local merged_pr_branches="" | ||||||
| if command -v gh &>/dev/null; then | ||||||
| merged_pr_branches=$(gh pr list --state merged --limit 200 --json headRefName --jq '.[].headRefName' 2>/dev/null || echo "") | ||||||
| fi | ||||||
|
|
||||||
| while IFS= read -r line; do | ||||||
| if [[ "$line" =~ ^worktree\ (.+)$ ]]; then | ||||||
| worktree_path="${BASH_REMATCH[1]}" | ||||||
|
|
@@ -719,14 +731,13 @@ cmd_clean() { | |||||
| ! git show-ref --verify --quiet "refs/remotes/origin/$worktree_branch" 2>/dev/null; then | ||||||
| is_merged=true | ||||||
| merge_type="remote deleted" | ||||||
| fi | ||||||
|
|
||||||
| # Safety check: skip if worktree has uncommitted changes | ||||||
| if [[ "$is_merged" == "true" ]] && worktree_has_changes "$worktree_path"; then | ||||||
| echo -e " ${RED}$worktree_branch${NC} (has uncommitted changes - skipping)" | ||||||
| echo " $worktree_path" | ||||||
| echo "" | ||||||
| is_merged=false | ||||||
| # Check 3: Squash-merge detection via GitHub PR state | ||||||
| # GitHub squash merges create a new commit — the original branch is NOT | ||||||
| # an ancestor of the target, so git branch --merged misses it. The remote | ||||||
| # branch may still exist if "auto-delete head branches" is off. | ||||||
| elif [[ -n "$merged_pr_branches" ]] && echo "$merged_pr_branches" | grep -qx "$worktree_branch"; then | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This line has two issues:
To address both, it's recommended to use
Suggested change
|
||||||
| is_merged=true | ||||||
| merge_type="squash-merged PR" | ||||||
| fi | ||||||
|
|
||||||
| # Ownership check (t189): skip if owned by another active session | ||||||
|
|
@@ -741,6 +752,19 @@ cmd_clean() { | |||||
| is_merged=false | ||||||
| fi | ||||||
|
|
||||||
| # Dirty check: behaviour depends on --force-merged flag | ||||||
| if [[ "$is_merged" == "true" ]] && worktree_has_changes "$worktree_path"; then | ||||||
| if [[ "$force_merged" == "true" ]]; then | ||||||
| # PR is confirmed merged — dirty state is abandoned WIP, safe to force-remove | ||||||
| merge_type="$merge_type, dirty (force)" | ||||||
| else | ||||||
| echo -e " ${RED}$worktree_branch${NC} (has uncommitted changes - skipping)" | ||||||
| echo " $worktree_path" | ||||||
| echo "" | ||||||
| is_merged=false | ||||||
| fi | ||||||
| fi | ||||||
|
|
||||||
| if [[ "$is_merged" == "true" ]]; then | ||||||
| found_any=true | ||||||
| echo -e " ${YELLOW}$worktree_branch${NC} ($merge_type)" | ||||||
|
|
@@ -780,13 +804,10 @@ cmd_clean() { | |||||
| elif [[ -z "$line" ]]; then | ||||||
| if [[ -n "$worktree_branch" ]] && [[ "$worktree_branch" != "$default_branch" ]]; then | ||||||
| local should_remove=false | ||||||
| local use_force=false | ||||||
|
|
||||||
| # Safety check: never remove worktrees with uncommitted changes | ||||||
| if worktree_has_changes "$worktree_path"; then | ||||||
| echo -e "${RED}Skipping $worktree_branch - has uncommitted changes${NC}" | ||||||
| should_remove=false | ||||||
| # Ownership check (t189): never remove worktrees owned by other sessions | ||||||
| elif is_worktree_owned_by_others "$worktree_path"; then | ||||||
| if is_worktree_owned_by_others "$worktree_path"; then | ||||||
| local rm_owner_info | ||||||
| rm_owner_info=$(check_worktree_owner "$worktree_path") | ||||||
| local rm_owner_pid | ||||||
|
|
@@ -800,17 +821,40 @@ cmd_clean() { | |||||
| elif branch_was_pushed "$worktree_branch" && | ||||||
| ! git show-ref --verify --quiet "refs/remotes/origin/$worktree_branch" 2>/dev/null; then | ||||||
| should_remove=true | ||||||
| # Check 3: Squash-merged PR | ||||||
| elif [[ -n "$merged_pr_branches" ]] && echo "$merged_pr_branches" | grep -qx "$worktree_branch"; then | ||||||
| should_remove=true | ||||||
| fi | ||||||
|
|
||||||
| # If should_remove but has changes, need --force-merged to proceed | ||||||
| if [[ "$should_remove" == "true" ]] && worktree_has_changes "$worktree_path"; then | ||||||
| if [[ "$force_merged" == "true" ]]; then | ||||||
| use_force=true | ||||||
| else | ||||||
| echo -e "${RED}Skipping $worktree_branch - has uncommitted changes${NC}" | ||||||
| should_remove=false | ||||||
| fi | ||||||
| fi | ||||||
|
|
||||||
| if [[ "$should_remove" == "true" ]]; then | ||||||
| echo -e "${BLUE}Removing $worktree_branch...${NC}" | ||||||
| # Clean up aidevops runtime files before removal | ||||||
| # Clean up heavy directories first to speed up removal | ||||||
| # (node_modules, .next, .turbo can have 100k+ files) | ||||||
| rm -rf "$worktree_path/node_modules" 2>/dev/null || true | ||||||
| rm -rf "$worktree_path/.next" 2>/dev/null || true | ||||||
| rm -rf "$worktree_path/.turbo" 2>/dev/null || true | ||||||
|
Comment on lines
+843
to
+845
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The Remediation: The script should identify and skip the main worktree. This can be done by checking if the worktree path is the same as the base repository path or by skipping the first entry in the |
||||||
| # Clean up aidevops runtime files | ||||||
| rm -rf "$worktree_path/.agents/loop-state" 2>/dev/null || true | ||||||
| rm -rf "$worktree_path/.agents/tmp" 2>/dev/null || true | ||||||
| rm -f "$worktree_path/.agents/.DS_Store" 2>/dev/null || true | ||||||
| rmdir "$worktree_path/.agent" 2>/dev/null || true | ||||||
| # Don't use --force to prevent data loss | ||||||
| if ! git worktree remove "$worktree_path" 2>/dev/null; then | ||||||
|
|
||||||
| local remove_flag="" | ||||||
| if [[ "$use_force" == "true" ]]; then | ||||||
| remove_flag="--force" | ||||||
| fi | ||||||
| # shellcheck disable=SC2086 | ||||||
| if ! git worktree remove $remove_flag "$worktree_path" 2>/dev/null; then | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Avoid redirecting stderr to
Suggested change
References
|
||||||
| echo -e "${RED}Failed to remove $worktree_branch - may have uncommitted changes${NC}" | ||||||
| else | ||||||
| # Unregister ownership (t189) | ||||||
|
|
@@ -934,8 +978,12 @@ COMMANDS | |||||
|
|
||||||
| switch <branch> Get/create worktree for branch (prints path) | ||||||
|
|
||||||
| clean [--auto] Remove worktrees for merged branches | ||||||
| clean [--auto] [--force-merged] | ||||||
| Remove worktrees for merged branches | ||||||
| --auto: skip confirmation prompt (for automated cleanup) | ||||||
| --force-merged: force-remove dirty worktrees when PR is | ||||||
| confirmed merged (dirty state = abandoned WIP). Also | ||||||
| detects squash merges via gh pr list. | ||||||
| Skips worktrees owned by other active sessions (t189) | ||||||
|
|
||||||
| registry [list|prune] View or prune the ownership registry (t189, t197) | ||||||
|
|
||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Avoid redirecting stderr to
/dev/null. Ifrepos.jsonis malformed orjqfails for another reason, the error message will be suppressed, making debugging difficult. The|| echo ""already provides a safe fallback if the command fails, so stderr can be allowed to print for better diagnostics.References