From 6f39450fa4c7a385b8349403e355cae7281b34d9 Mon Sep 17 00:00:00 2001 From: nemo Date: Tue, 7 Apr 2026 10:32:53 +0900 Subject: [PATCH 1/5] feat(clean): add --to filter for merged cleanup Assisted-by: gpt-5.4-high on opencode Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- README.md | 2 ++ completions/_git-gtr | 1 + completions/git-gtr.fish | 1 + completions/gtr.bash | 2 +- lib/commands/clean.sh | 10 ++++++---- lib/commands/help.sh | 3 +++ lib/provider.sh | 15 +++++++++++--- tests/cmd_clean.bats | 23 +++++++++++++++++++-- tests/cmd_help.bats | 1 + tests/provider.bats | 43 ++++++++++++++++++++++++++++++++++++++++ 10 files changed, 91 insertions(+), 10 deletions(-) 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..045ea6f 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..." @@ -100,7 +100,7 @@ _clean_merged() { fi # Check if branch has a merged PR/MR - if check_branch_merged "$provider" "$branch"; then + if check_branch_merged "$provider" "$branch" "$target_ref"; then if [ "$dry_run" -eq 1 ]; then log_info "[dry-run] Would remove: $branch ($dir)" removed=$((removed + 1)) @@ -146,12 +146,14 @@ _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}" @@ -204,6 +206,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..de7f773 100755 --- a/lib/provider.sh +++ b/lib/provider.sh @@ -98,21 +98,30 @@ ensure_provider_cli() { } # Check if a branch has a merged PR/MR on the detected provider -# Usage: check_branch_merged +# Usage: check_branch_merged [target_ref] # Returns 0 if merged, 1 if not check_branch_merged() { local provider="$1" local branch="$2" + local target_ref="${3:-}" 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) + if [ -n "$target_ref" ]; then + pr_state=$(gh pr list --head "$branch" --base "$target_ref" --state merged --json state --jq '.[0].state' 2>/dev/null || true) + else + pr_state=$(gh pr list --head "$branch" --state merged --json state --jq '.[0].state' 2>/dev/null || true) + fi [ "$pr_state" = "MERGED" ] ;; gitlab) local mr_result - mr_result=$(glab mr list --source-branch "$branch" --merged --per-page 1 --output json 2>/dev/null || true) + if [ -n "$target_ref" ]; then + mr_result=$(glab mr list --source-branch "$branch" --target-branch "$target_ref" --merged --per-page 1 --output json 2>/dev/null || true) + else + mr_result=$(glab mr list --source-branch "$branch" --merged --per-page 1 --output json 2>/dev/null || true) + fi [ -n "$mr_result" ] && [ "$mr_result" != "[]" ] && [ "$mr_result" != "null" ] ;; *) diff --git a/tests/cmd_clean.bats b/tests/cmd_clean.bats index 59160c2..5027d4c 100644 --- a/tests/cmd_clean.bats +++ b/tests/cmd_clean.bats @@ -123,6 +123,11 @@ teardown() { [ "$status" -eq 0 ] } +@test "cmd_clean accepts --to with a value" { + run cmd_clean --to main + [ "$status" -eq 0 ] +} + @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 +135,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 +144,20 @@ teardown() { [ ! -d "$TEST_WORKTREES_DIR/merged-force" ] } +@test "cmd_clean --merged --to filters by target ref" { + create_test_worktree "merged-to-main" + + _clean_detect_provider() { printf "github"; } + ensure_provider_cli() { return 0; } + check_branch_merged() { [ "$2" = "merged-to-main" ] && [ "$3" = "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" ] +} + @test "cmd_clean --merged --force skips the current active worktree" { create_test_worktree "active-merged" cd "$TEST_WORKTREES_DIR/active-merged" || false @@ -147,7 +166,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..7500f50 100644 --- a/tests/provider.bats +++ b/tests/provider.bats @@ -52,3 +52,46 @@ setup() { run extract_hostname "" [ "$status" -ne 0 ] } + +# ── check_branch_merged ─────────────────────────────────────────────────────── + +@test "check_branch_merged passes base ref to gh" { + gh() { + [ "$1" = "pr" ] || return 1 + [ "$2" = "list" ] || return 1 + [ "$3" = "--head" ] || return 1 + [ "$4" = "feature/test" ] || return 1 + [ "$5" = "--base" ] || return 1 + [ "$6" = "main" ] || return 1 + [ "$7" = "--state" ] || return 1 + [ "$8" = "merged" ] || return 1 + [ "$9" = "--json" ] || return 1 + [ "${10}" = "state" ] || return 1 + [ "${11}" = "--jq" ] || return 1 + [ "${12}" = ".[0].state" ] || return 1 + printf "MERGED" + } + + run check_branch_merged github feature/test main + [ "$status" -eq 0 ] +} + +@test "check_branch_merged passes target branch to glab" { + glab() { + [ "$1" = "mr" ] || return 1 + [ "$2" = "list" ] || return 1 + [ "$3" = "--source-branch" ] || return 1 + [ "$4" = "feature/test" ] || return 1 + [ "$5" = "--target-branch" ] || return 1 + [ "$6" = "main" ] || return 1 + [ "$7" = "--merged" ] || return 1 + [ "$8" = "--per-page" ] || return 1 + [ "$9" = "1" ] || return 1 + [ "${10}" = "--output" ] || return 1 + [ "${11}" = "json" ] || return 1 + printf '[{"iid":1}]' + } + + run check_branch_merged gitlab feature/test main + [ "$status" -eq 0 ] +} From c06bdd812a6178cf5504dc391bfc0e38ffc4dcd6 Mon Sep 17 00:00:00 2001 From: nemo Date: Fri, 10 Apr 2026 14:16:28 +0900 Subject: [PATCH 2/5] fix(clean): tighten merged target cleanup Avoid dirty skip noise for non-merged worktrees and reject --to without --merged. Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> --- lib/commands/clean.sh | 19 ++++++++++----- lib/provider.sh | 23 +++++++++++++----- scripts/generate-completions.sh | 4 +++- tests/cmd_clean.bats | 41 ++++++++++++++++++++++++++++++--- tests/provider.bats | 17 ++++++++++---- 5 files changed, 84 insertions(+), 20 deletions(-) diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh index 045ea6f..2c3c1b1 100644 --- a/lib/commands/clean.sh +++ b/lib/commands/clean.sh @@ -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" "$target_ref"; 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)) @@ -159,6 +161,11 @@ cmd_clean() { 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 diff --git a/lib/provider.sh b/lib/provider.sh index de7f773..689e846 100755 --- a/lib/provider.sh +++ b/lib/provider.sh @@ -97,23 +97,34 @@ ensure_provider_cli() { esac } -# Check if a branch has a merged PR/MR on the detected provider -# Usage: check_branch_merged [target_ref] +# 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:-}" case "$provider" in github) - local pr_state + local pr_matches if [ -n "$target_ref" ]; then - pr_state=$(gh pr list --head "$branch" --base "$target_ref" --state merged --json state --jq '.[0].state' 2>/dev/null || true) + if [ -n "$branch_tip" ]; then + pr_matches=$(gh pr list --head "$branch" --base "$target_ref" --state merged --json state,headRefOid --jq "map(select(.state == \"MERGED\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true) + else + pr_matches=$(gh pr list --head "$branch" --base "$target_ref" --state merged --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true) + fi else - pr_state=$(gh pr list --head "$branch" --state merged --json state --jq '.[0].state' 2>/dev/null || true) + if [ -n "$branch_tip" ]; then + pr_matches=$(gh pr list --head "$branch" --state merged --json state,headRefOid --jq "map(select(.state == \"MERGED\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true) + else + pr_matches=$(gh pr list --head "$branch" --state merged --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true) + fi fi - [ "$pr_state" = "MERGED" ] + [ "${pr_matches:-0}" -gt 0 ] ;; gitlab) local mr_result 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 5027d4c..5b8ef5b 100644 --- a/tests/cmd_clean.bats +++ b/tests/cmd_clean.bats @@ -123,9 +123,10 @@ teardown() { [ "$status" -eq 0 ] } -@test "cmd_clean accepts --to with a value" { +@test "cmd_clean rejects --to without --merged" { run cmd_clean --to main - [ "$status" -eq 0 ] + [ "$status" -eq 1 ] + [[ "$output" == *"--to can only be used with --merged"* ]] } @test "cmd_clean --merged --force removes dirty merged worktrees" { @@ -146,16 +147,50 @@ teardown() { @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() { [ "$2" = "merged-to-main" ] && [ "$3" = "main" ]; } + 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" { diff --git a/tests/provider.bats b/tests/provider.bats index 7500f50..8ef0ce5 100644 --- a/tests/provider.bats +++ b/tests/provider.bats @@ -66,16 +66,25 @@ setup() { [ "$7" = "--state" ] || return 1 [ "$8" = "merged" ] || return 1 [ "$9" = "--json" ] || return 1 - [ "${10}" = "state" ] || return 1 + [ "${10}" = "state,headRefOid" ] || return 1 [ "${11}" = "--jq" ] || return 1 - [ "${12}" = ".[0].state" ] || return 1 - printf "MERGED" + [[ "${12}" == *'.headRefOid == "abc123"'* ]] || return 1 + printf "1" } - run check_branch_merged github feature/test main + run check_branch_merged github feature/test 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 to glab" { glab() { [ "$1" = "mr" ] || return 1 From 0d7f1481738cc9bc489e3615d9ba837fccaf96df Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Fri, 10 Apr 2026 14:21:39 -0700 Subject: [PATCH 3/5] fix(clean): match GitLab merged cleanup by branch tip --- lib/provider.sh | 26 +++++++++++++--- tests/provider.bats | 76 ++++++++++++++++++++++++++++++++++++++++----- 2 files changed, 89 insertions(+), 13 deletions(-) diff --git a/lib/provider.sh b/lib/provider.sh index 689e846..194a300 100755 --- a/lib/provider.sh +++ b/lib/provider.sh @@ -127,13 +127,29 @@ check_branch_merged() { [ "${pr_matches:-0}" -gt 0 ] ;; gitlab) - local mr_result + local mr_result compact_result + local -a glab_args + glab_args=(mr list --source-branch "$branch" --merged --per-page 100 --output json) if [ -n "$target_ref" ]; then - mr_result=$(glab mr list --source-branch "$branch" --target-branch "$target_ref" --merged --per-page 1 --output json 2>/dev/null || true) - else - mr_result=$(glab mr list --source-branch "$branch" --merged --per-page 1 --output json 2>/dev/null || true) + glab_args+=(--target-branch "$target_ref") fi - [ -n "$mr_result" ] && [ "$mr_result" != "[]" ] && [ "$mr_result" != "null" ] + + 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/tests/provider.bats b/tests/provider.bats index 8ef0ce5..521af53 100644 --- a/tests/provider.bats +++ b/tests/provider.bats @@ -85,19 +85,79 @@ setup() { [ "$status" -eq 1 ] } -@test "check_branch_merged passes target branch to glab" { +@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" = "--target-branch" ] || return 1 - [ "$6" = "main" ] || return 1 - [ "$7" = "--merged" ] || return 1 - [ "$8" = "--per-page" ] || return 1 - [ "$9" = "1" ] || return 1 - [ "${10}" = "--output" ] || return 1 - [ "${11}" = "json" ] || return 1 + [ "$5" = "--merged" ] || return 1 + [ "$6" = "--per-page" ] || return 1 + [ "$7" = "100" ] || return 1 + [ "$8" = "--output" ] || return 1 + [ "$9" = "json" ] || return 1 + [ "${10}" = "--target-branch" ] || return 1 + [ "${11}" = "main" ] || return 1 + printf '[{"iid":1,"sha":"abc123"}]' + } + + run check_branch_merged gitlab feature/test 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" = "--per-page" ] || return 1 + [ "$7" = "100" ] || return 1 + [ "$8" = "--output" ] || return 1 + [ "$9" = "json" ] || return 1 + [ "${10}" = "--target-branch" ] || return 1 + [ "${11}" = "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" = "--per-page" ] || return 1 + [ "$7" = "100" ] || return 1 + [ "$8" = "--output" ] || return 1 + [ "$9" = "json" ] || return 1 + [ "${10}" = "--target-branch" ] || return 1 + [ "${11}" = "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" = "--per-page" ] || return 1 + [ "$7" = "100" ] || return 1 + [ "$8" = "--output" ] || return 1 + [ "$9" = "json" ] || return 1 + [ "${10}" = "--target-branch" ] || return 1 + [ "${11}" = "main" ] || return 1 printf '[{"iid":1}]' } From ad235221f186db7c07253c992506a12bfa7d02bf Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Fri, 10 Apr 2026 14:48:52 -0700 Subject: [PATCH 4/5] Address CodeRabbit review: normalize refs and paginate provider lookups --- lib/provider.sh | 50 +++++++++++++++++++++----- tests/provider.bats | 88 +++++++++++++++++++++++++++------------------ 2 files changed, 95 insertions(+), 43 deletions(-) diff --git a/lib/provider.sh b/lib/provider.sh index 194a300..19c7b07 100755 --- a/lib/provider.sh +++ b/lib/provider.sh @@ -97,6 +97,32 @@ ensure_provider_cli() { esac } +# 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#*/}" + ;; + *) + 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. @@ -107,21 +133,29 @@ check_branch_merged() { 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 -a gh_args local pr_matches - if [ -n "$target_ref" ]; then + 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 pr list --head "$branch" --base "$target_ref" --state merged --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 == \"MERGED\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true) else - pr_matches=$(gh pr list --head "$branch" --base "$target_ref" --state merged --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true) + 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 pr list --head "$branch" --state merged --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 == \"MERGED\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true) else - pr_matches=$(gh pr list --head "$branch" --state merged --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true) + pr_matches=$(gh "${gh_args[@]}" --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true) fi fi [ "${pr_matches:-0}" -gt 0 ] @@ -129,9 +163,9 @@ check_branch_merged() { gitlab) local mr_result compact_result local -a glab_args - glab_args=(mr list --source-branch "$branch" --merged --per-page 100 --output json) - if [ -n "$target_ref" ]; then - glab_args+=(--target-branch "$target_ref") + 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) diff --git a/tests/provider.bats b/tests/provider.bats index 521af53..10a91af 100644 --- a/tests/provider.bats +++ b/tests/provider.bats @@ -53,26 +53,48 @@ setup() { [ "$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" { + 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 base ref to gh" { +@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" = "--base" ] || return 1 - [ "$6" = "main" ] || return 1 - [ "$7" = "--state" ] || return 1 - [ "$8" = "merged" ] || return 1 - [ "$9" = "--json" ] || return 1 - [ "${10}" = "state,headRefOid" ] || return 1 - [ "${11}" = "--jq" ] || return 1 - [[ "${12}" == *'.headRefOid == "abc123"'* ]] || 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 main abc123 + run check_branch_merged github feature/test refs/heads/main abc123 [ "$status" -eq 0 ] } @@ -92,16 +114,15 @@ setup() { [ "$3" = "--source-branch" ] || return 1 [ "$4" = "feature/test" ] || return 1 [ "$5" = "--merged" ] || return 1 - [ "$6" = "--per-page" ] || return 1 - [ "$7" = "100" ] || return 1 - [ "$8" = "--output" ] || return 1 - [ "$9" = "json" ] || return 1 - [ "${10}" = "--target-branch" ] || return 1 - [ "${11}" = "main" ] || 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 main abc123 + run check_branch_merged gitlab feature/test origin/main abc123 [ "$status" -eq 0 ] } @@ -112,12 +133,11 @@ setup() { [ "$3" = "--source-branch" ] || return 1 [ "$4" = "feature/test" ] || return 1 [ "$5" = "--merged" ] || return 1 - [ "$6" = "--per-page" ] || return 1 - [ "$7" = "100" ] || return 1 - [ "$8" = "--output" ] || return 1 - [ "$9" = "json" ] || return 1 - [ "${10}" = "--target-branch" ] || return 1 - [ "${11}" = "main" ] || 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"}]' } @@ -132,12 +152,11 @@ setup() { [ "$3" = "--source-branch" ] || return 1 [ "$4" = "feature/test" ] || return 1 [ "$5" = "--merged" ] || return 1 - [ "$6" = "--per-page" ] || return 1 - [ "$7" = "100" ] || return 1 - [ "$8" = "--output" ] || return 1 - [ "$9" = "json" ] || return 1 - [ "${10}" = "--target-branch" ] || return 1 - [ "${11}" = "main" ] || 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"}}]' } @@ -152,12 +171,11 @@ setup() { [ "$3" = "--source-branch" ] || return 1 [ "$4" = "feature/test" ] || return 1 [ "$5" = "--merged" ] || return 1 - [ "$6" = "--per-page" ] || return 1 - [ "$7" = "100" ] || return 1 - [ "$8" = "--output" ] || return 1 - [ "$9" = "json" ] || return 1 - [ "${10}" = "--target-branch" ] || return 1 - [ "${11}" = "main" ] || 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}]' } From a26a3ae2cbcaeff0bf6c206f6bbf4684c6cd3c75 Mon Sep 17 00:00:00 2001 From: Tom Elizaga Date: Fri, 10 Apr 2026 14:55:39 -0700 Subject: [PATCH 5/5] fix(ci): normalize remote refs in provider tests --- lib/provider.sh | 3 +++ tests/provider.bats | 1 + 2 files changed, 4 insertions(+) diff --git a/lib/provider.sh b/lib/provider.sh index 19c7b07..53d5be3 100755 --- a/lib/provider.sh +++ b/lib/provider.sh @@ -113,6 +113,9 @@ normalize_target_ref() { 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#*/}" diff --git a/tests/provider.bats b/tests/provider.bats index 10a91af..6231ef5 100644 --- a/tests/provider.bats +++ b/tests/provider.bats @@ -64,6 +64,7 @@ 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