diff --git a/README.md b/README.md index d1a7ff4..c38e391 100644 --- a/README.md +++ b/README.md @@ -325,6 +325,7 @@ Remove worktrees: clean up empty directories, or remove those with merged PRs/MR ```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 --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 @@ -334,6 +335,7 @@ 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 - `--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 diff --git a/completions/_git-gtr b/completions/_git-gtr index e0a5ba3..676338a 100644 --- a/completions/_git-gtr +++ b/completions/_git-gtr @@ -80,6 +80,7 @@ _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:' \ '--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 7fdc786..ca7e8f8 100644 --- a/completions/git-gtr.fish +++ b/completions/git-gtr.fish @@ -99,6 +99,7 @@ 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 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 13314cf..8bcba08 100644 --- a/completions/gtr.bash +++ b/completions/gtr.bash @@ -82,7 +82,7 @@ _git_gtr() { ;; clean) if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--merged --yes -y --dry-run -n --force -f" -- "$cur")) + COMPREPLY=($(compgen -W "--merged --to --yes -y --dry-run -n --force -f" -- "$cur")) fi ;; copy) diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh index 477d07d..2c3c1b1 100644 --- a/lib/commands/clean.sh +++ b/lib/commands/clean.sh @@ -68,9 +68,9 @@ _clean_should_skip() { } # 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] +# 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:-}" + 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:-}" log_step "Checking for worktrees with merged PRs/MRs..." @@ -90,17 +90,19 @@ _clean_merged() { 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 - if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then - skipped=$((skipped + 1)) - continue - fi - # Check if branch has a merged PR/MR - if check_branch_merged "$provider" "$branch"; then + 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)) @@ -146,17 +148,24 @@ _clean_merged() { cmd_clean() { local _spec _spec="--merged +--to: value --yes|-y --dry-run|-n --force|-f" parse_args "$_spec" "$@" local merged_mode="${_arg_merged:-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" + return 1 + fi + log_step "Cleaning up stale worktrees..." # Run git worktree prune @@ -204,6 +213,6 @@ EOF # --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" + _clean_merged "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run" "$force" "$active_worktree_path" "$target_ref" fi } diff --git a/lib/commands/help.sh b/lib/commands/help.sh index c680987..1c99694 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -303,6 +303,7 @@ the remote URL. Options: --merged Also remove worktrees with merged PRs/MRs + --to Only remove worktrees for PRs/MRs merged into --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 @@ -310,6 +311,7 @@ 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 --merged --yes # Auto-confirm everything git gtr clean --merged --force # Force-clean merged, ignoring local changes @@ -566,6 +568,7 @@ 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 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 e28cdf3..53d5be3 100755 --- a/lib/provider.sh +++ b/lib/provider.sh @@ -97,23 +97,96 @@ ensure_provider_cli() { esac } -# Check if a branch has a merged PR/MR on the detected provider -# Usage: check_branch_merged +# Normalize user-provided refs to plain branch names for provider filters. +# Usage: normalize_target_ref [target_ref] +normalize_target_ref() { + local target_ref="${1:-}" + local remote_ref + + [ -n "$target_ref" ] || return 0 + + case "$target_ref" in + refs/heads/*) + printf "%s" "${target_ref#refs/heads/}" + ;; + refs/remotes/*) + remote_ref="${target_ref#refs/remotes/}" + printf "%s" "${remote_ref#*/}" + ;; + origin/*|upstream/*) + printf "%s" "${target_ref#*/}" + ;; + *) + if git show-ref --verify --quiet "refs/remotes/$target_ref" 2>/dev/null; then + printf "%s" "${target_ref#*/}" + else + printf "%s" "$target_ref" + fi + ;; + 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() { local provider="$1" local branch="$2" + local target_ref="${3:-}" + local branch_tip="${4:-}" + local normalized_target_ref + + normalized_target_ref=$(normalize_target_ref "$target_ref") || true case "$provider" in github) - local pr_state - pr_state=$(gh pr list --head "$branch" --state merged --json state --jq '.[0].state' 2>/dev/null || true) - [ "$pr_state" = "MERGED" ] + local -a gh_args + local pr_matches + gh_args=(pr list --head "$branch" --state merged --limit 1000) + if [ -n "$normalized_target_ref" ]; then + gh_args+=(--base "$normalized_target_ref") + fi + if [ -n "$normalized_target_ref" ]; then + 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) + else + pr_matches=$(gh "${gh_args[@]}" --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true) + fi + else + 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) + else + pr_matches=$(gh "${gh_args[@]}" --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true) + fi + fi + [ "${pr_matches:-0}" -gt 0 ] ;; gitlab) - local mr_result - mr_result=$(glab mr list --source-branch "$branch" --merged --per-page 1 --output json 2>/dev/null || true) - [ -n "$mr_result" ] && [ "$mr_result" != "[]" ] && [ "$mr_result" != "null" ] + local mr_result compact_result + local -a glab_args + glab_args=(mr list --source-branch "$branch" --merged --all --output json) + if [ -n "$normalized_target_ref" ]; then + glab_args+=(--target-branch "$normalized_target_ref") + fi + + mr_result=$(glab "${glab_args[@]}" 2>/dev/null || true) + [ -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 + fi + + return 0 ;; *) return 1 diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh index 1072645..d99d41d 100755 --- a/scripts/generate-completions.sh +++ b/scripts/generate-completions.sh @@ -176,7 +176,7 @@ MIDDLE1 ;; clean) if [[ "$cur" == -* ]]; then - COMPREPLY=($(compgen -W "--merged --yes -y --dry-run -n --force -f" -- "$cur")) + COMPREPLY=($(compgen -W "--merged --to --yes -y --dry-run -n --force -f" -- "$cur")) fi ;; copy) @@ -339,6 +339,7 @@ _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:' \ '--yes[Skip confirmation prompts]' \ '-y[Skip confirmation prompts]' \ '--dry-run[Show what would be removed]' \ @@ -578,6 +579,7 @@ 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 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 59160c2..5b8ef5b 100644 --- a/tests/cmd_clean.bats +++ b/tests/cmd_clean.bats @@ -123,6 +123,12 @@ teardown() { [ "$status" -eq 0 ] } +@test "cmd_clean rejects --to without --merged" { + run cmd_clean --to main + [ "$status" -eq 1 ] + [[ "$output" == *"--to can only be used with --merged"* ]] +} + @test "cmd_clean --merged --force removes dirty merged worktrees" { create_test_worktree "merged-force" echo "dirty" > "$TEST_WORKTREES_DIR/merged-force/dirty.txt" @@ -130,7 +136,7 @@ teardown() { _clean_detect_provider() { printf "github"; } ensure_provider_cli() { return 0; } - check_branch_merged() { [ "$2" = "merged-force" ]; } + check_branch_merged() { [ "$2" = "merged-force" ] && [ -z "$3" ]; } run_hooks_in() { return 0; } run_hooks() { return 0; } @@ -139,6 +145,54 @@ teardown() { [ ! -d "$TEST_WORKTREES_DIR/merged-force" ] } +@test "cmd_clean --merged --to filters by target ref" { + create_test_worktree "merged-to-main" + create_test_worktree "merged-to-feature" + + _clean_detect_provider() { printf "github"; } + ensure_provider_cli() { return 0; } + check_branch_merged() { + [ "$3" = "main" ] && [ "$2" = "merged-to-main" ] + } + run_hooks_in() { return 0; } + run_hooks() { return 0; } + + run cmd_clean --merged --to main --yes + [ "$status" -eq 0 ] + [ ! -d "$TEST_WORKTREES_DIR/merged-to-main" ] + [ -d "$TEST_WORKTREES_DIR/merged-to-feature" ] +} + +@test "cmd_clean passes current branch HEAD to merged check" { + create_test_worktree "merged-tip" + local branch_tip + branch_tip=$(git -C "$TEST_WORKTREES_DIR/merged-tip" rev-parse HEAD) + + _clean_detect_provider() { printf "github"; } + ensure_provider_cli() { return 0; } + check_branch_merged() { [ "$2" = "merged-tip" ] && [ "$3" = "main" ] && [ "$4" = "$branch_tip" ]; } + run_hooks_in() { return 0; } + run_hooks() { return 0; } + + run cmd_clean --merged --to main --yes + [ "$status" -eq 0 ] + [ ! -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 + + _clean_detect_provider() { printf "github"; } + ensure_provider_cli() { return 0; } + check_branch_merged() { return 1; } + + run cmd_clean --merged --to main --yes + [ "$status" -eq 0 ] + [[ "$output" != *"dirty-not-merged"* ]] +} + @test "cmd_clean --merged --force skips the current active worktree" { create_test_worktree "active-merged" cd "$TEST_WORKTREES_DIR/active-merged" || false @@ -147,7 +201,7 @@ teardown() { _clean_detect_provider() { printf "github"; } ensure_provider_cli() { return 0; } - check_branch_merged() { [ "$2" = "active-merged" ]; } + check_branch_merged() { [ "$2" = "active-merged" ] && [ -z "$3" ]; } run_hooks_in() { return 0; } run_hooks() { return 0; } diff --git a/tests/cmd_help.bats b/tests/cmd_help.bats index 274f015..8b23f42 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" == *"--to "* ]] } @test "cmd_help copy shows copy help" { diff --git a/tests/provider.bats b/tests/provider.bats index 3491e80..6231ef5 100644 --- a/tests/provider.bats +++ b/tests/provider.bats @@ -52,3 +52,134 @@ setup() { run extract_hostname "" [ "$status" -ne 0 ] } + +@test "normalize_target_ref strips refs/heads prefix" { + result=$(normalize_target_ref "refs/heads/main") + [ "$result" = "main" ] +} + +@test "normalize_target_ref strips refs/remotes prefix" { + result=$(normalize_target_ref "refs/remotes/origin/release/1.0") + [ "$result" = "release/1.0" ] +} + +@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 ] + + result=$(normalize_target_ref "upstream/main") + [ "$result" = "main" ] +} + +# ── check_branch_merged ─────────────────────────────────────────────────────── + +@test "check_branch_merged passes normalized base ref and limit 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" = "merged" ] || 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}" == *'.headRefOid == "abc123"'* ]] || return 1 + printf "1" + } + + run check_branch_merged github feature/test refs/heads/main abc123 + [ "$status" -eq 0 ] +} + +@test "check_branch_merged rejects reused GitHub branch names with different HEAD" { + gh() { + printf "0" + } + + run check_branch_merged github feature/test main def456 + [ "$status" -eq 1 ] +} + +@test "check_branch_merged 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" = "--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":"abc123"}]' + } + + run check_branch_merged gitlab feature/test origin/main abc123 + [ "$status" -eq 0 ] +} + +@test "check_branch_merged rejects reused GitLab branch names with different HEAD" { + 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"}]' + } + + run check_branch_merged gitlab feature/test main def456 + [ "$status" -eq 1 ] +} + +@test "check_branch_merged accepts GitLab diff_refs head SHA matches" { + 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,"diff_refs":{"head_sha":"abc123"}}]' + } + + run check_branch_merged gitlab feature/test main abc123 + [ "$status" -eq 0 ] +} + +@test "check_branch_merged still accepts GitLab merged MR without branch tip" { + 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}]' + } + + run check_branch_merged gitlab feature/test main + [ "$status" -eq 0 ] +}