Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,13 +327,16 @@ 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:**

- `--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`.

Expand Down
6 changes: 4 additions & 2 deletions completions/_git-gtr
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
3 changes: 2 additions & 1 deletion completions/git-gtr.fish
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion completions/gtr.bash
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
56 changes: 39 additions & 17 deletions lib/commands/clean.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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 <dir> <branch>
# Usage: _clean_should_skip <dir> <branch> [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..."

Expand All @@ -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
Expand All @@ -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))

Expand Down Expand Up @@ -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..."

Expand All @@ -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
Expand Down Expand Up @@ -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
}
}
4 changes: 4 additions & 0 deletions lib/commands/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down Expand Up @@ -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 <shell>
Generate shell completions (bash, zsh, fish)
Expand Down
11 changes: 7 additions & 4 deletions scripts/generate-completions.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down
79 changes: 79 additions & 0 deletions tests/cmd_clean.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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" ]
}
Loading