diff --git a/.agents/scripts/pulse-wrapper.sh b/.agents/scripts/pulse-wrapper.sh index 9a4b354eb..ac382c4b1 100755 --- a/.agents/scripts/pulse-wrapper.sh +++ b/.agents/scripts/pulse-wrapper.sh @@ -534,14 +534,17 @@ ${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" @@ -549,14 +552,48 @@ cleanup_worktrees() { 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) + # 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 } diff --git a/.agents/scripts/worktree-helper.sh b/.agents/scripts/worktree-helper.sh index f8a93f953..6ba87ace3 100755 --- a/.agents/scripts/worktree-helper.sh +++ b/.agents/scripts/worktree-helper.sh @@ -17,7 +17,7 @@ # remove Remove a worktree # status Show current worktree info # switch 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 + # 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 + 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 + # 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 echo -e "${RED}Failed to remove $worktree_branch - may have uncommitted changes${NC}" else # Unregister ownership (t189) @@ -934,8 +978,12 @@ COMMANDS switch 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)