Skip to content

Commit 84837d2

Browse files
authored
fix: prune pool-maintenance reaps live unborn-branch upstream config — guard checked-out branches, exempt pool's own checkout (#33)
orbit_prune_repo_maintenance judged an orphan branch.<name>.* section by ref existence alone (rev-parse refs/heads/<name>). 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/<ws>/main) would publish the internal prefixed name, and the correct repair (hand-editing branch.*) is one the skill forbids. Signed-off-by: zheng-weihao <[email protected]>
1 parent dbba0a0 commit 84837d2

3 files changed

Lines changed: 77 additions & 4 deletions

File tree

docs/spec-warnings.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -134,7 +134,7 @@ A third class: a destructive command declining to act. These state the fact and
134134
| `<ws> is marked done but not older than <dur>` | `orbit prune <ws> --older <dur>` where the workspace is done but younger than the duration — the age fact, not a metadata problem | `orbit_prune` |
135135
| `skipping <ws>: cannot scan branches in: <repos>` | `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` |
136136
| `<ws>: --force discards un-persisted work in <repos> — this cannot be undone` / `<ws>: --force removes <repo> 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` |
137-
| `<repo>: 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.<name>.*` 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` |
137+
| `<repo>: 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.<name>.*` 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` |
138138
| `<git error first line>` | `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` |
139139
| `workspaces kept: <ws>, …` + `after confirming …, force-delete:` + per-workspace `orbit prune <ws> --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` |
140140
| `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` |

