Skip to content

Commit 10c6c15

Browse files
authored
feat(clean): add --force flag to git gtr clean --merged (#158)
1 parent fe4c33c commit 10c6c15

File tree

8 files changed

+139
-25
lines changed

8 files changed

+139
-25
lines changed

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,13 +327,16 @@ git gtr clean # Remove empty worktree directori
327327
git gtr clean --merged # Remove worktrees for merged PRs/MRs
328328
git gtr clean --merged --dry-run # Preview which worktrees would be removed
329329
git gtr clean --merged --yes # Remove without confirmation prompts
330+
git gtr clean --merged --force # Force-clean merged, ignoring local changes
331+
git gtr clean --merged --force --yes # Force-clean and auto-confirm
330332
```
331333

332334
**Options:**
333335

334336
- `--merged`: Remove worktrees whose branches have merged PRs/MRs (also deletes the branch)
335337
- `--dry-run`, `-n`: Preview changes without removing
336338
- `--yes`, `-y`: Non-interactive mode (skip confirmation prompts)
339+
- `--force`, `-f`: Force removal even if worktree has uncommitted changes or untracked files
337340

338341
**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`.
339342

completions/_git-gtr

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,9 @@ _git-gtr() {
8383
'--yes[Skip confirmation prompts]' \
8484
'-y[Skip confirmation prompts]' \
8585
'--dry-run[Show what would be removed]' \
86-
'-n[Show what would be removed]'
86+
'-n[Show what would be removed]' \
87+
'--force[Force removal even if worktree has uncommitted changes or untracked files]' \
88+
'-f[Force removal even if worktree has uncommitted changes or untracked files]'
8789
return
8890
fi
8991

@@ -133,7 +135,7 @@ _git-gtr() {
133135
rm)
134136
_arguments \
135137
'--delete-branch[Delete branch]' \
136-
'--force[Force removal even if dirty]' \
138+
'--force[Force removal even if worktree has uncommitted changes or untracked files]' \
137139
'--yes[Non-interactive mode]'
138140
;;
139141
mv|rename)

completions/git-gtr.fish

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ complete -c git -n '__fish_git_gtr_using_command new' -s a -l ai -d 'Start AI to
7373

7474
# Remove command options
7575
complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch'
76-
complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if dirty'
76+
complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files'
7777
complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode'
7878

7979
# Rename command options
@@ -103,6 +103,7 @@ complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirma
103103
complete -c git -n '__fish_git_gtr_using_command clean' -s y -d 'Skip confirmation prompts'
104104
complete -c git -n '__fish_git_gtr_using_command clean' -l dry-run -d 'Show what would be removed'
105105
complete -c git -n '__fish_git_gtr_using_command clean' -s n -d 'Show what would be removed'
106+
complete -c git -n '__fish_git_gtr_using_command clean' -s f -l force -d 'Force removal even if worktree has uncommitted changes or untracked files'
106107

107108
# Config command
108109
complete -f -c git -n '__fish_git_gtr_using_command config' -a 'list get set add unset'

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" -- "$cur"))
85+
COMPREPLY=($(compgen -W "--merged --yes -y --dry-run -n --force -f" -- "$cur"))
8686
fi
8787
;;
8888
copy)

lib/commands/clean.sh

