diff --git a/README.md b/README.md index eaadc58..2ade753 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/commands/clean.sh b/lib/commands/clean.sh index a0226f8..ff3de22 100644 --- a/lib/commands/clean.sh +++ b/lib/commands/clean.sh @@ -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 </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() { @@ -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 diff --git a/lib/commands/help.sh b/lib/commands/help.sh index 4a461b6..66657d3 100644 --- a/lib/commands/help.sh +++ b/lib/commands/help.sh @@ -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 Only remove worktrees for PRs/MRs merged into diff --git a/tests/cmd_clean.bats b/tests/cmd_clean.bats index 55d93f5..173d2bb 100644 --- a/tests/cmd_clean.bats +++ b/tests/cmd_clean.bats @@ -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 +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