Skip to content

Commit b8ca496

Browse files
scarf005chatgpt-codex-connector[bot]helizaga
authored
feat(clean): add --to filter for git gtr clean --merged (#167)
* 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> * 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> * fix(clean): match GitLab merged cleanup by branch tip * Address CodeRabbit review: normalize refs and paginate provider lookups * fix(ci): normalize remote refs in provider tests * refactor(provider): simplify GitHub merge check --------- Co-authored-by: chatgpt-codex-connector[bot] <199175422+chatgpt-codex-connector[bot]@users.noreply.github.com> Co-authored-by: Tom Elizaga <tom.elizaga@gmail.com>
1 parent 6d1f465 commit b8ca496

11 files changed

Lines changed: 290 additions & 21 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,7 @@ Remove worktrees: clean up empty directories, or remove those with merged PRs/MR
326326
```bash
327327
git gtr clean # Remove empty worktree directories and prune
328328
git gtr clean --merged # Remove worktrees for merged PRs/MRs
329+
git gtr clean --merged --to main # Only remove worktrees merged to main
329330
git gtr clean --merged --dry-run # Preview which worktrees would be removed
330331
git gtr clean --merged --yes # Remove without confirmation prompts
331332
git gtr clean --merged --force # Force-clean merged, ignoring local changes
@@ -335,6 +336,7 @@ git gtr clean --merged --force --yes # Force-clean and auto-confirm
335336
**Options:**
336337

337338
- `--merged`: Remove worktrees whose branches have merged PRs/MRs (also deletes the branch)
339+
- `--to <ref>`: Limit `--merged` cleanup to PRs/MRs merged into the given base ref
338340
- `--dry-run`, `-n`: Preview changes without removing
339341
- `--yes`, `-y`: Non-interactive mode (skip confirmation prompts)
340342
- `--force`, `-f`: Force removal even if worktree has uncommitted changes or untracked files

completions/_git-gtr

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ _git-gtr() {
8181
if (( CURRENT >= 4 )) && [[ $words[3] == clean ]]; then
8282
_arguments \
8383
'--merged[Remove worktrees with merged PRs/MRs]' \
84+
'--to[Only remove worktrees for PRs/MRs merged into this ref]:ref:' \
8485
'--yes[Skip confirmation prompts]' \
8586
'-y[Skip confirmation prompts]' \
8687
'--dry-run[Show what would be removed]' \

completions/git-gtr.fish

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ complete -c git -n '__fish_git_gtr_using_command ai' -l ai -d 'AI tool to use' -
100100

101101
# Clean command options
102102
complete -c git -n '__fish_git_gtr_using_command clean' -l merged -d 'Remove worktrees with merged PRs/MRs'
103+
complete -c git -n '__fish_git_gtr_using_command clean' -l to -d 'Only remove worktrees for PRs/MRs merged into this ref' -r
103104
complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirmation prompts'
104105
complete -c git -n '__fish_git_gtr_using_command clean' -s y -d 'Skip confirmation prompts'
105106
complete -c git -n '__fish_git_gtr_using_command clean' -l dry-run -d 'Show what would be removed'

completions/gtr.bash

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,7 @@ _git_gtr() {
8282
;;
8383
clean)
8484
if [[ "$cur" == -* ]]; then
85-
COMPREPLY=($(compgen -W "--merged --yes -y --dry-run -n --force -f" -- "$cur"))
85+
COMPREPLY=($(compgen -W "--merged --to --yes -y --dry-run -n --force -f" -- "$cur"))
8686
fi
8787
;;
8888
copy)

lib/commands/clean.sh

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -68,9 +68,9 @@ _clean_should_skip() {
6868
}
6969

7070
# Remove worktrees whose PRs/MRs are merged (handles squash merges)
71-
# Usage: _clean_merged repo_root base_dir prefix yes_mode dry_run [force] [active_worktree_path]
71+
# Usage: _clean_merged repo_root base_dir prefix yes_mode dry_run [force] [active_worktree_path] [target_ref]
7272
_clean_merged() {
73-
local repo_root="$1" base_dir="$2" prefix="$3" yes_mode="$4" dry_run="$5" force="${6:-0}" active_worktree_path="${7:-}"
73+
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:-}"
7474

7575
log_step "Checking for worktrees with merged PRs/MRs..."
7676

@@ -90,17 +90,19 @@ _clean_merged() {
9090

9191
local branch
9292
branch=$(current_branch "$dir") || true
93+
local branch_tip
94+
branch_tip=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true)
9395

9496
# Skip main repo branch silently (not counted)
9597
[ "$branch" = "$main_branch" ] && continue
9698

97-
if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then
98-
skipped=$((skipped + 1))
99-
continue
100-
fi
101-
10299
# Check if branch has a merged PR/MR
103-
if check_branch_merged "$provider" "$branch"; then
100+
if check_branch_merged "$provider" "$branch" "$target_ref" "$branch_tip"; then
101+
if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then
102+
skipped=$((skipped + 1))
103+
continue
104+
fi
105+
104106
if [ "$dry_run" -eq 1 ]; then
105107
log_info "[dry-run] Would remove: $branch ($dir)"
106108
removed=$((removed + 1))
@@ -146,17 +148,24 @@ _clean_merged() {
146148
cmd_clean() {
147149
local _spec
148150
_spec="--merged
151+
--to: value
149152
--yes|-y
150153
--dry-run|-n
151154
--force|-f"
152155
parse_args "$_spec" "$@"
153156

154157
local merged_mode="${_arg_merged:-0}"
158+
local target_ref="${_arg_to:-}"
155159
local yes_mode="${_arg_yes:-0}"
156160
local dry_run="${_arg_dry_run:-0}"
157161
local force="${_arg_force:-0}"
158162
local active_worktree_path=""
159163

164+
if [ -n "$target_ref" ] && [ "$merged_mode" -ne 1 ]; then
165+
log_error "--to can only be used with --merged"
166+
return 1
167+
fi
168+
160169
log_step "Cleaning up stale worktrees..."
161170

162171
# Run git worktree prune
@@ -204,6 +213,6 @@ EOF
204213

205214
# --merged mode: remove worktrees with merged PRs/MRs (handles squash merges)
206215
if [ "$merged_mode" -eq 1 ]; then
207-
_clean_merged "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run" "$force" "$active_worktree_path"
216+
_clean_merged "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run" "$force" "$active_worktree_path" "$target_ref"
208217
fi
209218
}

lib/commands/help.sh

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,15 @@ the remote URL.
304304
305305
Options:
306306
--merged Also remove worktrees with merged PRs/MRs
307+
--to <ref> Only remove worktrees for PRs/MRs merged into <ref>
307308
--yes, -y Skip confirmation prompts
308309
--dry-run, -n Show what would be removed without removing
309310
--force, -f Force removal even if worktree has uncommitted changes or untracked files
310311
311312
Examples:
312313
git gtr clean # Clean empty directories
313314
git gtr clean --merged # Also clean merged PRs
315+
git gtr clean --merged --to main # Only clean PRs merged to main
314316
git gtr clean --merged --dry-run # Preview merged cleanup
315317
git gtr clean --merged --yes # Auto-confirm everything
316318
git gtr clean --merged --force # Force-clean merged, ignoring local changes
@@ -568,6 +570,7 @@ SETUP & MAINTENANCE:
568570
clean [options]
569571
Remove stale/prunable worktrees and empty directories
570572
--merged: also remove worktrees with merged PRs/MRs
573+
--to <ref>: limit merged cleanup to PRs/MRs merged into <ref>
571574
Auto-detects GitHub (gh) or GitLab (glab) from remote URL
572575
Override: git gtr config set gtr.provider gitlab
573576
--yes, -y: skip confirmation prompts

lib/provider.sh

Lines changed: 73 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -97,23 +97,88 @@ ensure_provider_cli() {
9797
esac
9898
}
9999

100-
# Check if a branch has a merged PR/MR on the detected provider
101-
# Usage: check_branch_merged <provider> <branch>
100+
# Normalize user-provided refs to plain branch names for provider filters.
101+
# Usage: normalize_target_ref [target_ref]
102+
normalize_target_ref() {
103+
local target_ref="${1:-}"
104+
local remote_ref
105+
106+
[ -n "$target_ref" ] || return 0
107+
108+
case "$target_ref" in
109+
refs/heads/*)
110+
printf "%s" "${target_ref#refs/heads/}"
111+
;;
112+
refs/remotes/*)
113+
remote_ref="${target_ref#refs/remotes/}"
114+
printf "%s" "${remote_ref#*/}"
115+
;;
116+
origin/*|upstream/*)
117+
printf "%s" "${target_ref#*/}"
118+
;;
119+
*)
120+
if git show-ref --verify --quiet "refs/remotes/$target_ref" 2>/dev/null; then
121+
printf "%s" "${target_ref#*/}"
122+
else
123+
printf "%s" "$target_ref"
124+
fi
125+
;;
126+
esac
127+
}
128+
129+
# Check if a branch has a merged PR/MR on the detected provider.
130+
# When branch_tip is provided, require the merged PR/MR to point at the same
131+
# commit so reused branch names do not match older merged PRs.
132+
# Usage: check_branch_merged <provider> <branch> [target_ref] [branch_tip]
102133
# Returns 0 if merged, 1 if not
103134
check_branch_merged() {
104135
local provider="$1"
105136
local branch="$2"
137+
local target_ref="${3:-}"
138+
local branch_tip="${4:-}"
139+
local normalized_target_ref
140+
141+
normalized_target_ref=$(normalize_target_ref "$target_ref") || true
106142

107143
case "$provider" in
108144
github)
109-
local pr_state
110-
pr_state=$(gh pr list --head "$branch" --state merged --json state --jq '.[0].state' 2>/dev/null || true)
111-
[ "$pr_state" = "MERGED" ]
145+
local -a gh_args
146+
local pr_matches
147+
gh_args=(pr list --head "$branch" --state merged --limit 1000)
148+
if [ -n "$normalized_target_ref" ]; then
149+
gh_args+=(--base "$normalized_target_ref")
150+
fi
151+
if [ -n "$branch_tip" ]; then
152+
pr_matches=$(gh "${gh_args[@]}" --json state,headRefOid --jq "map(select(.state == \"MERGED\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true)
153+
else
154+
pr_matches=$(gh "${gh_args[@]}" --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true)
155+
fi
156+
[ "${pr_matches:-0}" -gt 0 ]
112157
;;
113158
gitlab)
114-
local mr_result
115-
mr_result=$(glab mr list --source-branch "$branch" --merged --per-page 1 --output json 2>/dev/null || true)
116-
[ -n "$mr_result" ] && [ "$mr_result" != "[]" ] && [ "$mr_result" != "null" ]
159+
local mr_result compact_result
160+
local -a glab_args
161+
glab_args=(mr list --source-branch "$branch" --merged --all --output json)
162+
if [ -n "$normalized_target_ref" ]; then
163+
glab_args+=(--target-branch "$normalized_target_ref")
164+
fi
165+
166+
mr_result=$(glab "${glab_args[@]}" 2>/dev/null || true)
167+
[ -n "$mr_result" ] && [ "$mr_result" != "[]" ] && [ "$mr_result" != "null" ] || return 1
168+
169+
if [ -n "$branch_tip" ]; then
170+
compact_result=$(printf "%s" "$mr_result" | tr -d '[:space:]')
171+
case "$compact_result" in
172+
*"\"sha\":\"$branch_tip\""*|*"\"head_sha\":\"$branch_tip\""*)
173+
return 0
174+
;;
175+
*)
176+
return 1
177+
;;
178+
esac
179+
fi
180+
181+
return 0
117182
;;
118183
*)
119184
return 1

scripts/generate-completions.sh

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -176,7 +176,7 @@ MIDDLE1
176176
;;
177177
clean)
178178
if [[ "$cur" == -* ]]; then
179-
COMPREPLY=($(compgen -W "--merged --yes -y --dry-run -n --force -f" -- "$cur"))
179+
COMPREPLY=($(compgen -W "--merged --to --yes -y --dry-run -n --force -f" -- "$cur"))
180180
fi
181181
;;
182182
copy)
@@ -340,6 +340,7 @@ _git-gtr() {
340340
if (( CURRENT >= 4 )) && [[ $words[3] == clean ]]; then
341341
_arguments \
342342
'--merged[Remove worktrees with merged PRs/MRs]' \
343+
'--to[Only remove worktrees for PRs/MRs merged into this ref]:ref:' \
343344
'--yes[Skip confirmation prompts]' \
344345
'-y[Skip confirmation prompts]' \
345346
'--dry-run[Show what would be removed]' \
@@ -580,6 +581,7 @@ MIDDLE1
580581
581582
# Clean command options
582583
complete -c git -n '__fish_git_gtr_using_command clean' -l merged -d 'Remove worktrees with merged PRs/MRs'
584+
complete -c git -n '__fish_git_gtr_using_command clean' -l to -d 'Only remove worktrees for PRs/MRs merged into this ref' -r
583585
complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirmation prompts'
584586
complete -c git -n '__fish_git_gtr_using_command clean' -s y -d 'Skip confirmation prompts'
585587
complete -c git -n '__fish_git_gtr_using_command clean' -l dry-run -d 'Show what would be removed'

tests/cmd_clean.bats

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,14 +123,20 @@ teardown() {
123123
[ "$status" -eq 0 ]
124124
}
125125

126+
@test "cmd_clean rejects --to without --merged" {
127+
run cmd_clean --to main
128+
[ "$status" -eq 1 ]
129+
[[ "$output" == *"--to can only be used with --merged"* ]]
130+
}
131+
126132
@test "cmd_clean --merged --force removes dirty merged worktrees" {
127133
create_test_worktree "merged-force"
128134
echo "dirty" > "$TEST_WORKTREES_DIR/merged-force/dirty.txt"
129135
git -C "$TEST_WORKTREES_DIR/merged-force" add dirty.txt
130136

131137
_clean_detect_provider() { printf "github"; }
132138
ensure_provider_cli() { return 0; }
133-
check_branch_merged() { [ "$2" = "merged-force" ]; }
139+
check_branch_merged() { [ "$2" = "merged-force" ] && [ -z "$3" ]; }
134140
run_hooks_in() { return 0; }
135141
run_hooks() { return 0; }
136142

@@ -139,6 +145,54 @@ teardown() {
139145
[ ! -d "$TEST_WORKTREES_DIR/merged-force" ]
140146
}
141147

148+
@test "cmd_clean --merged --to filters by target ref" {
149+
create_test_worktree "merged-to-main"
150+
create_test_worktree "merged-to-feature"
151+
152+
_clean_detect_provider() { printf "github"; }
153+
ensure_provider_cli() { return 0; }
154+
check_branch_merged() {
155+
[ "$3" = "main" ] && [ "$2" = "merged-to-main" ]
156+
}
157+
run_hooks_in() { return 0; }
158+
run_hooks() { return 0; }
159+
160+
run cmd_clean --merged --to main --yes
161+
[ "$status" -eq 0 ]
162+
[ ! -d "$TEST_WORKTREES_DIR/merged-to-main" ]
163+
[ -d "$TEST_WORKTREES_DIR/merged-to-feature" ]
164+
}
165+
166+
@test "cmd_clean passes current branch HEAD to merged check" {
167+
create_test_worktree "merged-tip"
168+
local branch_tip
169+
branch_tip=$(git -C "$TEST_WORKTREES_DIR/merged-tip" rev-parse HEAD)
170+
171+
_clean_detect_provider() { printf "github"; }
172+
ensure_provider_cli() { return 0; }
173+
check_branch_merged() { [ "$2" = "merged-tip" ] && [ "$3" = "main" ] && [ "$4" = "$branch_tip" ]; }
174+
run_hooks_in() { return 0; }
175+
run_hooks() { return 0; }
176+
177+
run cmd_clean --merged --to main --yes
178+
[ "$status" -eq 0 ]
179+
[ ! -d "$TEST_WORKTREES_DIR/merged-tip" ]
180+
}
181+
182+
@test "cmd_clean does not log dirty skip for non-merged worktree" {
183+
create_test_worktree "dirty-not-merged"
184+
echo "dirty" > "$TEST_WORKTREES_DIR/dirty-not-merged/dirty.txt"
185+
git -C "$TEST_WORKTREES_DIR/dirty-not-merged" add dirty.txt
186+
187+
_clean_detect_provider() { printf "github"; }
188+
ensure_provider_cli() { return 0; }
189+
check_branch_merged() { return 1; }
190+
191+
run cmd_clean --merged --to main --yes
192+
[ "$status" -eq 0 ]
193+
[[ "$output" != *"dirty-not-merged"* ]]
194+
}
195+
142196
@test "cmd_clean --merged --force skips the current active worktree" {
143197
create_test_worktree "active-merged"
144198
cd "$TEST_WORKTREES_DIR/active-merged" || false
@@ -147,7 +201,7 @@ teardown() {
147201

148202
_clean_detect_provider() { printf "github"; }
149203
ensure_provider_cli() { return 0; }
150-
check_branch_merged() { [ "$2" = "active-merged" ]; }
204+
check_branch_merged() { [ "$2" = "active-merged" ] && [ -z "$3" ]; }
151205
run_hooks_in() { return 0; }
152206
run_hooks() { return 0; }
153207

tests/cmd_help.bats

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,7 @@ teardown() {
8181
[ "$status" -eq 0 ]
8282
[[ "$output" == *"git gtr clean"* ]]
8383
[[ "$output" == *"--merged"* ]]
84+
[[ "$output" == *"--to <ref>"* ]]
8485
}
8586

8687
@test "cmd_help copy shows copy help" {

0 commit comments

Comments
 (0)