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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ Hardens the destructive surface: `prune` and `sync --force`/`--branch` become ma
- Workspace/repo inference compares physical paths — commands work through symlinked cwds.
- Session guard warns when process ancestry is unreadable, instead of failing silently open.
- `orbit info` and the `orbit context --startup` reignite block no longer fetch — read paths are purely local again (zero network): #29's touchpoint fetch made every `info` and every session start with worktrees pay N serial remote round-trips (the default branch plus each tracked branch, one fetch each), multiplying with pool residue. Ruling: without an async daemon, auto-fetch on a main path taxes a synchronous caller for advisory freshness, and low friction outranks it — auto-fetch may return only off the main path. Layer-1 staleness (`remoteAhead`) now reads last-fetched refs, refreshed by the remaining fetching touchpoints (`orbit sync` / `orbit prune`) or the user's own fetch/pull; fetch-config maintenance (a local write) stays.
- Bare `orbit prune` no longer reaps an empty repo's default-branch config: pool maintenance's orphan-config sweep treats the pool HEAD's target branch as always alive (possibly unborn) — its `branch.<name>.*` section is first-push routing, not residue. The protection tracks HEAD and self-releases once the branch gains a ref or the pool switches defaults; non-empty repos are unchanged (the ref check already keeps such sections). ([#36](https://github.com/orbcli/orbit/pull/36))

#### Removal

Expand Down
2 changes: 1 addition & 1 deletion docs/spec-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -293,7 +293,7 @@ Residue is produced **externally** — an agent force-deleting a workspace direc
- **Ghost groups** (scoped branches `<prefix>/<ws>/…` whose directory is gone): convergent per-item cleanup through the git verdict layers (2 and 3 — a ghost carries no recorded PRs, so layer 1 never fires): merged → deleted, unmerged → kept and reported, `--force` → force-deleted. A targeted `orbit prune <ws>` with no directory but residue branches processes the ghost group instead of erroring. **The report of one ghost group lists the deleted and the kept branches in the same block** — never counts alone. Run N's report is complete *for run N*; with `(was <sha>)` on every deletion line, the deleted half doubles as the recoverable record. No history is persisted across runs (it would break the pure-function premise validation stands on; a history store would be a new agent-writable element needing its own guards — a new residue source; and merged content is upstream anyway, so cross-run records belong to terminal scrollback and CI logs, not to orbit). Note the two counts differ in kind: a ghost's kept count **decreases across runs** (each run cleans the merged part), while a live workspace's changes only by human action — a reader must not read a dropping ghost count as "something was silently deleted". The group block — header, deletion lines, kept lines — is report content on **stdout** in dry-run and real runs alike; only true errors (scan failures, deletion refusals) go to stderr. The two halves must stay adjacent, and a channel split would silently un-adjacent them.
- **Untraceable raw branches** (not scoped-shaped — a single-segment `<prefix>/<name>` does not qualify — no `origin/<name>` copy, not checked out in any worktree): report only, never deleted by orbit — listed with merged status and the exact native `branch -D` command for the human operator, grouped by repo. Raw-mode branches and branches created under a former prefix only ever appear in this report — no automatic deletion path exists for them.
- **Worktree registry self-heal**: a registration whose gitdir target is gone (`git worktree list --porcelain` reports it `prunable`; equivalently, `<path>/.git` is absent) splits by whether the worktree **path** itself still exists. Path gone → **stale registration**, repaired automatically with no `--force`: leaving it would make every later deletion of that branch refuse forever, and nothing else surfaces it once the branch is gone (the branch-deletion retry only fires while a branch still exists to be refused). Path present → a **damaged worktree**, which validation refuses; the registration is deliberately left intact, because it is the only evidence that makes that state recognizable at all. `git worktree prune` alone cannot tell the two apart, so it is never run bare; orbit prunes by confirmed-dead path only, and maintenance is never repaired at the cost of un-persisted content. Exactly what a stale-registration removal touches (admin directory only — never objects, content, refs, or live registrations) is enumerated in [spec-worktree](./spec-worktree.md) → Prune's Git Binding.
- **Orphan branch config**: `branch.<name>.*` sections whose branch no longer exists are dropped in the same pass — three config lines describing a branch that is not there, reconstructible by the next `orbit switch`. Left in place, a stale `branch.<name>.merge` can make an untraceable branch look traceable and suppress its report.
- **Orphan branch config**: `branch.<name>.*` sections whose branch no longer exists are dropped in the same pass — three config lines describing a branch that is not there, reconstructible by the next `orbit switch`. Left in place, a stale `branch.<name>.merge` can make an untraceable branch look traceable and suppress its report. Two branches never count as orphans even when refless: a branch checked out in a non-pool worktree (unborn push routing in use), and the pool HEAD's target branch — possibly unborn, e.g. an empty repo's clone-written default section — whose config is first-push routing, not residue.
- **Branches left outside `branch.prefix`**: a local branch shaped like this workspace's (`*/<workspace>/*`) but outside the configured prefix is named rather than silently left behind — it is not orbit's to delete (a raw-mode branch, or one created while the prefix held another value). Git holds the branch names, so they remain the recoverable record even if the config that named them is lost.
- **Pool config maintenance** belongs to this family: a pool's managed config keys are re-asserted on every prune path, so a run with no live candidate still converges them. The contract is in [spec-worktree](./spec-worktree.md) → Config Ownership and Touchpoint Fetch Discipline.
- **Closing block**: after the whole report, every workspace with kept content — a validation refusal (live) or a kept ghost branch — gets one force-delete suggestion (`orbit prune <ws> --force`), gated behind the single confirm-useless caveat; raw-branch `branch -D` commands follow last.
Expand Down
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 — **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` |
| `<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 AND not the pool HEAD's target**: an unborn branch, e.g. an empty repo's orphan worktree, is alive while checked out, ref or no ref; and the pool HEAD's target branch is always alive (possibly unborn — an empty repo's clone-written default section is first-push routing, not residue)) — 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
18 changes: 14 additions & 4 deletions orbit.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2895,9 +2895,8 @@ $repo_group"
# - 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)
# ref or no ref; and the pool HEAD's target branch is always alive,
# possibly unborn — its config is first-push routing, not residue)
# 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
Expand All @@ -2913,7 +2912,15 @@ orbit_prune_repo_maintenance() {
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
local porcelain checked_out=" " pool_wt=0 head_br
# The pool HEAD's target branch is always protected below, ref or no ref:
# its branch.<name>.* section is live first-push routing (e.g. an empty
# repo's clone-written default), never residue. In non-empty repos this is
# invisible — the ref check already keeps it. Premise: the pool's HEAD is
# a symref. No orbit command ever detaches it (sync only fetches;
# add/switch only touch worktrees) — if a future command detaches the pool
# HEAD, this exemption silently no-ops (head_br comes out empty).
head_br=$(git -C "$repo" symbolic-ref --short -q HEAD 2>/dev/null || true)
porcelain=$(git -C "$repo" worktree list --porcelain 2>/dev/null || true)
while IFS= read -r line; do
case "$line" in
Expand Down Expand Up @@ -2946,6 +2953,9 @@ EOF
# 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
# The pool HEAD's target branch (possibly unborn) — live first-push
# routing, protected whether or not the repo is empty.
[ -n "$head_br" ] && [ "$br" = "$head_br" ] && continue
if git -C "$repo" config --remove-section "branch.$br" 2>/dev/null; then
n_cfg=$((n_cfg + 1))
fi
Expand Down
44 changes: 34 additions & 10 deletions tests/09_prune.bats
Original file line number Diff line number Diff line change
Expand Up @@ -2073,20 +2073,19 @@ EOF
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.
# Both unborn sections are alive: the worktree's scoped branch (checked out)
# and the pool HEAD's target (clone-written default — first-push routing).
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)"
refute_contains "$output" "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" ]
merge=$(git -C "$proj/.repos/emptyrepo" config --get branch.main.merge)
[ "$merge" = "refs/heads/main" ]
}

