diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh index 2c3c1b1..a0226f8 100644 --- a/lib/commands/clean.sh +++ b/lib/commands/clean.sh @@ -72,6 +72,10 @@ _clean_should_skip() { _clean_merged() { local repo_root="$1" base_dir="$2" prefix="$3" yes_mode="$4" dry_run="$5" force="${6:-0}" active_worktree_path="${7:-}" target_ref="${8:-}" + # base_dir and prefix are kept for the helper contract. Merged cleanup uses + # Git's registry so nested registered worktrees are processed directly. + : "$base_dir" "$prefix" + log_step "Checking for worktrees with merged PRs/MRs..." local provider @@ -84,57 +88,77 @@ _clean_merged() { local removed=0 skipped=0 local main_branch main_branch=$(current_branch "$repo_root") - - for dir in "$base_dir/${prefix}"*; do - [ -d "$dir" ] || continue - - local branch - branch=$(current_branch "$dir") || true - local branch_tip - branch_tip=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true) - - # Skip main repo branch silently (not counted) - [ "$branch" = "$main_branch" ] && continue - - # Check if branch has a merged PR/MR - if check_branch_merged "$provider" "$branch" "$target_ref" "$branch_tip"; then - if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then - skipped=$((skipped + 1)) - continue - fi - - if [ "$dry_run" -eq 1 ]; then - log_info "[dry-run] Would remove: $branch ($dir)" - removed=$((removed + 1)) - elif [ "$yes_mode" -eq 1 ] || prompt_yes_no "Remove worktree and delete branch '$branch'?"; then - log_step "Removing worktree: $branch" - - if ! run_hooks_in preRemove "$dir" \ - REPO_ROOT="$repo_root" \ - WORKTREE_PATH="$dir" \ - BRANCH="$branch"; then - log_warn "Pre-remove hook failed for $branch, skipping" - skipped=$((skipped + 1)) - continue - fi - - if remove_worktree "$dir" "$force"; then - git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true - removed=$((removed + 1)) - - if ! run_hooks postRemove \ - REPO_ROOT="$repo_root" \ - WORKTREE_PATH="$dir" \ - BRANCH="$branch"; then - log_warn "Post-remove hook failed for $branch" + local records + records=$(list_worktree_records "$repo_root") + + local is_main="" dir="" branch="" line + while IFS= read -r line; do + case "$line" in + "") + if [ -n "$dir" ] && [ "$is_main" != "1" ]; then + local branch_tip + branch_tip=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true) + + # Skip main repo branch silently (not counted) + [ "$branch" = "$main_branch" ] && continue + + # Check if branch has a merged PR/MR + if check_branch_merged "$provider" "$branch" "$target_ref" "$branch_tip"; then + if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then + skipped=$((skipped + 1)) + continue + fi + + if [ "$dry_run" -eq 1 ]; then + log_info "[dry-run] Would remove: $branch ($dir)" + removed=$((removed + 1)) + elif [ "$yes_mode" -eq 1 ] || prompt_yes_no "Remove worktree and delete branch '$branch'?"; then + log_step "Removing worktree: $branch" + + if ! run_hooks_in preRemove "$dir" \ + REPO_ROOT="$repo_root" \ + WORKTREE_PATH="$dir" \ + BRANCH="$branch"; then + log_warn "Pre-remove hook failed for $branch, skipping" + skipped=$((skipped + 1)) + continue + fi + + if remove_worktree "$dir" "$force"; then + git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true + removed=$((removed + 1)) + + if ! run_hooks postRemove \ + REPO_ROOT="$repo_root" \ + WORKTREE_PATH="$dir" \ + BRANCH="$branch"; then + log_warn "Post-remove hook failed for $branch" + fi + fi + else + log_warn "Skipped: $branch (user declined)" + skipped=$((skipped + 1)) + fi fi fi - else - log_warn "Skipped: $branch (user declined)" - skipped=$((skipped + 1)) - fi - fi - done + is_main="" + dir="" + branch="" + ;; + "is_main "*) + is_main="${line#is_main }" + ;; + "path "*) + dir=$(_tsv_unescape_field "${line#path }") + ;; + "branch "*) + branch=$(_tsv_unescape_field "${line#branch }") + ;; + esac + done <branchstatus - local branch status - branch=$(current_branch "$repo_root") - status=$(worktree_status "$repo_root") - printf "%s\t%s\t%s\n" "$repo_root" "$branch" "$status" + local is_main="" path="" branch="" status="" linked_rows="" line + while IFS= read -r line; do + case "$line" in + "") + [ -z "$path" ] && continue + if [ "$is_main" = "1" ]; then + printf "%s\t%s\t%s\n" "$path" "$branch" "$status" + else + linked_rows="${linked_rows}${path}"$'\t'"${branch}"$'\t'"${status}"$'\n' + fi + is_main="" + path="" + branch="" + status="" + ;; + "is_main "*) + is_main="${line#is_main }" + ;; + "path "*) + path=$(_tsv_unescape_field "${line#path }") + ;; + "branch "*) + branch=$(_tsv_unescape_field "${line#branch }") + ;; + "status "*) + status="${line#status }" + ;; + esac + done <branchstatus - # Exclude the base directory itself to avoid matching when prefix is empty - find "$base_dir" -maxdepth 1 -type d -name "${prefix}*" 2>/dev/null | while IFS= read -r dir; do - # Skip the base directory itself - [ "$dir" = "$base_dir" ] && continue - local branch status - branch=$(current_branch "$dir") - [ -z "$branch" ] && branch="(detached)" - status=$(worktree_status "$dir") - printf "%s\t%s\t%s\n" "$dir" "$branch" "$status" - done | LC_ALL=C sort -k2,2 + if [ -n "$path" ]; then + if [ "$is_main" = "1" ]; then + printf "%s\t%s\t%s\n" "$path" "$branch" "$status" + else + linked_rows="${linked_rows}${path}"$'\t'"${branch}"$'\t'"${status}"$'\n' + fi + fi + + if [ -n "$linked_rows" ]; then + printf "%s" "$linked_rows" | LC_ALL=C sort -t "$(printf '\t')" -k2,2 -k1,1 fi return 0 fi @@ -41,24 +68,54 @@ cmd_list() { printf "%-30s %s\n" "BRANCH" "PATH" printf "%-30s %s\n" "------" "----" - # Always show repo root first - local branch - branch=$(current_branch "$repo_root") - printf "%-30s %s\n" "$branch [main repo]" "$repo_root" + local is_main="" path="" branch="" status="" linked_rows="" line + while IFS= read -r line; do + case "$line" in + "") + [ -z "$path" ] && continue + if [ "$is_main" = "1" ]; then + printf "%-30s %s\n" "$branch [main repo]" "$path" + else + linked_rows="${linked_rows}${branch}"$'\t'"${path}"$'\n' + fi + is_main="" + path="" + branch="" + status="" + ;; + "is_main "*) + is_main="${line#is_main }" + ;; + "path "*) + path=$(_tsv_unescape_field "${line#path }") + ;; + "branch "*) + branch=$(_tsv_unescape_field "${line#branch }") + ;; + "status "*) + status="${line#status }" + ;; + esac + done </dev/null | while IFS= read -r dir; do - # Skip the base directory itself - [ "$dir" = "$base_dir" ] && continue - local branch - branch=$(current_branch "$dir") - [ -z "$branch" ] && branch="(detached)" - printf "%-30s %s\n" "$branch" "$dir" - done | LC_ALL=C sort -k1,1 + if [ -n "$linked_rows" ]; then + printf "%s" "$linked_rows" | LC_ALL=C sort -t "$(printf '\t')" -k1,1 -k2,2 | while IFS=$'\t' read -r branch path; do + [ -z "$path" ] && continue + printf "%-30s %s\n" "$branch" "$path" + done fi echo "" echo "" echo "Tip: Use 'git gtr list --porcelain' for machine-readable output" -} \ No newline at end of file +} diff --git a/lib/core.sh b/lib/core.sh index d630f8a..4f5bdad 100644 --- a/lib/core.sh +++ b/lib/core.sh @@ -190,62 +190,221 @@ current_branch() { printf "%s" "$branch" } +_worktree_record_status() { + local detached="$1" locked="$2" prunable="$3" + + if [ "$locked" -eq 1 ]; then + printf "locked" + elif [ "$prunable" -eq 1 ]; then + printf "prunable" + elif [ "$detached" -eq 1 ]; then + printf "detached" + else + printf "ok" + fi +} + +_emit_worktree_record() { + local repo_root="$1" + local wt_path="$2" + local wt_branch="$3" + local wt_detached="$4" + local wt_locked="$5" + local wt_prunable="$6" + + [ -z "$wt_path" ] && return 0 + + local is_main=0 branch="$wt_branch" status wt_path_canonical + wt_path_canonical=$(canonicalize_path "$wt_path" || printf "%s" "$wt_path") + if [ "$wt_path" = "$repo_root" ] || [ "$wt_path_canonical" = "$repo_root" ]; then + is_main=1 + fi + [ -z "$branch" ] && branch="(detached)" + status=$(_worktree_record_status "$wt_detached" "$wt_locked" "$wt_prunable") + + printf "is_main %s\n" "$is_main" + printf "path %s\n" "$(_tsv_escape_field "$wt_path")" + printf "branch %s\n" "$(_tsv_escape_field "$branch")" + printf "status %s\n\n" "$status" +} + +_parse_worktree_records() { + local repo_root_canonical="$1" + local delimiter="$2" + local wt_path="" wt_branch="" wt_detached=0 wt_locked=0 wt_prunable=0 + local field + + while IFS= read -r -d "$delimiter" field; do + case "$field" in + "") + _emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable" + wt_path="" + wt_branch="" + wt_detached=0 + wt_locked=0 + wt_prunable=0 + ;; + "worktree "*) + if [ -n "$wt_path" ]; then + _emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable" + wt_branch="" + wt_detached=0 + wt_locked=0 + wt_prunable=0 + fi + wt_path="${field#worktree }" + ;; + "branch refs/heads/"*) + wt_branch="${field#branch refs/heads/}" + ;; + "branch "*) + wt_branch="${field#branch }" + ;; + detached) + wt_detached=1 + ;; + locked*) + wt_locked=1 + ;; + prunable*) + wt_prunable=1 + ;; + esac + done + + _emit_worktree_record "$repo_root_canonical" "$wt_path" "$wt_branch" "$wt_detached" "$wt_locked" "$wt_prunable" +} + +# List registered git worktrees for a repository. +# Usage: list_worktree_records repo_root +# Output: blank-line-delimited records with is_main/path/branch/status fields +list_worktree_records() { + local repo_root="$1" + local repo_root_canonical + repo_root_canonical=$(canonicalize_path "$repo_root" || printf "%s" "$repo_root") + + if git -C "$repo_root" worktree list --porcelain -z >/dev/null 2>&1; then + _parse_worktree_records "$repo_root_canonical" "" < <(git -C "$repo_root" worktree list --porcelain -z 2>/dev/null) + else + _parse_worktree_records "$repo_root_canonical" $'\n' < <(git -C "$repo_root" worktree list --porcelain 2>/dev/null) + fi +} + # Get the status of a worktree from git # Usage: worktree_status worktree_path # Returns: status (ok, detached, locked, prunable, or missing) worktree_status() { local target_path="$1" - local porcelain_output - local in_section=0 + local target_path_canonical + target_path_canonical=$(canonicalize_path "$target_path" || printf "%s" "$target_path") + local status="ok" local found=0 + local repo_root + repo_root=$(_resolve_main_repo_root) || return 1 - # Parse git worktree list --porcelain line by line - porcelain_output=$(git worktree list --porcelain 2>/dev/null) - + local is_main="" path="" branch="" record_status="" line path_canonical while IFS= read -r line; do - # Check if this is the start of our target worktree - if [ "$line" = "worktree $target_path" ]; then - in_section=1 + case "$line" in + "") + [ -z "$path" ] && continue + path_canonical=$(canonicalize_path "$path" || printf "%s" "$path") + if [ "$path" = "$target_path" ] || [ "$path_canonical" = "$target_path_canonical" ]; then + found=1 + status="$record_status" + break + fi + is_main="" + path="" + branch="" + record_status="" + ;; + "is_main "*) + is_main="${line#is_main }" + ;; + "path "*) + path=$(_tsv_unescape_field "${line#path }") + ;; + "branch "*) + branch=$(_tsv_unescape_field "${line#branch }") + ;; + "status "*) + record_status="${line#status }" + ;; + esac + done < prunable > detached) - case "$line" in - locked*) - status="locked" + next="${value:0:1}" + value="${value:1}" + case "$next" in + t) + out="${out}"$'\t' ;; - prunable*) - [ "$status" = "ok" ] && status="prunable" + n) + out="${out}"$'\n' ;; - detached) - [ "$status" = "ok" ] && status="detached" + "\\") + out="${out}\\" + ;; + *) + out="${out}\\${next}" ;; esac + else + out="${out}${char}" fi - done </dev/null) + done </dev/null 2>&1; then + skip "git worktree list --porcelain -z is not available" + fi + + local newline_path="${TEST_REPO}-external"$'\n'"newline" + git -C "$TEST_REPO" worktree add "$newline_path" -b newline-path --quiet + local expected_path + expected_path=$(cd "$newline_path" && pwd -P) + + local records escaped_path + escaped_path=$(_tsv_escape_field "$expected_path") + records=$(list_worktree_records "$TEST_REPO") + + [[ "$records" == *"path $escaped_path"$'\n'"branch newline-path"* ]] + + local status + status=$(worktree_status "$expected_path") + [ "$status" = "ok" ] + + resolve_worktree "newline-path" "$TEST_REPO" "$TEST_WORKTREES_DIR" "" + [ "$_ctx_worktree_path" = "$expected_path" ] + [ "$_ctx_branch" = "newline-path" ] +} + # ── discover_repo_root from worktree ────────────────────────────────────────── @test "discover_repo_root returns main repo root when called from a worktree" { diff --git a/tests/integration_lifecycle.bats b/tests/integration_lifecycle.bats index c0e19e1..8469e61 100644 --- a/tests/integration_lifecycle.bats +++ b/tests/integration_lifecycle.bats @@ -38,6 +38,29 @@ teardown() { [[ "$branches" == *"test-list"* ]] } +@test "list_worktree_branches shows nested externally-created worktree" { + local base_dir="${TEST_REPO}-worktrees" + mkdir -p "$base_dir/jsmith" + git -C "$TEST_REPO" worktree add "$base_dir/jsmith/my-feature" -b jsmith/my-feature --quiet + + local branches + branches=$(list_worktree_branches "$base_dir" "") + + [[ "$branches" == *"jsmith/my-feature"* ]] + [[ "$branches" != *"(detached)"* ]] +} + +@test "list_worktree_branches uses explicit repo root outside repo" { + local base_dir="${TEST_REPO}-worktrees" + create_worktree "$base_dir" "" "outside-context" "HEAD" "none" "1" "0" "" "" >/dev/null + cd /tmp || false + + local branches + branches=$(list_worktree_branches "$base_dir" "" "$TEST_REPO") + + [[ "$branches" == *"outside-context"* ]] +} + @test "resolve_target finds worktree by branch name" { local default_branch default_branch=$(git rev-parse --abbrev-ref HEAD)