Skip to content
Open
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ Remove worktrees: clean up empty directories, or remove those with merged PRs/MR
```bash
git gtr clean # Remove empty worktree directories and prune
git gtr clean --merged # Remove worktrees for merged PRs/MRs
git gtr clean --merged --to main # Only remove worktrees merged to main
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
Expand All @@ -334,6 +335,7 @@ 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)
- `--to <ref>`: Limit `--merged` cleanup to PRs/MRs merged into the given base ref
- `--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
Expand Down
1 change: 1 addition & 0 deletions completions/_git-gtr
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,7 @@ _git-gtr() {
if (( CURRENT >= 4 )) && [[ $words[3] == clean ]]; then
_arguments \
'--merged[Remove worktrees with merged PRs/MRs]' \
'--to[Only remove worktrees for PRs/MRs merged into this ref]:ref:' \
'--yes[Skip confirmation prompts]' \
'-y[Skip confirmation prompts]' \
'--dry-run[Show what would be removed]' \
Expand Down
1 change: 1 addition & 0 deletions completions/git-gtr.fish
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ complete -c git -n '__fish_git_gtr_using_command ai' -l ai -d 'AI tool to use' -

# Clean command options
complete -c git -n '__fish_git_gtr_using_command clean' -l merged -d 'Remove worktrees with merged PRs/MRs'
complete -c git -n '__fish_git_gtr_using_command clean' -l to -d 'Only remove worktrees for PRs/MRs merged into this ref' -r
complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirmation prompts'
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'
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 --force -f" -- "$cur"))
COMPREPLY=($(compgen -W "--merged --to --yes -y --dry-run -n --force -f" -- "$cur"))
fi
;;
copy)
Expand Down
27 changes: 18 additions & 9 deletions lib/commands/clean.sh
Original file line number Diff line number Diff line change
Expand Up @@ -68,9 +68,9 @@ _clean_should_skip() {
}

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

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

Expand All @@ -90,17 +90,19 @@ _clean_merged() {

local branch
branch=$(current_branch "$dir") || true
local branch_tip
branch_tip=$(git -C "$dir" rev-parse HEAD 2>/dev/null || true)

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

if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then
skipped=$((skipped + 1))
continue
fi

# Check if branch has a merged PR/MR
if check_branch_merged "$provider" "$branch"; then
if check_branch_merged "$provider" "$branch" "$target_ref" "$branch_tip"; then
if _clean_should_skip "$dir" "$branch" "$force" "$active_worktree_path"; then
skipped=$((skipped + 1))
continue
fi

if [ "$dry_run" -eq 1 ]; then
log_info "[dry-run] Would remove: $branch ($dir)"
removed=$((removed + 1))
Expand Down Expand Up @@ -146,17 +148,24 @@ _clean_merged() {
cmd_clean() {
local _spec
_spec="--merged
--to: value
--yes|-y
--dry-run|-n
--force|-f"
parse_args "$_spec" "$@"

local merged_mode="${_arg_merged:-0}"
local target_ref="${_arg_to:-}"
local yes_mode="${_arg_yes:-0}"
local dry_run="${_arg_dry_run:-0}"
local force="${_arg_force:-0}"
local active_worktree_path=""

if [ -n "$target_ref" ] && [ "$merged_mode" -ne 1 ]; then
log_error "--to can only be used with --merged"
return 1
fi

log_step "Cleaning up stale worktrees..."

# Run git worktree prune
Expand Down Expand Up @@ -204,6 +213,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" "$force" "$active_worktree_path"
_clean_merged "$repo_root" "$base_dir" "$prefix" "$yes_mode" "$dry_run" "$force" "$active_worktree_path" "$target_ref"
fi
}
3 changes: 3 additions & 0 deletions lib/commands/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -303,13 +303,15 @@ the remote URL.

Options:
--merged Also remove worktrees with merged PRs/MRs
--to <ref> Only remove worktrees for PRs/MRs merged into <ref>
--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 --to main # Only clean PRs merged to main
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
Expand Down Expand Up @@ -566,6 +568,7 @@ SETUP & MAINTENANCE:
clean [options]
Remove stale/prunable worktrees and empty directories
--merged: also remove worktrees with merged PRs/MRs
--to <ref>: limit merged cleanup to PRs/MRs merged into <ref>
Auto-detects GitHub (gh) or GitLab (glab) from remote URL
Override: git gtr config set gtr.provider gitlab
--yes, -y: skip confirmation prompts
Expand Down
89 changes: 81 additions & 8 deletions lib/provider.sh
Original file line number Diff line number Diff line change
Expand Up @@ -97,23 +97,96 @@ ensure_provider_cli() {
esac
}

# Check if a branch has a merged PR/MR on the detected provider
# Usage: check_branch_merged <provider> <branch>
# Normalize user-provided refs to plain branch names for provider filters.
# Usage: normalize_target_ref [target_ref]
normalize_target_ref() {
local target_ref="${1:-}"
local remote_ref

[ -n "$target_ref" ] || return 0

case "$target_ref" in
refs/heads/*)
printf "%s" "${target_ref#refs/heads/}"
;;
refs/remotes/*)
remote_ref="${target_ref#refs/remotes/}"
printf "%s" "${remote_ref#*/}"
;;
origin/*|upstream/*)
printf "%s" "${target_ref#*/}"
;;
*)
if git show-ref --verify --quiet "refs/remotes/$target_ref" 2>/dev/null; then
printf "%s" "${target_ref#*/}"
else
printf "%s" "$target_ref"
fi
;;
esac
}