orbit.sh

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2893,40 +2893,67 @@ $repo_group"
28932893
# path still exists is a damaged worktree: validation's job, never
28942894
# auto-repaired — the registration is the only evidence of that state)
28952895
# - orphan branch.<name>.* config sections whose branch no longer exists
2896+
# (existence ≠ having a ref: an UNBORN branch — e.g. an empty repo's
2897+
# orphan worktree — is alive while checked out in a NON-pool worktree,
2898+
# ref or no ref; the pool's own checkout does NOT protect — its
2899+
# clone-written branch.<default>.* stays reapable, mirroring the
2900+
# registration side's pool exemption)
28962901
# Neither touches an object or a file with content, so no --force. Prints one
28972902
# summary line when it repaired anything. --dry-run evaluates and stays
28982903
# silent (the line's exact counts add no plan value over the dry-run's other
28992904
# output).
29002905
orbit_prune_repo_maintenance() {
29012906
local repo="$1" dry_run="$2" n_reg=0 n_cfg=0 line wt_path br
2907+
# Porcelain prints PHYSICAL paths (macOS /var → /private/var); a logical
2908+
# $repo would never match the pool's own worktree line (physical-vs-logical
2909+
# mismatch lesson from the prune cwd-protection pitfall). Keep the logical
2910+
# basename for display: callers print group headers from the un-resolved
2911+
# path — a symlinked pool dir must not rename the line under its header.
2912+
local display
2913+
display=$(basename "$repo")
2914+
repo=$(cd "$repo" 2>/dev/null && pwd -P) || return 1
29022915
if [ "$dry_run" = "0" ]; then
2916+
local porcelain checked_out=" " pool_wt=0
2917+
porcelain=$(git -C "$repo" worktree list --porcelain 2>/dev/null || true)
29032918
while IFS= read -r line; do
29042919
case "$line" in
29052920
worktree\ *)
29062921
wt_path="${line#worktree }"
2922+
pool_wt=0
29072923
# The pool's own checkout is never stale, whatever the registry says.
2908-
[ "$wt_path" = "$repo" ] && continue
2924+
if [ "$wt_path" = "$repo" ]; then pool_wt=1; continue; fi
29092925
[ -e "$wt_path" ] && continue
29102926
if git -C "$repo" worktree remove --force "$wt_path" >/dev/null 2>&1; then
29112927
n_reg=$((n_reg + 1))
29122928
fi
29132929
;;
2930+
branch\ refs/heads/*)
2931+
# Porcelain groups the branch line under its worktree: the pool's own
2932+
# checkout is infrastructure, not a user worktree — its (possibly
2933+
# unborn) branch must not shield clone-written config residue.
2934+
[ "$pool_wt" = "1" ] && continue
2935+
checked_out="$checked_out${line#branch refs/heads/} " ;;
29142936
esac
2915-
done < <(git -C "$repo" worktree list --porcelain 2>/dev/null || true)
2937+
done <<EOF
2938+
$porcelain
2939+
EOF
29162940
while IFS= read -r line; do
29172941
[ -n "$line" ] || continue
29182942
# branch.<name>.<key>: the KEY is the last segment — strip it only.
29192943
# Truncating at the first dot would orphan "feat.v2" forever.
29202944
br="${line#branch.}"; br="${br%.*}"
29212945
git -C "$repo" rev-parse --verify --quiet "refs/heads/$br" >/dev/null 2>&1 && continue
2946+
# A branch checked out in a worktree is alive even without a ref (unborn)
2947+
# — its upstream config is push routing in use, not an orphan.
2948+
case "$checked_out" in *" $br "*) continue ;; esac
29222949
if git -C "$repo" config --remove-section "branch.$br" 2>/dev/null; then
29232950
n_cfg=$((n_cfg + 1))
29242951
fi
29252952
done < <(git -C "$repo" config --get-regexp '^branch\..*\.' 2>/dev/null | cut -d' ' -f1 | sort -u || true)
29262953
fi
29272954
if [ "$n_reg" -gt 0 ] || [ "$n_cfg" -gt 0 ]; then
29282955
printf '%s: pruned %d stale worktree registration(s), %d orphan branch config section(s)\n' \
2929-
"$(basename "$repo")" "$n_reg" "$n_cfg"
2956+
"$display" "$n_reg" "$n_cfg"
29302957
return 0
29312958
fi
29322959
return 1

tests/09_prune.bats

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2062,6 +2062,52 @@ EOF
20622062
[ -z "$output" ]
20632063
}
20642064

2065+
@test "prune: live orphan-worktree upstream config survives pool maintenance (empty repo)" {
2066+
local proj="$SANDBOX/prune-empty-live"
2067+
local remote="$SANDBOX/empty_remote_prune-empty-live.git"
2068+
create_empty_bare_repo "$remote"
2069+
TEST_PROJECT="$proj"
2070+
mkdir -p "$proj"
2071+
cd "$proj" && orbit clone "$remote" --name emptyrepo >/dev/null 2>&1
2072+
cd "$proj" && orbit new "empty prune" --name dev >/dev/null 2>&1
2073+
cd "$proj/dev" && orbit add emptyrepo >/dev/null 2>&1
2074+
cd "$SANDBOX"
2075+
2076+
# The worktree's branch is UNBORN (no ref) but checked out — its upstream
2077+
# config is push routing in use, never an orphan. Only the clone-written
2078+
# branch.main section (pool's own checkout — NOT protected) is reaped.
2079+
run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune 2>&1"
2080+
[ "$status" -eq 0 ]
2081+
assert_contains "$output" "1 orphan branch config section(s)"
2082+
local merge
2083+
merge=$(git -C "$proj/.repos/emptyrepo" config --get branch.ws/dev/main.merge)
2084+
[ "$merge" = "refs/heads/main" ]
2085+
run git -C "$proj/.repos/emptyrepo" config --get branch.main.merge
2086+
[ -z "$output" ]
2087+
}
2088+
2089+
@test "prune: done empty-repo workspace reclaims cleanly — unborn config unprotected after D1" {
2090+
local proj="$SANDBOX/prune-empty-done"
2091+
local remote="$SANDBOX/empty_remote_prune-empty-done.git"
2092+
create_empty_bare_repo "$remote"
2093+
TEST_PROJECT="$proj"
2094+
mkdir -p "$proj"
2095+
cd "$proj" && orbit clone "$remote" --name emptyrepo >/dev/null 2>&1
2096+
cd "$proj" && orbit new "empty done" --name dev >/dev/null 2>&1
2097+
cd "$proj/dev" && orbit add emptyrepo >/dev/null 2>&1
2098+
cd "$proj/dev" && orbit done >/dev/null 2>&1
2099+
cd "$SANDBOX"
2100+
2101+
run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune 2>&1"
2102+
[ "$status" -eq 0 ]
2103+
assert_contains "$output" "pruned: dev (1 worktree removed, 0 branches deleted)"
2104+
[ ! -d "$proj/dev" ]
2105+
# D1 removed the worktree first, so by maintenance time the unborn branch
2106+
# was checked out nowhere — guard self-limits, both sections reaped.
2107+
run git -C "$proj/.repos/emptyrepo" config --get-regexp '^branch\.'
2108+
[ -z "$output" ]
2109+
}
2110+
20652111
@test "prune: a pool that cannot scan branches blocks the live workspace — both modes" {
20662112
local proj="$SANDBOX/prune-scan-fail"
20672113
local remote="$REMOTES/prune-scan-fail-repo.git"

0 commit comments

Comments
 (0)