Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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" || true)
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
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" ]
}