# Check if a branch has a merged PR/MR on the detected provider.
# When branch_tip is provided, require the merged PR/MR to point at the same
# commit so reused branch names do not match older merged PRs.
# Usage: check_branch_merged <provider> <branch> [target_ref] [branch_tip]
# Returns 0 if merged, 1 if not
check_branch_merged() {
local provider="$1"
local branch="$2"
local target_ref="${3:-}"
local branch_tip="${4:-}"
local normalized_target_ref

normalized_target_ref=$(normalize_target_ref "$target_ref") || true

case "$provider" in
github)
local pr_state
pr_state=$(gh pr list --head "$branch" --state merged --json state --jq '.[0].state' 2>/dev/null || true)
[ "$pr_state" = "MERGED" ]
local -a gh_args
local pr_matches
gh_args=(pr list --head "$branch" --state merged --limit 1000)
if [ -n "$normalized_target_ref" ]; then
gh_args+=(--base "$normalized_target_ref")
fi
if [ -n "$normalized_target_ref" ]; then
if [ -n "$branch_tip" ]; then
pr_matches=$(gh "${gh_args[@]}" --json state,headRefOid --jq "map(select(.state == \"MERGED\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true)
else
pr_matches=$(gh "${gh_args[@]}" --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true)
fi
else
if [ -n "$branch_tip" ]; then
pr_matches=$(gh "${gh_args[@]}" --json state,headRefOid --jq "map(select(.state == \"MERGED\" and .headRefOid == \"$branch_tip\")) | length" 2>/dev/null || true)
else
pr_matches=$(gh "${gh_args[@]}" --json state --jq 'map(select(.state == "MERGED")) | length' 2>/dev/null || true)
fi
fi
[ "${pr_matches:-0}" -gt 0 ]
;;
gitlab)
local mr_result
mr_result=$(glab mr list --source-branch "$branch" --merged --per-page 1 --output json 2>/dev/null || true)
[ -n "$mr_result" ] && [ "$mr_result" != "[]" ] && [ "$mr_result" != "null" ]
local mr_result compact_result
local -a glab_args
glab_args=(mr list --source-branch "$branch" --merged --all --output json)
if [ -n "$normalized_target_ref" ]; then
glab_args+=(--target-branch "$normalized_target_ref")
fi

mr_result=$(glab "${glab_args[@]}" 2>/dev/null || true)
[ -n "$mr_result" ] && [ "$mr_result" != "[]" ] && [ "$mr_result" != "null" ] || return 1

if [ -n "$branch_tip" ]; then
compact_result=$(printf "%s" "$mr_result" | tr -d '[:space:]')
case "$compact_result" in
*"\"sha\":\"$branch_tip\""*|*"\"head_sha\":\"$branch_tip\""*)
return 0
;;
*)
return 1
;;
esac
fi

