diff --git a/README.md b/README.md
index ee0e5e0..d1a7ff4 100644
--- a/README.md
+++ b/README.md
@@ -327,6 +327,8 @@ git gtr clean # Remove empty worktree directori
git gtr clean --merged # Remove worktrees for merged PRs/MRs
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 --yes # Force-clean and auto-confirm
```
**Options:**
@@ -334,6 +336,7 @@ git gtr clean --merged --yes # Remove without confirmation pro
- `--merged`: Remove worktrees whose branches have merged PRs/MRs (also deletes the branch)
- `--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`.
diff --git a/completions/_git-gtr b/completions/_git-gtr
index 17c5c5b..e0a5ba3 100644
--- a/completions/_git-gtr
+++ b/completions/_git-gtr
@@ -83,7 +83,9 @@ _git-gtr() {
'--yes[Skip confirmation prompts]' \
'-y[Skip confirmation prompts]' \
'--dry-run[Show what would be removed]' \
- '-n[Show what would be removed]'
+ '-n[Show what would be removed]' \
+ '--force[Force removal even if worktree has uncommitted changes or untracked files]' \
+ '-f[Force removal even if worktree has uncommitted changes or untracked files]'
return
fi
@@ -133,7 +135,7 @@ _git-gtr() {
rm)
_arguments \
'--delete-branch[Delete branch]' \
- '--force[Force removal even if dirty]' \
+ '--force[Force removal even if worktree has uncommitted changes or untracked files]' \
'--yes[Non-interactive mode]'
;;
mv|rename)
diff --git a/completions/git-gtr.fish b/completions/git-gtr.fish
index 2a3b351..7fdc786 100644
--- a/completions/git-gtr.fish
+++ b/completions/git-gtr.fish
@@ -73,7 +73,7 @@ complete -c git -n '__fish_git_gtr_using_command new' -s a -l ai -d 'Start AI to
# Remove command options
complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch'
-complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if dirty'
+complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files'
complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode'
# Rename command options
@@ -103,6 +103,7 @@ complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirma
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'
complete -c git -n '__fish_git_gtr_using_command clean' -s n -d 'Show what would be removed'
+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'
# Config command
complete -f -c git -n '__fish_git_gtr_using_command config' -a 'list get set add unset'
diff --git a/completions/gtr.bash b/completions/gtr.bash
index 0983376..13314cf 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" -- "$cur"))
+ COMPREPLY=($(compgen -W "--merged --yes -y --dry-run -n --force -f" -- "$cur"))
fi
;;
copy)
diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh
index 929f078..477d07d 100644
--- a/lib/commands/clean.sh
+++ b/lib/commands/clean.sh
@@ -30,33 +30,47 @@ _clean_detect_provider() {
# Check if a worktree should be skipped during merged cleanup.
# Returns 0 if should skip, 1 if should process.
-# Usage: _clean_should_skip
+# Usage: _clean_should_skip [force] [active_worktree_path]
_clean_should_skip() {
- local dir="$1" branch="$2"
+ local dir="$1" branch="$2" force="${3:-0}" active_worktree_path="${4:-}"
+ local dir_canonical="$dir"
+ local active_worktree_canonical="$active_worktree_path"
- if [ -z "$branch" ] || [ "$branch" = "(detached)" ]; then
- log_warn "Skipping $dir (detached HEAD)"
- return 0
+ if [ -n "$active_worktree_path" ]; then
+ dir_canonical=$(canonicalize_path "$dir" || printf "%s" "$dir")
+ active_worktree_canonical=$(canonicalize_path "$active_worktree_path" || printf "%s" "$active_worktree_path")
fi
- if ! git -C "$dir" diff --quiet 2>/dev/null || \
- ! git -C "$dir" diff --cached --quiet 2>/dev/null; then
- log_warn "Skipping $branch (has uncommitted changes)"
+ if [ -n "$active_worktree_path" ] && [ "$dir_canonical" = "$active_worktree_canonical" ]; then
+ log_warn "Skipping $branch (current active worktree)"
return 0
fi
- if [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then
- log_warn "Skipping $branch (has untracked files)"
+ if [ -z "$branch" ] || [ "$branch" = "(detached)" ]; then
+ log_warn "Skipping $dir (detached HEAD)"
return 0
fi
+ if [ "$force" -eq 0 ]; then
+ if ! git -C "$dir" diff --quiet 2>/dev/null || \
+ ! git -C "$dir" diff --cached --quiet 2>/dev/null; then
+ log_warn "Skipping $branch (has uncommitted changes)"
+ return 0
+ fi
+
+ if [ -n "$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null)" ]; then
+ log_warn "Skipping $branch (has untracked files)"
+ return 0
+ fi
+ fi
+
return 1
}
# Remove worktrees whose PRs/MRs are merged (handles squash merges)
-# Usage: _clean_merged repo_root base_dir prefix yes_mode dry_run
+# Usage: _clean_merged repo_root base_dir prefix yes_mode dry_run [force] [active_worktree_path]
_clean_merged() {
- local repo_root="$1" base_dir="$2" prefix="$3" yes_mode="$4" dry_run="$5"
+ local repo_root="$1" base_dir="$2" prefix="$3" yes_mode="$4" dry_run="$5" force="${6:-0}" active_worktree_path="${7:-}"
log_step "Checking for worktrees with merged PRs/MRs..."
@@ -80,7 +94,7 @@ _clean_merged() {
# Skip main repo branch silently (not counted)
[ "$branch" = "$main_branch" ] && continue
- if _clean_should_skip "$dir" "$branch"; then
+ if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then
skipped=$((skipped + 1))
continue
fi
@@ -102,7 +116,7 @@ _clean_merged() {
continue
fi
- if remove_worktree "$dir" 0; then
+ if remove_worktree "$dir" "$force"; then
git branch -d "$branch" 2>/dev/null || git branch -D "$branch" 2>/dev/null || true
removed=$((removed + 1))
@@ -133,12 +147,15 @@ cmd_clean() {
local _spec
_spec="--merged
--yes|-y
---dry-run|-n"
+--dry-run|-n
+--force|-f"
parse_args "$_spec" "$@"
local merged_mode="${_arg_merged:-0}"
local yes_mode="${_arg_yes:-0}"
local dry_run="${_arg_dry_run:-0}"
+ local force="${_arg_force:-0}"
+ local active_worktree_path=""
log_step "Cleaning up stale worktrees..."
@@ -151,6 +168,11 @@ cmd_clean() {
local repo_root="$_ctx_repo_root" base_dir="$_ctx_base_dir" prefix="$_ctx_prefix"
+ active_worktree_path=$(git rev-parse --show-toplevel 2>/dev/null || true)
+ if [ -n "$active_worktree_path" ]; then
+ active_worktree_path=$(canonicalize_path "$active_worktree_path" || printf "%s" "$active_worktree_path")
+ fi
+
if [ ! -d "$base_dir" ]; then
log_info "No worktrees directory to clean"
return 0
@@ -182,6 +204,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"
+ _clean_merged "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run" "$force" "$active_worktree_path"
fi
-}
\ No newline at end of file
+}
diff --git a/lib/commands/help.sh b/lib/commands/help.sh
index bbb3e60..c680987 100644
--- a/lib/commands/help.sh
+++ b/lib/commands/help.sh
@@ -305,12 +305,15 @@ Options:
--merged Also remove worktrees with merged PRs/MRs
--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
Examples:
git gtr clean # Clean empty directories
git gtr clean --merged # Also clean merged PRs
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
+ git gtr clean --merged --force --yes # Force-clean and auto-confirm
EOF
}
@@ -567,6 +570,7 @@ SETUP & MAINTENANCE:
Override: git gtr config set gtr.provider gitlab
--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
completion
Generate shell completions (bash, zsh, fish)
diff --git a/scripts/generate-completions.sh b/scripts/generate-completions.sh
index 9f483c6..1072645 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" -- "$cur"))
+ COMPREPLY=($(compgen -W "--merged --yes -y --dry-run -n --force -f" -- "$cur"))
fi
;;
copy)
@@ -342,7 +342,9 @@ _git-gtr() {
'--yes[Skip confirmation prompts]' \
'-y[Skip confirmation prompts]' \
'--dry-run[Show what would be removed]' \
- '-n[Show what would be removed]'
+ '-n[Show what would be removed]' \
+ '--force[Force removal even if worktree has uncommitted changes or untracked files]' \
+ '-f[Force removal even if worktree has uncommitted changes or untracked files]'
return
fi
@@ -396,7 +398,7 @@ MIDDLE1
rm)
_arguments \
'--delete-branch[Delete branch]' \
- '--force[Force removal even if dirty]' \
+ '--force[Force removal even if worktree has uncommitted changes or untracked files]' \
'--yes[Non-interactive mode]'
;;
mv|rename)
@@ -546,7 +548,7 @@ complete -c git -n '__fish_git_gtr_using_command new' -s a -l ai -d 'Start AI to
# Remove command options
complete -c git -n '__fish_git_gtr_using_command rm' -l delete-branch -d 'Delete branch'
-complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if dirty'
+complete -c git -n '__fish_git_gtr_using_command rm' -l force -d 'Force removal even if worktree has uncommitted changes or untracked files'
complete -c git -n '__fish_git_gtr_using_command rm' -l yes -d 'Non-interactive mode'
# Rename command options
@@ -580,6 +582,7 @@ complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirma
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'
complete -c git -n '__fish_git_gtr_using_command clean' -s n -d 'Show what would be removed'
+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'
# Config command
complete -f -c git -n '__fish_git_gtr_using_command config' -a 'list get set add unset'
diff --git a/tests/cmd_clean.bats b/tests/cmd_clean.bats
index fa8031d..59160c2 100644
--- a/tests/cmd_clean.bats
+++ b/tests/cmd_clean.bats
@@ -76,3 +76,82 @@ teardown() {
run _clean_should_skip "$TEST_WORKTREES_DIR/clean-wt" "clean-wt"
[ "$status" -eq 1 ] # 1 = don't skip
}
+
+@test "_clean_should_skip with force=1 does not skip dirty worktree" {
+ create_test_worktree "dirty-force"
+ echo "dirty" > "$TEST_WORKTREES_DIR/dirty-force/untracked.txt"
+ git -C "$TEST_WORKTREES_DIR/dirty-force" add untracked.txt
+ run _clean_should_skip "$TEST_WORKTREES_DIR/dirty-force" "dirty-force" 1
+ [ "$status" -eq 1 ] # 1 = don't skip
+}
+
+@test "_clean_should_skip with force=1 does not skip worktree with untracked files" {
+ create_test_worktree "untracked-force"
+ echo "new" > "$TEST_WORKTREES_DIR/untracked-force/newfile.txt"
+ run _clean_should_skip "$TEST_WORKTREES_DIR/untracked-force" "untracked-force" 1
+ [ "$status" -eq 1 ] # 1 = don't skip
+}
+
+@test "_clean_should_skip with force=1 still skips detached HEAD" {
+ run _clean_should_skip "/some/dir" "(detached)" 1
+ [ "$status" -eq 0 ] # 0 = skip (protection maintained)
+}
+
+@test "_clean_should_skip with force=1 still skips empty branch" {
+ run _clean_should_skip "/some/dir" "" 1
+ [ "$status" -eq 0 ] # 0 = skip (protection maintained)
+}
+
+@test "_clean_should_skip with force=1 still skips current active worktree" {
+ create_test_worktree "active-force"
+ run _clean_should_skip "$TEST_WORKTREES_DIR/active-force" "active-force" 1 "$TEST_WORKTREES_DIR/active-force"
+ [ "$status" -eq 0 ] # 0 = skip (protection maintained)
+}
+
+@test "_clean_should_skip with force=1 skips current active worktree via symlink path" {
+ create_test_worktree "active-force-symlink"
+ ln -s "$TEST_WORKTREES_DIR/active-force-symlink" "$TEST_REPO/active-force-link"
+ run _clean_should_skip "$TEST_REPO/active-force-link" "active-force-symlink" 1 "$TEST_WORKTREES_DIR/active-force-symlink"
+ [ "$status" -eq 0 ] # 0 = skip (protection maintained)
+}
+
+@test "cmd_clean accepts --force and -f flags without error" {
+ run cmd_clean --force
+ [ "$status" -eq 0 ]
+
+ run cmd_clean -f
+ [ "$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"
+ git -C "$TEST_WORKTREES_DIR/merged-force" add dirty.txt
+
+ _clean_detect_provider() { printf "github"; }
+ ensure_provider_cli() { return 0; }
+ check_branch_merged() { [ "$2" = "merged-force" ]; }
+ run_hooks_in() { return 0; }
+ run_hooks() { return 0; }
+
+ run cmd_clean --merged --force --yes
+ [ "$status" -eq 0 ]
+ [ ! -d "$TEST_WORKTREES_DIR/merged-force" ]
+}
+
+@test "cmd_clean --merged --force skips the current active worktree" {
+ create_test_worktree "active-merged"
+ cd "$TEST_WORKTREES_DIR/active-merged" || false
+ echo "dirty" > dirty.txt
+ git add dirty.txt
+
+ _clean_detect_provider() { printf "github"; }
+ ensure_provider_cli() { return 0; }
+ check_branch_merged() { [ "$2" = "active-merged" ]; }
+ run_hooks_in() { return 0; }
+ run_hooks() { return 0; }
+
+ run cmd_clean --merged --force --yes
+ [ "$status" -eq 0 ]
+ [ -d "$TEST_WORKTREES_DIR/active-merged" ]
+}