diff --git a/README.md b/README.md index 2ade753..03adf33 100644 --- a/README.md +++ b/README.md @@ -111,8 +111,8 @@ git gtr list # Remove when done git gtr rm my-feature -# Or remove all worktrees with merged PRs/MRs (requires gh or glab CLI) -git gtr clean --merged +# Or remove all worktrees with merged or closed PRs/MRs (requires gh or glab CLI) +git gtr clean --merged --closed ``` ## Why gtr? @@ -321,27 +321,29 @@ git gtr config list # List all gtr config ### `git gtr clean [options]` -Remove worktrees: clean up empty directories, or remove those with merged PRs/MRs. +Remove worktrees: clean up empty directories, or remove those with merged or closed PRs/MRs. ```bash git gtr clean # Remove empty worktree directories and prune git gtr clean --merged # Remove worktrees for merged PRs/MRs -git gtr clean --merged --to main # Only remove worktrees merged to main +git gtr clean --closed # Remove worktrees for closed PRs/MRs +git gtr clean --merged --closed --to main # Remove worktrees for merged or closed PRs/MRs targeting main git gtr clean --merged --dry-run # Preview which worktrees would be removed git gtr clean --merged --yes # Remove without confirmation prompts -git gtr clean --merged --force # Force-clean merged, ignoring local changes +git gtr clean --merged --force # Force-clean PR cleanup, ignoring local changes git gtr clean --merged --force --yes # Force-clean and auto-confirm ``` **Options:** - `--merged`: Remove worktrees whose branches have merged PRs/MRs (also deletes the branch) -- `--to `: Limit `--merged` cleanup to PRs/MRs merged into the given base ref +- `--closed`: Remove worktrees whose branches have closed PRs/MRs (also deletes the branch) +- `--to `: Limit PR/MR cleanup to PRs/MRs targeting the given base ref - `--dry-run`, `-n`: Preview changes without removing - `--yes`, `-y`: Non-interactive mode (skip confirmation prompts) - `--force`, `-f`: Force removal even if worktree has uncommitted changes or untracked files -**Note:** The `--merged` mode auto-detects your hosting provider (GitHub or GitLab) from the `origin` remote URL and requires the corresponding CLI tool (`gh` or `glab`) to be installed and authenticated. For self-hosted instances, set the provider explicitly: `git gtr config set gtr.provider gitlab`. +**Note:** The `--merged`/`--closed` mode auto-detects your hosting provider (GitHub or GitLab) from the `origin` remote URL and requires the corresponding CLI tool (`gh` or `glab`) to be installed and authenticated. For self-hosted instances, set the provider explicitly: `git gtr config set gtr.provider gitlab`. **Note:** `clean` also detects registry entries that are locked but whose directories no longer exist (for example, a crashed agent session that deleted its worktree directory). `git worktree prune` skips locked entries by design, so these linger and keep their branches checked out. `clean` offers to unlock and prune them; `--force` or `--yes` confirms automatically. diff --git a/completions/_git-gtr b/completions/_git-gtr index 0ba9c9a..ac3c6b5 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -82,7 +82,8 @@ _git-gtr() { if (( CURRENT >= 4 )) && [[ $words[3] == clean ]]; then _arguments \ '--merged[Remove worktrees with merged PRs/MRs]' \ - '--to[Only remove worktrees for PRs/MRs merged into this ref]:ref:' \ + '--closed[Remove worktrees with closed PRs/MRs]' \ + '--to[Only remove worktrees for PRs/MRs targeting this ref]:ref:' \ '--yes[Skip confirmation prompts]' \ '-y[Skip confirmation prompts]' \ '--dry-run[Show what would be removed]' \ diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish index 98c746c..ca9e464 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -101,7 +101,8 @@ complete -c git -n '__fish_git_gtr_using_command ai' -l ai -d 'AI tool to use' - # Clean command options complete -c git -n '__fish_git_gtr_using_command clean' -l merged -d 'Remove worktrees with merged PRs/MRs' -complete -c git -n '__fish_git_gtr_using_command clean' -l to -d 'Only remove worktrees for PRs/MRs merged into this ref' -r +complete -c git -n '__fish_git_gtr_using_command clean' -l closed -d 'Remove worktrees with closed PRs/MRs' +complete -c git -n '__fish_git_gtr_using_command clean' -l to -d 'Only remove worktrees for PRs/MRs targeting this ref' -r complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirmation prompts' complete -c git -n '__fish_git_gtr_using_command clean' -s y -d 'Skip confirmation prompts' complete -c git -n '__fish_git_gtr_using_command clean' -l dry-run -d 'Show what would be removed' diff --git a/completions/gtr.bash b/completions/gtr.bash index 2e78583..329b06f 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -82,7 +82,7 @@ _git_gtr() { ;; clean) if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--merged --to --yes -y --dry-run -n --force -f" -- "$cur")) + COMPREPLY=($(compgen -W "--merged --closed --to --yes -y --dry-run -n --force -f" -- "$cur")) fi ;; copy) diff --git a/docs/configuration.md b/docs/configuration.md index 0fc24c5..25c81b5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,7 +112,7 @@ echo "/.worktrees/" >> .gitignore ## Provider Settings -The `clean --merged` command auto-detects your hosting provider from the `origin` remote URL (`github.com` → GitHub, `gitlab.com` → GitLab). For self-hosted instances, set the provider explicitly: +The `clean --merged` and `clean --closed` commands auto-detect your hosting provider from the `origin` remote URL (`github.com` → GitHub, `gitlab.com` → GitLab). For self-hosted instances, set the provider explicitly: ```bash # Override auto-detected hosting provider (github or gitlab) diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh index ff3de22..8914753 100644 --- a/lib/commands/clean.sh +++ b/lib/commands/clean.sh @@ -28,7 +28,7 @@ _clean_detect_provider() { return 1 } -# Check if a worktree should be skipped during merged cleanup. +# Check if a worktree should be skipped during PR/MR cleanup. # Returns 0 if should skip, 1 if should process. # Usage: _clean_should_skip [force] [active_worktree_path] _clean_should_skip() { @@ -122,16 +122,42 @@ EOF fi } -# Remove worktrees whose PRs/MRs are merged (handles squash merges) -# Usage: _clean_merged repo_root base_dir prefix yes_mode dry_run [force] [active_worktree_path] [target_ref] -_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:-}" +# Check if a branch has any requested PR/MR cleanup state. +# Returns 0 if matched, 1 if not. +# Usage: _clean_branch_matches_pr_state provider branch target_ref branch_tip merged_mode closed_mode +_clean_branch_matches_pr_state() { + local provider="$1" branch="$2" target_ref="$3" branch_tip="$4" merged_mode="$5" closed_mode="$6" - # base_dir and prefix are kept for the helper contract. Merged cleanup uses + if [ "$merged_mode" -eq 1 ] && check_branch_merged "$provider" "$branch" "$target_ref" "$branch_tip"; then + return 0 + fi + + if [ "$closed_mode" -eq 1 ] && check_branch_closed "$provider" "$branch" "$target_ref" "$branch_tip"; then + return 0 + fi + + return 1 +} + +# Remove worktrees whose PRs/MRs match requested cleanup states (handles squash merges) +# Usage: _clean_prs repo_root base_dir prefix yes_mode dry_run [force] [active_worktree_path] [target_ref] [merged_mode] [closed_mode] +_clean_prs() { + 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:-}" merged_mode="${9:-0}" closed_mode="${10:-0}" + + # base_dir and prefix are kept for the helper contract. PR/MR 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 cleanup_label="matching" + if [ "$merged_mode" -eq 1 ] && [ "$closed_mode" -eq 1 ]; then + cleanup_label="merged or closed" + elif [ "$merged_mode" -eq 1 ]; then + cleanup_label="merged" + elif [ "$closed_mode" -eq 1 ]; then + cleanup_label="closed" + fi + + log_step "Checking for worktrees with $cleanup_label PRs/MRs..." local provider provider=$(_clean_detect_provider) || exit 1 @@ -157,13 +183,13 @@ _clean_merged() { # 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 _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then + skipped=$((skipped + 1)) + continue + fi + # Check if branch has a PR/MR matching any requested state + if _clean_branch_matches_pr_state "$provider" "$branch" "$target_ref" "$branch_tip" "$merged_mode" "$closed_mode"; then if [ "$dry_run" -eq 1 ]; then log_info "[dry-run] Would remove: $branch ($dir)" removed=$((removed + 1)) @@ -219,7 +245,7 @@ EOF if [ "$dry_run" -eq 1 ]; then log_info "Dry run complete. Would remove: $removed, Skipped: $skipped" else - log_info "Merged cleanup complete. Removed: $removed, Skipped: $skipped" + log_info "PR cleanup complete. Removed: $removed, Skipped: $skipped" fi } @@ -227,6 +253,7 @@ EOF cmd_clean() { local _spec _spec="--merged +--closed --to: value --yes|-y --dry-run|-n @@ -234,14 +261,15 @@ cmd_clean() { parse_args "$_spec" "$@" local merged_mode="${_arg_merged:-0}" + local closed_mode="${_arg_closed:-0}" local target_ref="${_arg_to:-}" local yes_mode="${_arg_yes:-0}" local dry_run="${_arg_dry_run:-0}" local force="${_arg_force:-0}" local active_worktree_path="" - if [ -n "$target_ref" ] && [ "$merged_mode" -ne 1 ]; then - log_error "--to can only be used with --merged" + if [ -n "$target_ref" ] && [ "$merged_mode" -ne 1 ] && [ "$closed_mode" -ne 1 ]; then + log_error "--to can only be used with --merged or --closed" return 1 fi @@ -294,8 +322,8 @@ EOF log_info "Cleanup complete (no empty directories found)" fi - # --merged mode: remove worktrees with merged PRs/MRs (handles squash merges) - if [ "$merged_mode" -eq 1 ]; then - _clean_merged "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run" "$force" "$active_worktree_path" "$target_ref" + # PR/MR cleanup mode: remove worktrees with matching PRs/MRs (handles squash merges) + if [ "$merged_mode" -eq 1 ] || [ "$closed_mode" -eq 1 ]; then + _clean_prs "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run" "$force" "$active_worktree_path" "$target_ref" "$merged_mode" "$closed_mode" fi } diff --git a/lib/commands/doctor.sh b/lib/commands/doctor.sh index da49668..f2b01b6 100644 --- a/lib/commands/doctor.sh +++ b/lib/commands/doctor.sh @@ -96,19 +96,19 @@ cmd_doctor() { if command -v gh >/dev/null 2>&1; then echo "[OK] GitHub CLI: $(gh --version 2>/dev/null | head -1)" else - echo "[!] GitHub CLI: not found (needed for: clean --merged)" + echo "[!] GitHub CLI: not found (needed for: clean --merged/--closed)" fi ;; gitlab) if command -v glab >/dev/null 2>&1; then echo "[OK] GitLab CLI: $(glab --version 2>/dev/null | head -1)" else - echo "[!] GitLab CLI: not found (needed for: clean --merged)" + echo "[!] GitLab CLI: not found (needed for: clean --merged/--closed)" fi ;; esac else - echo "[i] Provider: unknown (set gtr.provider for clean --merged)" + echo "[i] Provider: unknown (set gtr.provider for clean --merged/--closed)" fi fi diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 66657d3..9af8711 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -308,8 +308,8 @@ git gtr clean - Remove stale worktrees Usage: git gtr clean [options] Removes empty worktree directories and optionally removes worktrees whose -PRs/MRs have been merged. Auto-detects GitHub (gh) or GitLab (glab) from -the remote URL. +PRs/MRs have been merged or closed. Auto-detects GitHub (gh) or GitLab +(glab) from the remote URL. Also detects registry entries that are locked but whose directories no longer exist (git worktree prune skips locked entries) and offers to @@ -317,7 +317,8 @@ unlock and prune them. Confirmed automatically with --force or --yes. Options: --merged Also remove worktrees with merged PRs/MRs - --to Only remove worktrees for PRs/MRs merged into + --closed Also remove worktrees with closed PRs/MRs + --to Only remove worktrees for PRs/MRs targeting --yes, -y Skip confirmation prompts --dry-run, -n Show what would be removed without removing --force, -f Force removal even if worktree has uncommitted changes or untracked files @@ -325,10 +326,11 @@ Options: Examples: git gtr clean # Clean empty directories git gtr clean --merged # Also clean merged PRs - git gtr clean --merged --to main # Only clean PRs merged to main - git gtr clean --merged --dry-run # Preview merged cleanup + git gtr clean --closed # Also clean closed PRs + git gtr clean --merged --closed --to main # Clean merged or closed PRs targeting main + git gtr clean --merged --dry-run # Preview PR cleanup git gtr clean --merged --yes # Auto-confirm everything - git gtr clean --merged --force # Force-clean merged, ignoring local changes + git gtr clean --merged --force # Force-clean, ignoring local changes git gtr clean --merged --force --yes # Force-clean and auto-confirm EOF } @@ -606,7 +608,8 @@ SETUP & MAINTENANCE: clean [options] Remove stale/prunable worktrees and empty directories --merged: also remove worktrees with merged PRs/MRs - --to : limit merged cleanup to PRs/MRs merged into + --closed: also remove worktrees with closed PRs/MRs + --to : limit PR cleanup to PRs/MRs targeting Auto-detects GitHub (gh) or GitLab (glab) from remote URL Override: git gtr config set gtr.provider gitlab --yes, -y: skip confirmation prompts diff --git a/lib/provider.sh b/lib/provider.sh index b78d4d2..ce45678 100755 --- a/lib/provider.sh +++ b/lib/provider.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Remote hosting provider detection and CLI integration -# Used by cmd_clean --merged to support GitHub (gh) and GitLab (glab) +# Used by cmd_clean --merged/--closed to support GitHub (gh) and GitLab (glab) # Extract hostname from a git remote URL # Handles SSH shorthand, SSH with scheme, and HTTPS: @@ -126,39 +126,86 @@ normalize_target_ref() { esac } -# Check if a branch has a merged PR/MR on the detected provider. -# When branch_tip is provided, require the merged PR/MR to point at the same -# commit so reused branch names do not match older merged PRs. -# Usage: check_branch_merged [target_ref] [branch_tip] -# Returns 0 if merged, 1 if not -check_branch_merged() { +# Check whether GitLab MR JSON includes a source-head SHA matching branch_tip. +# Uses jq when available; otherwise falls back to exact JSON key extraction to +# keep GitLab cleanup usable on systems without jq. +# Usage: _gitlab_mr_matches_tip +_gitlab_mr_matches_tip() { + local mr_result="$1" + local branch_tip="$2" + local mr_matches + + if command -v jq >/dev/null 2>&1; then + mr_matches=$(printf "%s" "$mr_result" | jq --arg branch_tip "$branch_tip" 'map(select((.sha // "") == $branch_tip or (.head_sha // "") == $branch_tip or (.diff_refs.head_sha // "") == $branch_tip)) | length' 2>/dev/null || true) + [ "${mr_matches:-0}" -gt 0 ] + return + fi + + local compact_result objects object sha_field head_sha_field diff_refs + compact_result=$(printf "%s" "$mr_result" | tr -d '[:space:]') + objects=$(printf "%s" "$compact_result" | sed 's/},{/}\ +{/g') + + while IFS= read -r object; do + sha_field=$(printf "%s" "$object" | sed -n 's/^[^{]*{[^{}]*"sha":"\([^"]*\)".*/\1/p') + [ "$sha_field" = "$branch_tip" ] && return 0 + + head_sha_field=$(printf "%s" "$object" | sed -n 's/^[^{]*{[^{}]*"head_sha":"\([^"]*\)".*/\1/p') + [ "$head_sha_field" = "$branch_tip" ] && return 0 + + diff_refs=$(printf "%s" "$object" | sed -n 's/.*"diff_refs":{\([^}]*\)}.*/\1/p') + case "$diff_refs" in + *"\"head_sha\":\"$branch_tip\""*) + return 0 + ;; + esac + done < [target_ref] [branch_tip] +# Returns 0 if found, 1 if not +check_branch_pr_state() { local provider="$1" local branch="$2" - local target_ref="${3:-}" - local branch_tip="${4:-}" + local pr_state="$3" + local target_ref="${4:-}" + local branch_tip="${5:-}" local normalized_target_ref + case "$pr_state" in + merged|closed) ;; + *) return 1 ;; + esac + normalized_target_ref=$(normalize_target_ref "$target_ref") || true case "$provider" in github) local -a gh_args - local pr_matches - gh_args=(pr list --head "$branch" --state merged --limit 1000) + local expected_state pr_matches + expected_state=$(printf "%s" "$pr_state" | tr '[:lower:]' '[:upper:]') + gh_args=(pr list --head "$branch" --state "$pr_state" --limit 1000) if [ -n "$normalized_target_ref" ]; then gh_args+=(--base "$normalized_target_ref") fi if [ -n "$branch_tip" ]; then - pr_matches=$(gh "${gh_args[@]}" --json state,headRefOid --jq "map(select(.state == \"MERGED\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true) + pr_matches=$(gh "${gh_args[@]}" --json state,headRefOid --jq "map(select(.state == \"$expected_state\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true) else - pr_matches=$(gh "${gh_args[@]}" --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true) + pr_matches=$(gh "${gh_args[@]}" --json state --jq "map(select(.state == \"$expected_state\")) | length" 2>/dev/null || true) fi [ "${pr_matches:-0}" -gt 0 ] ;; gitlab) - local mr_result compact_result + local mr_result local -a glab_args - glab_args=(mr list --source-branch "$branch" --merged --all --output json) + glab_args=(mr list --source-branch "$branch" "--$pr_state" --all --output json) if [ -n "$normalized_target_ref" ]; then glab_args+=(--target-branch "$normalized_target_ref") fi @@ -167,15 +214,8 @@ check_branch_merged() { [ -n "$mr_result" ] && [ "$mr_result" != "[]" ] && [ "$mr_result" != "null" ] || return 1 if [ -n "$branch_tip" ]; then - compact_result=$(printf "%s" "$mr_result" | tr -d '[:space:]') - case "$compact_result" in - *"\"sha\":\"$branch_tip\""*|*"\"head_sha\":\"$branch_tip\""*) - return 0 - ;; - *) - return 1 - ;; - esac + _gitlab_mr_matches_tip "$mr_result" "$branch_tip" + return fi return 0 @@ -185,3 +225,15 @@ check_branch_merged() { ;; esac } + +# Check if a branch has a merged PR/MR on the detected provider. +# Usage: check_branch_merged [target_ref] [branch_tip] +check_branch_merged() { + check_branch_pr_state "$1" "$2" merged "${3:-}" "${4:-}" +} + +# Check if a branch has a closed, unmerged PR/MR on the detected provider. +# Usage: check_branch_closed [target_ref] [branch_tip] +check_branch_closed() { + check_branch_pr_state "$1" "$2" closed "${3:-}" "${4:-}" +} diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index 0745cd2..f324d54 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -176,7 +176,7 @@ MIDDLE1 ;; clean) if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--merged --to --yes -y --dry-run -n --force -f" -- "$cur")) + COMPREPLY=($(compgen -W "--merged --closed --to --yes -y --dry-run -n --force -f" -- "$cur")) fi ;; copy) @@ -341,7 +341,8 @@ _git-gtr() { if (( CURRENT >= 4 )) && [[ $words[3] == clean ]]; then _arguments \ '--merged[Remove worktrees with merged PRs/MRs]' \ - '--to[Only remove worktrees for PRs/MRs merged into this ref]:ref:' \ + '--closed[Remove worktrees with closed PRs/MRs]' \ + '--to[Only remove worktrees for PRs/MRs targeting this ref]:ref:' \ '--yes[Skip confirmation prompts]' \ '-y[Skip confirmation prompts]' \ '--dry-run[Show what would be removed]' \ @@ -583,7 +584,8 @@ MIDDLE1 # Clean command options complete -c git -n '__fish_git_gtr_using_command clean' -l merged -d 'Remove worktrees with merged PRs/MRs' -complete -c git -n '__fish_git_gtr_using_command clean' -l to -d 'Only remove worktrees for PRs/MRs merged into this ref' -r +complete -c git -n '__fish_git_gtr_using_command clean' -l closed -d 'Remove worktrees with closed PRs/MRs' +complete -c git -n '__fish_git_gtr_using_command clean' -l to -d 'Only remove worktrees for PRs/MRs targeting this ref' -r complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirmation prompts' complete -c git -n '__fish_git_gtr_using_command clean' -s y -d 'Skip confirmation prompts' complete -c git -n '__fish_git_gtr_using_command clean' -l dry-run -d 'Show what would be removed' diff --git a/tests/cmd_clean.bats b/tests/cmd_clean.bats index 173d2bb..ccf9dad 100644 --- a/tests/cmd_clean.bats +++ b/tests/cmd_clean.bats @@ -123,10 +123,10 @@ teardown() { [ "$status" -eq 0 ] } -@test "cmd_clean rejects --to without --merged" { +@test "cmd_clean rejects --to without PR state filter" { run cmd_clean --to main [ "$status" -eq 1 ] - [[ "$output" == *"--to can only be used with --merged"* ]] + [[ "$output" == *"--to can only be used with --merged or --closed"* ]] } @test "cmd_clean --merged --force removes dirty merged worktrees" { @@ -185,6 +185,43 @@ teardown() { [ -d "$TEST_WORKTREES_DIR/merged-to-feature" ] } +@test "cmd_clean --closed --to removes closed worktrees" { + create_test_worktree "closed-to-main" + create_test_worktree "closed-to-feature" + + _clean_detect_provider() { printf "github"; } + ensure_provider_cli() { return 0; } + check_branch_closed() { + [ "$3" = "main" ] && [ "$2" = "closed-to-main" ] + } + run_hooks_in() { return 0; } + run_hooks() { return 0; } + + run cmd_clean --closed --to main --yes + [ "$status" -eq 0 ] + [ ! -d "$TEST_WORKTREES_DIR/closed-to-main" ] + [ -d "$TEST_WORKTREES_DIR/closed-to-feature" ] +} + +@test "cmd_clean --merged --closed removes merged OR closed worktrees" { + create_test_worktree "merged-match" + create_test_worktree "closed-match" + create_test_worktree "open-match" + + _clean_detect_provider() { printf "github"; } + ensure_provider_cli() { return 0; } + check_branch_merged() { [ "$2" = "merged-match" ]; } + check_branch_closed() { [ "$2" = "closed-match" ]; } + run_hooks_in() { return 0; } + run_hooks() { return 0; } + + run cmd_clean --merged --closed --yes --force --to main + [ "$status" -eq 0 ] + [ ! -d "$TEST_WORKTREES_DIR/merged-match" ] + [ ! -d "$TEST_WORKTREES_DIR/closed-match" ] + [ -d "$TEST_WORKTREES_DIR/open-match" ] +} + @test "cmd_clean passes current branch HEAD to merged check" { create_test_worktree "merged-tip" local branch_tip @@ -201,18 +238,19 @@ teardown() { [ ! -d "$TEST_WORKTREES_DIR/merged-tip" ] } -@test "cmd_clean does not log dirty skip for non-merged worktree" { - create_test_worktree "dirty-not-merged" - echo "dirty" > "$TEST_WORKTREES_DIR/dirty-not-merged/dirty.txt" - git -C "$TEST_WORKTREES_DIR/dirty-not-merged" add dirty.txt +@test "cmd_clean skips dirty worktree before provider lookup" { + create_test_worktree "dirty-before-provider" + echo "dirty" > "$TEST_WORKTREES_DIR/dirty-before-provider/dirty.txt" + git -C "$TEST_WORKTREES_DIR/dirty-before-provider" add dirty.txt _clean_detect_provider() { printf "github"; } ensure_provider_cli() { return 0; } - check_branch_merged() { return 1; } + check_branch_merged() { printf "provider lookup should not run"; return 1; } run cmd_clean --merged --to main --yes [ "$status" -eq 0 ] - [[ "$output" != *"dirty-not-merged"* ]] + [[ "$output" == *"dirty-before-provider"* ]] + [[ "$output" != *"provider lookup should not run"* ]] } # ── Locked entries with missing directories (#180) ────────────────────────── diff --git a/tests/cmd_help.bats b/tests/cmd_help.bats index 8b23f42..ad52c9a 100644 --- a/tests/cmd_help.bats +++ b/tests/cmd_help.bats @@ -81,6 +81,7 @@ teardown() { [ "$status" -eq 0 ] [[ "$output" == *"git gtr clean"* ]] [[ "$output" == *"--merged"* ]] + [[ "$output" == *"--closed"* ]] [[ "$output" == *"--to "* ]] } diff --git a/tests/provider.bats b/tests/provider.bats index 6231ef5..5937e64 100644 --- a/tests/provider.bats +++ b/tests/provider.bats @@ -64,13 +64,17 @@ setup() { } @test "normalize_target_ref strips remote prefix when remote ref exists" { - git remote remove upstream >/dev/null 2>&1 || true - run git remote add upstream https://example.com/repo.git - [ "$status" -eq 0 ] - run git update-ref refs/remotes/upstream/main HEAD - [ "$status" -eq 0 ] + local test_repo + test_repo=$(mktemp -d) + git -C "$test_repo" init --quiet + git -C "$test_repo" config user.name "Test User" + git -C "$test_repo" config user.email "test@example.com" + git -C "$test_repo" commit --allow-empty -m init --quiet + git -C "$test_repo" remote add upstream https://example.com/repo.git + git -C "$test_repo" update-ref refs/remotes/upstream/main HEAD - result=$(normalize_target_ref "upstream/main") + result=$(cd "$test_repo" && normalize_target_ref "upstream/main") + rm -rf "$test_repo" [ "$result" = "main" ] } @@ -108,6 +112,30 @@ setup() { [ "$status" -eq 1 ] } +@test "check_branch_closed passes closed state and branch tip to gh" { + gh() { + [ "$1" = "pr" ] || return 1 + [ "$2" = "list" ] || return 1 + [ "$3" = "--head" ] || return 1 + [ "$4" = "feature/test" ] || return 1 + [ "$5" = "--state" ] || return 1 + [ "$6" = "closed" ] || return 1 + [ "$7" = "--limit" ] || return 1 + [ "$8" = "1000" ] || return 1 + [ "$9" = "--base" ] || return 1 + [ "${10}" = "main" ] || return 1 + [ "${11}" = "--json" ] || return 1 + [ "${12}" = "state,headRefOid" ] || return 1 + [ "${13}" = "--jq" ] || return 1 + [[ "${14}" == *'.state == "CLOSED"'* ]] || return 1 + [[ "${14}" == *'.headRefOid == "abc123"'* ]] || return 1 + printf "1" + } + + run check_branch_closed github feature/test main abc123 + [ "$status" -eq 0 ] +} + @test "check_branch_merged passes target branch and branch tip to glab" { glab() { [ "$1" = "mr" ] || return 1 @@ -146,6 +174,53 @@ setup() { [ "$status" -eq 1 ] } +@test "check_branch_closed passes target branch and branch tip to glab" { + glab() { + [ "$1" = "mr" ] || return 1 + [ "$2" = "list" ] || return 1 + [ "$3" = "--source-branch" ] || return 1 + [ "$4" = "feature/test" ] || return 1 + [ "$5" = "--closed" ] || return 1 + [ "$6" = "--all" ] || return 1 + [ "$7" = "--output" ] || return 1 + [ "$8" = "json" ] || return 1 + [ "${9}" = "--target-branch" ] || return 1 + [ "${10}" = "main" ] || return 1 + printf '[{"iid":1,"sha":"abc123"}]' + } + + run check_branch_closed gitlab feature/test main abc123 + [ "$status" -eq 0 ] +} + +@test "check_branch_merged accepts GitLab top-level head SHA without jq" { + local mock_bin old_path + mock_bin=$(mktemp -d) + ln -s "$(command -v sed)" "$mock_bin/sed" + ln -s "$(command -v tr)" "$mock_bin/tr" + old_path="$PATH" + PATH="$mock_bin" + + glab() { + [ "$1" = "mr" ] || return 1 + [ "$2" = "list" ] || return 1 + [ "$3" = "--source-branch" ] || return 1 + [ "$4" = "feature/test" ] || return 1 + [ "$5" = "--merged" ] || return 1 + [ "$6" = "--all" ] || return 1 + [ "$7" = "--output" ] || return 1 + [ "$8" = "json" ] || return 1 + [ "${9}" = "--target-branch" ] || return 1 + [ "${10}" = "main" ] || return 1 + printf '[{"iid":1,"head_sha":"abc123"}]' + } + + run check_branch_merged gitlab feature/test main abc123 + PATH="$old_path" + rm -rf "$mock_bin" + [ "$status" -eq 0 ] +} + @test "check_branch_merged accepts GitLab diff_refs head SHA matches" { glab() { [ "$1" = "mr" ] || return 1 @@ -165,6 +240,27 @@ setup() { [ "$status" -eq 0 ] } +@test "check_branch_merged ignores unrelated GitLab nested head SHA fields" { + command -v jq >/dev/null 2>&1 || skip "jq is required for structural GitLab JSON validation" + + glab() { + [ "$1" = "mr" ] || return 1 + [ "$2" = "list" ] || return 1 + [ "$3" = "--source-branch" ] || return 1 + [ "$4" = "feature/test" ] || return 1 + [ "$5" = "--merged" ] || return 1 + [ "$6" = "--all" ] || return 1 + [ "$7" = "--output" ] || return 1 + [ "$8" = "json" ] || return 1 + [ "${9}" = "--target-branch" ] || return 1 + [ "${10}" = "main" ] || return 1 + printf '[{"iid":1,"sha":"old123","metadata":{"head_sha":"abc123"}}]' + } + + run check_branch_merged gitlab feature/test main abc123 + [ "$status" -eq 1 ] +} + @test "check_branch_merged still accepts GitLab merged MR without branch tip" { glab() { [ "$1" = "mr" ] || return 1