return 0
;;
*)
return 1
Expand Down
4 changes: 3 additions & 1 deletion 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 --force -f" -- "$cur"))
COMPREPLY=($(compgen -W "--merged --to --yes -y --dry-run -n --force -f" -- "$cur"))
fi
;;
copy)
Expand Down Expand Up @@ -339,6 +339,7 @@ _git-gtr() {
if (( CURRENT >= 4 )) && [[ $words[3] == clean ]]; then
_arguments \
'--merged[Remove worktrees with merged PRs/MRs]' \
'--to[Only remove worktrees for PRs/MRs merged into this ref]:ref:' \
'--yes[Skip confirmation prompts]' \
'-y[Skip confirmation prompts]' \
'--dry-run[Show what would be removed]' \
Expand Down Expand Up @@ -578,6 +579,7 @@ MIDDLE1

# Clean command options
complete -c git -n '__fish_git_gtr_using_command clean' -l merged -d 'Remove worktrees with merged PRs/MRs'
complete -c git -n '__fish_git_gtr_using_command clean' -l to -d 'Only remove worktrees for PRs/MRs merged into this ref' -r
complete -c git -n '__fish_git_gtr_using_command clean' -l yes -d 'Skip confirmation prompts'
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'
Expand Down
58 changes: 56 additions & 2 deletions tests/cmd_clean.bats
Original file line number Diff line number Diff line change
Expand Up @@ -123,14 +123,20 @@ teardown() {
[ "$status" -eq 0 ]
}

@test "cmd_clean rejects --to without --merged" {
run cmd_clean --to main
[ "$status" -eq 1 ]
[[ "$output" == *"--to can only be used with --merged"* ]]
}

@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" ]; }
check_branch_merged() { [ "$2" = "merged-force" ] && [ -z "$3" ]; }
run_hooks_in() { return 0; }
run_hooks() { return 0; }

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

@test "cmd_clean --merged --to filters by target ref" {
create_test_worktree "merged-to-main"
create_test_worktree "merged-to-feature"

_clean_detect_provider() { printf "github"; }
ensure_provider_cli() { return 0; }
check_branch_merged() {
[ "$3" = "main" ] && [ "$2" = "merged-to-main" ]
}
run_hooks_in() { return 0; }
run_hooks() { return 0; }

run cmd_clean --merged --to main --yes
[ "$status" -eq 0 ]
[ ! -d "$TEST_WORKTREES_DIR/merged-to-main" ]
[ -d "$TEST_WORKTREES_DIR/merged-to-feature" ]
}

@test "cmd_clean passes current branch HEAD to merged check" {
create_test_worktree "merged-tip"
local branch_tip
branch_tip=$(git -C "$TEST_WORKTREES_DIR/merged-tip" rev-parse HEAD)

_clean_detect_provider() { printf "github"; }
ensure_provider_cli() { return 0; }
check_branch_merged() { [ "$2" = "merged-tip" ] && [ "$3" = "main" ] && [ "$4" = "$branch_tip" ]; }
run_hooks_in() { return 0; }
run_hooks() { return 0; }

run cmd_clean --merged --to main --yes
[ "$status" -eq 0 ]
[ ! -d "$TEST_WORKTREES_DIR/merged-tip" ]
}

@test "cmd_clean does not log dirty skip for non-merged worktree" {
create_test_worktree "dirty-not-merged"
echo "dirty" > "$TEST_WORKTREES_DIR/dirty-not-merged/dirty.txt"
git -C "$TEST_WORKTREES_DIR/dirty-not-merged" add dirty.txt

_clean_detect_provider() { printf "github"; }
ensure_provider_cli() { return 0; }
check_branch_merged() { return 1; }

run cmd_clean --merged --to main --yes
[ "$status" -eq 0 ]
[[ "$output" != *"dirty-not-merged"* ]]
}

@test "cmd_clean --merged --force skips the current active worktree" {
create_test_worktree "active-merged"
cd "$TEST_WORKTREES_DIR/active-merged" || false
Expand All @@ -147,7 +201,7 @@ teardown() {

_clean_detect_provider() { printf "github"; }
ensure_provider_cli() { return 0; }
check_branch_merged() { [ "$2" = "active-merged" ]; }
check_branch_merged() { [ "$2" = "active-merged" ] && [ -z "$3" ]; }
run_hooks_in() { return 0; }
run_hooks() { return 0; }

Expand Down
1 change: 1 addition & 0 deletions tests/cmd_help.bats
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ teardown() {
[ "$status" -eq 0 ]
[[ "$output" == *"git gtr clean"* ]]
[[ "$output" == *"--merged"* ]]
[[ "$output" == *"--to <ref>"* ]]
}

@test "cmd_help copy shows copy help" {
Expand Down
Loading
Loading