From 32559ddd9d41d4a770e1a4e94864a277952eefdf Mon Sep 17 00:00:00 2001 From: zheng-weihao Date: Thu, 13 Aug 2026 08:16:43 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20prune=20pool-maintenance=20reaps=20live?= =?UTF-8?q?=20unborn-branch=20upstream=20config=20=E2=80=94=20guard=20chec?= =?UTF-8?q?ked-out=20branches,=20exempt=20pool's=20own=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit orbit_prune_repo_maintenance judged an orphan branch..* section by ref existence alone (rev-parse refs/heads/). An empty repo's 'orbit add' bootstraps an orphan worktree whose scoped branch is UNBORN — registered in the worktree list, upstream config wired, but no ref — so any untargeted prune's closing sweep reaped the live worktree's push routing. First 'git push' after authoring the first commit then failed 128 ('has no upstream branch'), breaking scoped mode's wired-up-front promise; git's own hint (-u origin ws//main) would publish the internal prefixed name, and the correct repair (hand-editing branch.*) is one the skill forbids. Guard: collect branches checked out in worktrees from the same worktree-list --porcelain pass (unborn branches are listed there, ref or no ref) and skip their sections — mirroring orbit_prune_raw_residue's checked-out guard. Two refinements: - The pool's own checkout does NOT protect: porcelain lists it under an unborn 'main' for an empty repo, and its clone-written branch..* is bookkeeping residue, not push routing — exempting the pool keeps the previously-correct reap behavior unchanged (the branch line is attributed to its worktree line via porcelain grouping). - Normalize $repo with pwd -P before any path comparison: porcelain prints physical paths (macOS /var -> /private/var), so a logical $repo never matched the pool's own worktree line — the pre-existing registration-side pool exemption was latently dead, saved only by the -e fallback. The logical basename is kept for the summary line so a symlinked pool dir's line stays consistent with its group header. spec-warnings.md: the orphan-config row now defines the boundary — 'gone' = no ref AND checked out in no non-pool worktree; the pool's own checkout never counts. tests/09_prune.bats: empty-repo regression pair — (1) bare prune after 'orbit add' keeps the worktree's branch.ws/dev/main.* while still reaping the pool's branch.main.*; (2) done -> prune closes the lifecycle: D1 removes the worktree, the guard self-limits, both sections reaped. Signed-off-by: zheng-weihao --- docs/spec-warnings.md | 2 +- orbit.sh | 33 ++++++++++++++++++++++++++++--- tests/09_prune.bats | 46 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 77 insertions(+), 4 deletions(-) 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"