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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -343,6 +343,8 @@ git gtr clean --merged --force --yes # Force-clean and auto-confirm

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

**Note:** `clean` also detects registry entries that are locked but whose directories no longer exist (for example, a crashed agent session that deleted its worktree directory). `git worktree prune` skips locked entries by design, so these linger and keep their branches checked out. `clean` offers to unlock and prune them; `--force` or `--yes` confirms automatically.

### `git gtr trust`

Review and approve executable commands defined in the repository's `.gtrconfig` file. Hooks and editor/AI defaults from `.gtrconfig` are **not used** until explicitly trusted — this prevents malicious contributors from injecting arbitrary shell commands via shared config files.
Expand Down
59 changes: 59 additions & 0 deletions lib/commands/clean.sh
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,61 @@ _clean_should_skip() {
return 1
}

# Recover registry entries that are locked but whose directories are gone
# (e.g. an agent session crashed after its worktree directory was deleted).
# `git worktree prune` skips locked entries by design, so they linger and
# keep their branches checked out in a phantom worktree.
# Usage: _clean_locked_phantoms repo_root yes_mode dry_run force
_clean_locked_phantoms() {
local repo_root="$1" yes_mode="$2" dry_run="$3" force="${4:-0}"
local records
records=$(list_worktree_records "$repo_root")

local is_main="" dir="" wt_status="" line unlocked=0
while IFS= read -r line; do
case "$line" in
"")
if [ -n "$dir" ] && [ "$is_main" != "1" ] && [ "$wt_status" = "locked" ] && [ ! -e "$dir" ]; then
log_warn "Locked worktree entry with missing directory: $dir"
if [ "$dry_run" -eq 1 ]; then
log_info "[dry-run] Would unlock and prune: $dir"
elif [ "$force" -eq 1 ] || [ "$yes_mode" -eq 1 ] || \
prompt_yes_no "Unlock and prune '$dir'?"; then
if git -C "$repo_root" worktree unlock "$dir" 2>/dev/null; then
log_info "Unlocked missing worktree entry: $dir"
unlocked=$((unlocked + 1))
else
log_warn "Could not unlock worktree entry: $dir"
fi
else
log_info "Recover manually with: git worktree unlock '$dir' && git worktree prune"
fi
fi
is_main=""
dir=""
wt_status=""
;;
"is_main "*)
is_main="${line#is_main }"
;;
"path "*)
dir=$(_tsv_unescape_field "${line#path }")
;;
"status "*)
wt_status="${line#status }"
;;
esac
done <<EOF
$records

EOF

if [ "$unlocked" -gt 0 ]; then
git -C "$repo_root" worktree prune 2>/dev/null || true
log_info "Pruned $unlocked unlocked worktree entr$([ "$unlocked" -eq 1 ] && echo 'y' || echo 'ies')"
fi
}

# 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] [target_ref]
_clean_merged() {
Expand Down Expand Up @@ -206,6 +261,10 @@ cmd_clean() {
active_worktree_path=$(canonicalize_path "$active_worktree_path" || printf "%s" "$active_worktree_path")
fi

# Locked entries with missing directories survive `git worktree prune`;
# offer to unlock and prune them (may live outside base_dir)
_clean_locked_phantoms "$repo_root" "$yes_mode" "$dry_run" "$force"

if [ ! -d "$base_dir" ]; then
log_info "No worktrees directory to clean"
return 0
Expand Down
4 changes: 4 additions & 0 deletions lib/commands/help.sh
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,10 @@ Removes empty worktree directories and optionally removes worktrees whose
PRs/MRs have been merged. Auto-detects GitHub (gh) or GitLab (glab) from
the remote URL.

Also detects registry entries that are locked but whose directories no
longer exist (git worktree prune skips locked entries) and offers to
unlock and prune them. Confirmed automatically with --force or --yes.

Options:
--merged Also remove worktrees with merged PRs/MRs
--to <ref> Only remove worktrees for PRs/MRs merged into <ref>
Expand Down
71 changes: 71 additions & 0 deletions tests/cmd_clean.bats
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,77 @@ teardown() {
[[ "$output" != *"dirty-not-merged"* ]]
}

# ── Locked entries with missing directories (#180) ──────────────────────────

# Create a locked worktree whose directory has been deleted out from under git
# Usage: create_locked_phantom <branch>
create_locked_phantom() {
local branch="$1"
create_test_worktree "$branch"
git -C "$TEST_REPO" worktree lock "$TEST_WORKTREES_DIR/$branch"
rm -rf "$TEST_WORKTREES_DIR/$branch"
}

@test "cmd_clean surfaces recovery hint for locked missing worktree when declined" {
create_locked_phantom "phantom-hint"

run cmd_clean < /dev/null
[ "$status" -eq 0 ]
[[ "$output" == *"Locked worktree entry with missing directory"* ]]
[[ "$output" == *"git worktree unlock"* ]]
# Entry stays registered without confirmation
git -C "$TEST_REPO" worktree list --porcelain | grep -q "phantom-hint"
}

@test "cmd_clean --force unlocks and prunes locked missing worktree" {
create_locked_phantom "phantom-force"

run cmd_clean --force
[ "$status" -eq 0 ]
! git -C "$TEST_REPO" worktree list --porcelain | grep -q "phantom-force"
# Branch is no longer held by the phantom worktree
git -C "$TEST_REPO" branch -D phantom-force
}

@test "cmd_clean --yes unlocks and prunes locked missing worktree" {
create_locked_phantom "phantom-yes"

run cmd_clean --yes
[ "$status" -eq 0 ]
! git -C "$TEST_REPO" worktree list --porcelain | grep -q "phantom-yes"
}

@test "cmd_clean --dry-run reports locked missing worktree without changes" {
create_locked_phantom "phantom-dry"

run cmd_clean --dry-run --force
[ "$status" -eq 0 ]
[[ "$output" == *"[dry-run] Would unlock and prune"* ]]
git -C "$TEST_REPO" worktree list --porcelain | grep -q "phantom-dry"
}

@test "cmd_clean --force keeps locked worktree whose directory exists" {
create_test_worktree "locked-alive"
git -C "$TEST_REPO" worktree lock "$TEST_WORKTREES_DIR/locked-alive"

run cmd_clean --force --yes
[ "$status" -eq 0 ]
[ -d "$TEST_WORKTREES_DIR/locked-alive" ]
git -C "$TEST_REPO" worktree list --porcelain | grep -q "locked-alive"
}

@test "cmd_clean --merged recovers locked missing worktree before merged pass" {
create_locked_phantom "phantom-merged"

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

run cmd_clean --merged --force --yes
[ "$status" -eq 0 ]
! git -C "$TEST_REPO" worktree list --porcelain | grep -q "phantom-merged"
}

@test "cmd_clean --merged --force skips the current active worktree" {
create_test_worktree "active-merged"
cd "$TEST_WORKTREES_DIR/active-merged" || false
Expand Down
Loading