diff --git a/docs/spec-warnings.md b/docs/spec-warnings.md index eb79d7a..aa658d9 100644 --- a/docs/spec-warnings.md +++ b/docs/spec-warnings.md @@ -134,7 +134,7 @@ A third class: a destructive command declining to act. These state the fact and | ` is marked done but not older than ` | `orbit prune --older ` where the workspace is done but younger than the duration — the age fact, not a metadata problem | `orbit_prune` | | `skipping : cannot scan branches in: ` | `orbit prune`: a pool repo's refs could not be enumerated for this workspace's branch set — blocks in BOTH modes (`--force` cannot supply missing evidence); never feeds the closing block's force suggestion (a rerun would fail the same way). Same exit class as a validation refusal (nothing was attempted) | `orbit_prune` | | `: --force discards un-persisted work in — this cannot be undone` / `: --force removes whose state cannot be read — content may be un-persisted, this cannot be undone` | `orbit prune --force` immediately before removing a workspace that a content guard would have skipped — the uncommitted-changes guard (first form) or the damaged-worktree guard (second form, where orbit cannot read what is at stake). The only steps with no recovery path, so the consequence is stated before the act | `orbit_prune` | -| `: pruned N stale worktree registration(s), M orphan branch config section(s)` | `orbit prune` residue phase: maintenance whose subject no longer exists (a registration whose worktree path is gone, `branch..*` whose branch is gone) — repaired automatically, no `--force`: no object and no file with content is removed, only the admin directory and three config lines | `orbit_prune` | +| `: pruned N stale worktree registration(s), M orphan branch config section(s)` | `orbit prune` residue phase: maintenance whose subject no longer exists (a registration whose worktree path is gone, `branch..*` whose branch is gone — **gone = no ref AND checked out in no non-pool worktree**: an unborn branch, e.g. an empty repo's orphan worktree, is alive while checked out, ref or no ref; the pool's own checkout never counts) — repaired automatically, no `--force`: no object and no file with content is removed, only the admin directory and three config lines | `orbit_prune` | | `` | `orbit prune` branch cleanup: git refused the deletion (checked out elsewhere, and other native refusals). git's own first line only — its `hint:` continuations name `git branch -D`, which a refusal must not hand out. The branch counts as skipped | `orbit_branch_delete` | | `workspaces kept: , …` + `after confirming …, force-delete:` + per-workspace `orbit prune --force` | closing block of a `prune` run with kept content — fed by validation refusals (live workspaces, all-or-nothing) and kept ghost branches; scoped entries only — a raw skip belongs to the raw report below. Two refusals never feed it because the suggestion cannot help: a scan failure (above; the rerun fails the same way) and a deletion that git *refused* mid-execution (an execution failure, not a keep) | `orbit_prune` | | `untraceable branches (raw, no remote, no workspace) — human disposal:` + per-branch status/review lines (grouped by repo) + `branch -D` commands | `orbit prune` enumeration report of branches traceable to nothing; orbit never deletes them | `orbit_prune_raw_residue` | diff --git a/orbit.sh b/orbit.sh index 728f5bc..1f351d3 100755 --- a/orbit.sh +++ b/orbit.sh @@ -2893,32 +2893,59 @@ $repo_group" # path still exists is a damaged worktree: validation's job, never # auto-repaired — the registration is the only evidence of that state) # - orphan branch..* config sections whose branch no longer exists +# (existence ≠ having a ref: an UNBORN branch — e.g. an empty repo's +# orphan worktree — is alive while checked out in a NON-pool worktree, +# ref or no ref; the pool's own checkout does NOT protect — its +# clone-written branch..* stays reapable, mirroring the +# registration side's pool exemption) # Neither touches an object or a file with content, so no --force. Prints one # summary line when it repaired anything. --dry-run evaluates and stays # silent (the line's exact counts add no plan value over the dry-run's other # output). orbit_prune_repo_maintenance() { local repo="$1" dry_run="$2" n_reg=0 n_cfg=0 line wt_path br + # Porcelain prints PHYSICAL paths (macOS /var → /private/var); a logical + # $repo would never match the pool's own worktree line (physical-vs-logical + # mismatch lesson from the prune cwd-protection pitfall). Keep the logical + # basename for display: callers print group headers from the un-resolved + # path — a symlinked pool dir must not rename the line under its header. + local display + display=$(basename "$repo") + repo=$(cd "$repo" 2>/dev/null && pwd -P) || return 1 if [ "$dry_run" = "0" ]; then + local porcelain checked_out=" " pool_wt=0 + porcelain=$(git -C "$repo" worktree list --porcelain 2>/dev/null || true) while IFS= read -r line; do case "$line" in worktree\ *) wt_path="${line#worktree }" + pool_wt=0 # The pool's own checkout is never stale, whatever the registry says. - [ "$wt_path" = "$repo" ] && continue + if [ "$wt_path" = "$repo" ]; then pool_wt=1; continue; fi [ -e "$wt_path" ] && continue if git -C "$repo" worktree remove --force "$wt_path" >/dev/null 2>&1; then n_reg=$((n_reg + 1)) fi ;; + branch\ refs/heads/*) + # Porcelain groups the branch line under its worktree: the pool's own + # checkout is infrastructure, not a user worktree — its (possibly + # unborn) branch must not shield clone-written config residue. + [ "$pool_wt" = "1" ] && continue + checked_out="$checked_out${line#branch refs/heads/} " ;; esac - done < <(git -C "$repo" worktree list --porcelain 2>/dev/null || true) + done <.: the KEY is the last segment — strip it only. # Truncating at the first dot would orphan "feat.v2" forever. br="${line#branch.}"; br="${br%.*}" git -C "$repo" rev-parse --verify --quiet "refs/heads/$br" >/dev/null 2>&1 && continue + # A branch checked out in a worktree is alive even without a ref (unborn) + # — its upstream config is push routing in use, not an orphan. + case "$checked_out" in *" $br "*) continue ;; esac if git -C "$repo" config --remove-section "branch.$br" 2>/dev/null; then n_cfg=$((n_cfg + 1)) fi @@ -2926,7 +2953,7 @@ orbit_prune_repo_maintenance() { fi if [ "$n_reg" -gt 0 ] || [ "$n_cfg" -gt 0 ]; then printf '%s: pruned %d stale worktree registration(s), %d orphan branch config section(s)\n' \ - "$(basename "$repo")" "$n_reg" "$n_cfg" + "$display" "$n_reg" "$n_cfg" return 0 fi return 1 diff --git a/tests/09_prune.bats b/tests/09_prune.bats index 30cc7a9..a3d5e16 100644 --- a/tests/09_prune.bats +++ b/tests/09_prune.bats @@ -2062,6 +2062,52 @@ EOF [ -z "$output" ] } +@test "prune: live orphan-worktree upstream config survives pool maintenance (empty repo)" { + local proj="$SANDBOX/prune-empty-live" + local remote="$SANDBOX/empty_remote_prune-empty-live.git" + create_empty_bare_repo "$remote" + TEST_PROJECT="$proj" + mkdir -p "$proj" + cd "$proj" && orbit clone "$remote" --name emptyrepo >/dev/null 2>&1 + cd "$proj" && orbit new "empty prune" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add emptyrepo >/dev/null 2>&1 + cd "$SANDBOX" + + # The worktree's branch is UNBORN (no ref) but checked out — its upstream + # config is push routing in use, never an orphan. Only the clone-written + # branch.main section (pool's own checkout — NOT protected) is reaped. + run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune 2>&1" + [ "$status" -eq 0 ] + assert_contains "$output" "1 orphan branch config section(s)" + local merge + merge=$(git -C "$proj/.repos/emptyrepo" config --get branch.ws/dev/main.merge) + [ "$merge" = "refs/heads/main" ] + run git -C "$proj/.repos/emptyrepo" config --get branch.main.merge + [ -z "$output" ] +} + +@test "prune: done empty-repo workspace reclaims cleanly — unborn config unprotected after D1" { + local proj="$SANDBOX/prune-empty-done" + local remote="$SANDBOX/empty_remote_prune-empty-done.git" + create_empty_bare_repo "$remote" + TEST_PROJECT="$proj" + mkdir -p "$proj" + cd "$proj" && orbit clone "$remote" --name emptyrepo >/dev/null 2>&1 + cd "$proj" && orbit new "empty done" --name dev >/dev/null 2>&1 + cd "$proj/dev" && orbit add emptyrepo >/dev/null 2>&1 + cd "$proj/dev" && orbit done >/dev/null 2>&1 + cd "$SANDBOX" + + run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune 2>&1" + [ "$status" -eq 0 ] + assert_contains "$output" "pruned: dev (1 worktree removed, 0 branches deleted)" + [ ! -d "$proj/dev" ] + # D1 removed the worktree first, so by maintenance time the unborn branch + # was checked out nowhere — guard self-limits, both sections reaped. + run git -C "$proj/.repos/emptyrepo" config --get-regexp '^branch\.' + [ -z "$output" ] +} + @test "prune: a pool that cannot scan branches blocks the live workspace — both modes" { local proj="$SANDBOX/prune-scan-fail" local remote="$REMOTES/prune-scan-fail-repo.git"