@test "prune: done empty-repo workspace reclaims cleanly — unborn config unprotected after D1" {
@test "prune: done empty-repo workspace reclaims cleanly — worktree unborn config reaped, pool default survives" {
local proj="$SANDBOX/prune-empty-done"
local remote="$SANDBOX/empty_remote_prune-empty-done.git"
create_empty_bare_repo "$remote"
Expand All @@ -2102,10 +2101,35 @@ EOF
[ "$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\.'
# D1 removed the worktree first, so the scoped branch's unborn config is
# reaped — but the pool HEAD's target (the empty repo's clone-written
# default) stays protected: it is first-push routing for the pool's next
# worktree.
run git -C "$proj/.repos/emptyrepo" config --get branch.ws/dev/main.merge
[ -z "$output" ]
local merge
merge=$(git -C "$proj/.repos/emptyrepo" config --get branch.main.merge)
[ "$merge" = "refs/heads/main" ]
}

@test "prune: empty-repo default-branch config is protected whatever its name (dev)" {
local proj="$SANDBOX/prune-empty-dev-default"
local remote="$SANDBOX/empty_remote_prune-empty-dev-default.git"
git init --bare "$remote" >/dev/null 2>&1
git -C "$remote" symbolic-ref HEAD refs/heads/dev
TEST_PROJECT="$proj"
mkdir -p "$proj"
cd "$proj" && orbit clone "$remote" --name emptyrepo >/dev/null 2>&1
cd "$proj" && orbit new "empty dev default" --name ws1 >/dev/null 2>&1
cd "$proj/ws1" && orbit add emptyrepo >/dev/null 2>&1
cd "$SANDBOX"

run bash -c "cd '$proj' && ORBIT_ROOT='$proj' bash '$ORBIT_CMD' prune 2>&1"
[ "$status" -eq 0 ]
refute_contains "$output" "orphan branch config section(s)"
local merge
merge=$(git -C "$proj/.repos/emptyrepo" config --get branch.dev.merge)
[ "$merge" = "refs/heads/dev" ]
}

@test "prune: a pool that cannot scan branches blocks the live workspace — both modes" {
Expand Down
Loading