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: 1 addition & 1 deletion docs/spec-warnings.md
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ A third class: a destructive command declining to act. These state the fact and
| `<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` |
| `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` |
| `<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` |
| `<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` |
| `<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` |
| `<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` |
| `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` |
| `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` |
Expand Down
33 changes: 30 additions & 3 deletions orbit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2893,40 +2893,67 @@ $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.<name>.* 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.<default>.* 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 <<EOF
$porcelain
EOF
while IFS= read -r line; do
[ -n "$line" ] || continue
# branch.<name>.<key>: 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
done < <(git -C "$repo" config --get-regexp '^branch\..*\.' 2>/dev/null | cut -d' ' -f1 | sort -u || true)
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
Expand Down
46 changes: 46 additions & 0 deletions tests/09_prune.bats
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
Loading