Lines changed: 39 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -30,33 +30,47 @@ _clean_detect_provider() {
3030

3131
# Check if a worktree should be skipped during merged cleanup.
3232
# Returns 0 if should skip, 1 if should process.
33-
# Usage: _clean_should_skip <dir> <branch>
33+
# Usage: _clean_should_skip <dir> <branch> [force] [active_worktree_path]
3434
_clean_should_skip() {
35-
local dir="$1" branch="$2"
35+
local dir="$1" branch="$2" force="${3:-0}" active_worktree_path="${4:-}"
36+
local dir_canonical="$dir"
37+
local active_worktree_canonical="$active_worktree_path"
3638

37-
if [ -z "$branch" ] || [ "$branch" = "(detached)" ]; then
38-
log_warn "Skipping $dir (detached HEAD)"
39-
return 0
39+
if [ -n "$active_worktree_path" ]; then
40+
dir_canonical=$(canonicalize_path "$dir" || printf "%s" "$dir")
41+
active_worktree_canonical=$(canonicalize_path "$active_worktree_path" || printf "%s" "$active_worktree_path")
4042
fi
4143

42-
if ! git -C "$dir" diff --quiet 2>/dev/null || \
43-
! git -C "$dir" diff --cached --quiet 2>/dev/null; then
44-
log_warn "Skipping $branch (has uncommitted changes)"
44+
if [ -n "$active_worktree_path" ] && [ "$dir_canonical" = "$active_worktree_canonical" ]; then
45+
log_warn "Skipping $branch (current active worktree)"
4546
return 0
4647
fi
4748

48-
if [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then
49-
log_warn "Skipping $branch (has untracked files)"
49+
if [ -z "$branch" ] || [ "$branch" = "(detached)" ]; then
50+
log_warn "Skipping $dir (detached HEAD)"
5051
return 0
5152
fi
5253

54+
if [ "$force" -eq 0 ]; then
55+
if ! git -C "$dir" diff --quiet 2>/dev/null || \
56+
! git -C "$dir" diff --cached --quiet 2>/dev/null; then
57+
log_warn "Skipping $branch (has uncommitted changes)"
58+
return 0
59+
fi
60+
61+
if [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then
62+
log_warn "Skipping $branch (has untracked files)"
63+
return 0
64+
fi
65+
fi
66+
5367
return 1
5468
}
5569

5670
# Remove worktrees whose PRs/MRs are merged (handles squash merges)
57-
# Usage: _clean_merged repo_root base_dir prefix yes_mode dry_run
71+
# Usage: _clean_merged repo_root base_dir prefix yes_mode dry_run [force] [active_worktree_path]
5872
_clean_merged() {
59-
local repo_root="$1" base_dir="$2" prefix="$3" yes_mode="$4" dry_run="$5"
73+
local repo_root="$1" base_dir="$2" prefix="$3" yes_mode="$4" dry_run="$5" force="${6:-0}" active_worktree_path="${7:-}"
6074

6175
log_step "Checking for worktrees with merged PRs/MRs..."
6276

@@ -80,7 +94,7 @@ _clean_merged() {
8094
# Skip main repo branch silently (not counted)
8195
[ "$branch" = "$main_branch" ] && continue
8296

83-
if _clean_should_skip "$dir" "$branch"; then
97+
if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then
8498
skipped=$((skipped + 1))
8599
continue
86100
fi
@@ -102,7 +116,7 @@ _clean_merged() {
102116
continue
103117
fi
104118

105-
if remove_worktree "$dir" 0; then
119+
if remove_worktree "$dir" "$force"; then
106120
git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true
107121
removed=$((removed + 1))
108122

@@ -133,12 +147,15 @@ cmd_clean() {
133147
local _spec
134148
_spec="--merged
135149
--yes|-y
136-
--dry-run|-n"
150+
--dry-run|-n
151+
--force|-f"
137152
parse_args "$_spec" "$@"
138153

139154
local merged_mode="${_arg_merged:-0}"
140155
local yes_mode="${_arg_yes:-0}"
141156
local dry_run="${_arg_dry_run:-0}"
157+
local force="${_arg_force:-0}"
158+
local active_worktree_path=""
142159

143160
log_step "Cleaning up stale worktrees..."
144161

@@ -151,6 +168,11 @@ cmd_clean() {
151168

152169
local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix"
153170

171+
active_worktree_path=$(git rev-parse --show-toplevel 2>/dev/null || true)
172+
if [ -n "$active_worktree_path" ]; then
173+
active_worktree_path=$(canonicalize_path "$active_worktree_path" || printf "%s" "$active_worktree_path")
174+
fi
175+
154176
if [ ! -d "$base_dir" ]; then
155177
log_info "No worktrees directory to clean"
156178
return 0
@@ -182,6 +204,6 @@ EOF
182204

183205
# --merged mode: remove worktrees with merged PRs/MRs (handles squash merges)
184206
if [ "$merged_mode" -eq 1 ]; then
185-
_clean_merged "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run"
207+
_clean_merged "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run" "$force" "$active_worktree_path"
186208
fi
187-
}
209+
}

lib/commands/help.sh

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,12 +305,15 @@ Options:
305305
--merged Also remove worktrees with merged PRs/MRs
306306
--yes, -y Skip confirmation prompts
307307
--dry-run, -n Show what would be removed without removing
308+
--force, -f Force removal even if worktree has uncommitted changes or untracked files
308309
309310
Examples:
310311
git gtr clean # Clean empty directories
311312
git gtr clean --merged # Also clean merged PRs
312313
git gtr clean --merged --dry-run # Preview merged cleanup
313314
git gtr clean --merged --yes # Auto-confirm everything
315+
git gtr clean --merged --force # Force-clean merged, ignoring local changes
316+
git gtr clean --merged --force --yes # Force-clean and auto-confirm
314317
EOF
315318
}
316319

@@ -567,6 +570,7 @@ SETUP & MAINTENANCE:
567570
Override: git gtr config set gtr.provider gitlab
568571
--yes, -y: skip confirmation prompts
569572
--dry-run, -n: show what would be removed without removing
573+
--force, -f: force removal even if worktree has uncommitted changes or untracked files
570574
571575
completion <shell>
572576
Generate shell completions (bash, zsh, fish)

scripts/generate-completions.sh

Lines changed: 7 additions & 4 deletions
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" -- "$cur"))
179+
COMPREPLY=($(compgen -W "--merged --yes -y --dry-run -n --force -f" -- "$cur"))
180180
fi
181181
;;
182182
copy)
@@ -342,7 +342,9 @@ _git-gtr() {
342342
'--yes[Skip confirmation prompts]' \
343343
'-y[Skip confirmation prompts]' \
344344
'--dry-run[Show what would be removed]' \
345-
'-n[Show what would be removed]'
345+
'-n[Show what would be removed]' \
346+
'--force[Force removal even if worktree has uncommitted changes or untracked files]' \
347+
'-f[Force removal even if worktree has uncommitted changes or untracked files]'
346348
return
347349
fi
348350
@@ -396,7 +398,7 @@ MIDDLE1
396398
rm)
397399
_arguments \
398400
'--delete-branch[Delete branch]' \
399-
'--force[Force removal even if dirty]' \
401+
'--force[Force removal even if worktree has uncommitted changes or untracked files]' \
400402
'--yes[Non-interactive mode]'
401403
;;
402404
mv|rename)
@@ -546,7 +548,7 @@ complete -c git -n '__fish_git_gtr_using_command new' -s a -l ai -d 'Start AI to
546548
547549
# Remove command options
548550
complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch'
549-
complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if dirty'
551+
complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files'
550552
complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode'
551553
552554
# Rename command options
@@ -580,6 +582,7 @@ complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirma
580582
complete -c git -n '__fish_git_gtr_using_command clean' -s y -d 'Skip confirmation prompts'
581583
complete -c git -n '__fish_git_gtr_using_command clean' -l dry-run -d 'Show what would be removed'
582584
complete -c git -n '__fish_git_gtr_using_command clean' -s n -d 'Show what would be removed'
585+
complete -c git -n '__fish_git_gtr_using_command clean' -s f -l force -d 'Force removal even if worktree has uncommitted changes or untracked files'
583586
584587
# Config command
585588
complete -f -c git -n '__fish_git_gtr_using_command config' -a 'list get set add unset'

tests/cmd_clean.bats

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,3 +76,82 @@ teardown() {
7676
run _clean_should_skip "$TEST_WORKTREES_DIR/clean-wt" "clean-wt"
7777
[ "$status" -eq 1 ] # 1 = don't skip
7878
}
79+
80+
@test "_clean_should_skip with force=1 does not skip dirty worktree" {
81+
create_test_worktree "dirty-force"
82+
echo "dirty" > "$TEST_WORKTREES_DIR/dirty-force/untracked.txt"
83+
git -C "$TEST_WORKTREES_DIR/dirty-force" add untracked.txt
84+
run _clean_should_skip "$TEST_WORKTREES_DIR/dirty-force" "dirty-force" 1
85+
[ "$status" -eq 1 ] # 1 = don't skip
86+
}
87+
88+
@test "_clean_should_skip with force=1 does not skip worktree with untracked files" {
89+
create_test_worktree "untracked-force"
90+
echo "new" > "$TEST_WORKTREES_DIR/untracked-force/newfile.txt"
91+
run _clean_should_skip "$TEST_WORKTREES_DIR/untracked-force" "untracked-force" 1
92+
[ "$status" -eq 1 ] # 1 = don't skip
93+
}
94+
95+
@test "_clean_should_skip with force=1 still skips detached HEAD" {
96+
run _clean_should_skip "/some/dir" "(detached)" 1
97+
[ "$status" -eq 0 ] # 0 = skip (protection maintained)
98+
}
99+
100+
@test "_clean_should_skip with force=1 still skips empty branch" {
101+
run _clean_should_skip "/some/dir" "" 1
102+
[ "$status" -eq 0 ] # 0 = skip (protection maintained)
103+
}
104+
105+
@test "_clean_should_skip with force=1 still skips current active worktree" {
106+
create_test_worktree "active-force"
107+
run _clean_should_skip "$TEST_WORKTREES_DIR/active-force" "active-force" 1 "$TEST_WORKTREES_DIR/active-force"
108+
[ "$status" -eq 0 ] # 0 = skip (protection maintained)
109+
}
110+
111+
@test "_clean_should_skip with force=1 skips current active worktree via symlink path" {
112+
create_test_worktree "active-force-symlink"
113+
ln -s "$TEST_WORKTREES_DIR/active-force-symlink" "$TEST_REPO/active-force-link"
114+
run _clean_should_skip "$TEST_REPO/active-force-link" "active-force-symlink" 1 "$TEST_WORKTREES_DIR/active-force-symlink"
115+
[ "$status" -eq 0 ] # 0 = skip (protection maintained)
116+
}
117+
118+
@test "cmd_clean accepts --force and -f flags without error" {
119+
run cmd_clean --force
120+
[ "$status" -eq 0 ]
121+
122+
run cmd_clean -f
123+
[ "$status" -eq 0 ]
124+
}
125+
126+
@test "cmd_clean --merged --force removes dirty merged worktrees" {
127+
create_test_worktree "merged-force"
128+
echo "dirty" > "$TEST_WORKTREES_DIR/merged-force/dirty.txt"
129+
git -C "$TEST_WORKTREES_DIR/merged-force" add dirty.txt
130+
131+
_clean_detect_provider() { printf "github"; }
132+
ensure_provider_cli() { return 0; }
133+
check_branch_merged() { [ "$2" = "merged-force" ]; }
134+
run_hooks_in() { return 0; }
135+
run_hooks() { return 0; }
136+
137+
run cmd_clean --merged --force --yes
138+
[ "$status" -eq 0 ]
139+
[ ! -d "$TEST_WORKTREES_DIR/merged-force" ]
140+
}
141+
142+
@test "cmd_clean --merged --force skips the current active worktree" {
143+
create_test_worktree "active-merged"
144+
cd "$TEST_WORKTREES_DIR/active-merged" || false
145+
echo "dirty" > dirty.txt
146+
git add dirty.txt
147+
148+
_clean_detect_provider() { printf "github"; }
149+
ensure_provider_cli() { return 0; }
150+
check_branch_merged() { [ "$2" = "active-merged" ]; }
151+
run_hooks_in() { return 0; }
152+
run_hooks() { return 0; }
153+
154+
run cmd_clean --merged --force --yes
155+
[ "$status" -eq 0 ]
156+
[ -d "$TEST_WORKTREES_DIR/active-merged" ]
157+
}

0 commit comments

Comments
 (0)