Development record
`abcd sweep`: one dry run over every leftover, and `--apply` for what is provably spent
Summary
spc-2610230730269295 delivers itd-2610222214476887. abcd sweep is one verb over five kinds of leftover:
- worktrees;
- local branches;
- merged pull-request branches on origin;
- reservation branches;
- waiting inbox reports.
A bare run changes nothing: it lists every item with what --apply would do, or why it would leave the item. --apply acts only on what is provably spent.
The verb adds two things of its own, a liveness probe and patch salvage. Everything else is primitives it calls:
- the worktree store's list, merged judgement and prune (itd-2609091014076309, which this spec waits on);
- the reservation sweep's judgement and its workflow (iss-2610142200389875);
- the inbox's promote (internal/core/report);
- the claim package's reservation view;
- the embargo store's path test.
Approach
Rows
One Row per item, carrying:
- kind (worktree, branch, remote-branch, reservation, inbox, stray or embargo-store);
- name;
- path, home-redacted;
- the state the sweep read;
- action (remove, delete, dispatch, promote, save-patch or keep);
- reason;
- for each piece of evidence, whether it is proven or a hint.
A dry run renders the rows. --apply renders the same rows, each with its outcome, and --json emits them. Exit codes:
- 0 when anything was acted on;
- 1 when the run completed and acted on nothing;
- 2 on a refusal or a fault.
Worktrees
Rows. They come from the store's list (git's worktree list --porcelain, so worktrees outside the store appear too) and the store's merged judgement. That judgement reads ancestry, a rebase merge, a squash merge by patch-equivalence, and the forge where it is reachable.
Removal. It is the store's prune core, called per worktree. Prune archives the notes, never forces, leaves the branch standing, and refuses locked, own and loop-held worktrees. The sweep adds no reclaim of its own.
Keep reasons the sweep adds on top of prune's:
- the embargo store, named by
storepath.IsStorePath, which is never read, saved or removed; - a live session (below);
- an external lock, with git's lock reason shown and a JSON reason's
ownernamed.
Liveness
A worktree is held when any of these proofs names it:
- a live reservation's footprint on origin, read through
claim.Viewafter the sweep's fetch; - the build loop's run state (
loop.Runs) naming it as a lane, for an unfinished run within the lease; - a running process whose working directory is inside it.
The process proof reads /proc/<pid>/cwd on Linux. On macOS it runs /usr/sbin/lsof -a -d cwd -Fn by absolute path, never resolved through PATH, with a 5-second deadline, a 4 MiB bounded read and defensive parsing of the -F fields. It uses no cgo, because the released binaries are cross-compiled.
lsof escapes some bytes in a name, even under a UTF-8 locale: a backslash comes out doubled, a byte it will not print comes out as a backslash escape, and most control bytes come out in caret form. Every byte before the first backslash or caret is the directory's own, so the probe keeps that prefix and does not refuse the whole check. A process in such a directory holds, as a hint, every worktree that the prefix leads into, or that extends the prefix, compared as plain text with the same case folding as other paths, because the escape can fall in the middle of a name. The keep reads "in use: a process works in a directory lsof printed escaped, under <prefix>". A worktree that the prefix does not reach is not held by that process.
A last write to the tree or its index within 24 hours (configurable) is a hint, not a proof. The hint also keeps the worktree.
A reservation holds a worktree in either of two cases. Its tip names that worktree's own copy key, so a session that reserved from the worktree holds it before changing anything. Or its footprint overlaps the paths the worktree changes. The rule errs toward keeping, and the keep names the reservation by its record and holder. Matching a reservation's branch to the worktree's branch was rejected, because a reservation carries no branch.
Reservations that cannot be read keep worktrees too. If no fetch of the reservations is stamped, nothing can show that a worktree is unreserved, so every worktree is held as "reservations unread", and the dry run says so once at the top. An unreadable reservation still within its hold has a footprint no one can read, so it holds every worktree as a hint naming that record and its holder. A build run state that could not be read cannot show that no unfinished run names a worktree, so every worktree is held as a hint, "build runs unread", and the dry run says so once at the top. A worktree whose changed paths could not be computed is held as "changes unknown". A changed path that is not a clean repository-relative path, such as one holding a backslash, cannot be compared with any footprint, so the worktree is held as a hint, "changes unreadable: <path>".
The sweep judges only worktrees this account owns. One whose top directory or git file another account owns is held, naming that account's uid.
The probe fails closed. A worktree the probe could not clear counts as possibly live whenever any of these happens:
- lsof is missing, times out, exits non-zero, or prints what the parser cannot read;
/proccannot be read, or does not list the sweep's own process, as happens when it is empty or belongs to another system, in a chroot or a container ("this process is not in /proc").
Such a worktree is left in place and named "process check unavailable: <why>", and the dry run says so once at the top.
Patch salvage
Patch salvage applies to a dirty worktree that no proof holds:
- Its
git diff --binaryandgit diff --cached --binary, and a tar of its untracked, non-ignored files, are written to~/.abcd.noindex/notes/<root-sha>/<UTC>-<name>-salvage/. - The write goes through the store's directory primitive and redaction.
- A manifest names the worktree, its branch, HEAD, the home-redacted source path and the file list.
- The worktree is kept.
- A second run that finds an identical diff (by sha256) writes nothing new and points at the earlier salvage.
The embargo store and any worktree outside the store are never salvaged.
Local branches
A local branch with no worktree is judged by the same merged judgement. It is deleted with git branch -d, never -D, so git re-checks the merge.
These are kept, with the reason:
- the default branch;
- the checked-out branch;
- a branch with commits not on its upstream;
- reservation refs.
Merged pull-request branches on origin
Under --apply, a branch on origin is deleted when the forge records its pull request as merged, or when it is patch-equivalent to the default branch, once nothing still needs it:
- no open pull request heads it;
- no reservation names it;
- no running session works on it, judged by the liveness proof against any local worktree on that branch.
The deletion is a push of an empty ref carrying a lease on the tip that was judged (the reservation sweep's pattern), made through the guard, and each branch it deletes is named.
These are never deleted:
- the default branch;
- release and tag refs;
abcd-reserved/*;- a branch the sweep cannot show is merged.
A branch something still needs is kept, naming what holds it. There is no separate opt-in (the product thinker's ruling of 2026-10-23).
Reservations
- Rows. They come from the reservation sweep's dry-run judgement (
reservesweep.Sweepwith Apply false). - Under
--apply. When any reservation is spent, the sweep runsgh workflow run reservation-sweep.yml --ref <default>, and the workflow deletes them then. The sweep reports the run's id and URL, read back withgh run list --workflow reservation-sweep.yml --limit 1. - Fallback. Where
ghis absent or the dispatch fails, the report gives the next scheduled time, read from the workflow's cron. - The sweep never pushes a reservation deletion itself.
Inbox
Under --apply, every waiting report is promoted through internal/core/report's promote. Each is filed with the automatic filer's remedy, so the drain never acts on one unread, and the report names each record it filed. A report's text stays framed as untrusted in the rows, and promotion writes a record and runs nothing.
Strays
An entry in the store lane that is neither a worktree nor a directory failing the store's proof of belonging (a loose file, say) is listed as a stray and never touched.
How the Acceptance Criteria are met
- A dry run lists every item and changes nothing: Rows; no writer runs without
--apply. - A patch-equivalent merge is removed: the store's merged judgement and prune.
- A worktree whose tip is the default branch but which holds a live working directory is kept: Liveness, the process proof.
- A dirty worktree is saved as a patch and kept: Patch salvage.
- The embargo store is untouched: the
storepath.IsStorePathkeep reason. - A worktree another tool has locked is kept, naming the owner: the lock keep reason.
- A stray is listed: Strays.
- A merged pull-request branch is cleared once nothing needs it, or kept naming what holds it: Merged pull-request branches on origin.
- Reservations are dispatched, never deleted directly: Reservations.
- Inbox reports are filed: Inbox.
- The technical list:
- one reclaim primitive (the store's prune);
- a fetch first;
- no force and no process stopped;
--json;- exit codes 0, 1 and 2;
- writes only under
~/.abcd.noindex/and the local tier.
Footprint
- packages: internal/core/sweep/sweep.go, internal/core/sweep/rows.go, internal/core/sweep/liveness.go, internal/core/sweep/salvage.go, internal/core/sweep/branches.go, internal/core/sweep/remote.go, internal/core/sweep/reservations.go, internal/core/sweep/inbox.go, internal/surface/cli/sweep.go, internal/surface/cli/helpgroups.go, internal/surface/cli/barerender.go, internal/surface/cli/cli.go, internal/core/guard/deletes.go, commands/sweep.md, docs/reference/cli/commands.md, .abcd/development/release/surface.json, .abcd/development/brief/04-surfaces/40-sweep.md
- tests: internal/core/sweep/*_test.go, internal/surface/cli/sweep_test.go
Steps
- Rows, liveness and the dry run
- criteria: 1, 3, 5, 6, 7, 11
- packages: internal/core/sweep/sweep.go, internal/core/sweep/rows.go, internal/core/sweep/liveness.go
- tests: the dry run lists every kind and writes nothing; each keep reason (dirty, unmerged, locked naming its owner, live by each proof, the recent hint, the embargo store, a stray, unclassifiable); a worktree whose tip is the default branch with a live working directory kept; the probe missing, timing out or unreadable leaving the worktree as possibly live and saying so; exit codes. Waits on the store's list and merged judgement (itd-2609091014076309 steps 1 and 3).
- landed: 5ce7c0e882e8
- Worktree apply through the store's prune, and patch salvage
- criteria: 2, 4
- packages: internal/core/sweep/salvage.go, internal/core/sweep/sweep.go
- tests: a patch-equivalent merge reclaimed through prune; salvage content, manifest and idempotence; a failed salvage keeping the worktree. Waits on the store's prune (itd-2609091014076309 step 4).
- Local and merged origin branches
- criteria: 8
- packages: internal/core/sweep/branches.go, internal/core/sweep/remote.go
- tests:
branch -dhonoured and its refusal kept; a merged origin branch deleted under a lease and named; one held by an open pull request, a reservation or a live session kept, naming what holds it; the never-delete list.
- Reservations and inbox
- criteria: 9, 10
- packages: internal/core/sweep/reservations.go, internal/core/sweep/inbox.go
- tests: the reservation workflow dispatched and its run reported; the fallback to the next scheduled time with no gh; every waiting report promoted with the automatic filer's remedy and named. Waits on iss-2610142200389875's sweep being on main.
- The surfaces
- criteria: 1, 11
- packages: internal/surface/cli/sweep.go, internal/surface/cli/helpgroups.go, internal/surface/cli/barerender.go, internal/surface/cli/cli.go, internal/core/guard/deletes.go, commands/sweep.md, docs/reference/cli/commands.md, .abcd/development/release/surface.json, .abcd/development/brief/04-surfaces/40-sweep.md
- tests: internal/surface/cli/sweep_test.go: the verb's rows,
--jsonand exit codes through the front door; the reference and surface baseline regenerated; once this step wiresabcd sweep, the shell guard's refusal of a delete it cannot prove safe (git-delete-unproven, iss-2610241239492159) names it as what settles a spent branch or worktree, which until then it never does.
Frontmatter
| id | spc-2610230730269295 |
|---|---|
| type | spec |
| lifecycle | open |
.abcd/development/specs/open/spc-2610230730269295-abcd-has-no-single-verb-that-cleans-up-a.md
open on GitHub ↗ · commit history ↗
References1
- itd-2610222214476887One command shows everything a repository has left lying around, and clears what is provably finishedimplements
mentions6
- dcn-26102311143084862026-10-23 — The worktree store's PruneOne, its PreRemove hook and Row's absolute path land in step 2 with no production caller, as the API the sweep (spc-2610230730269295) calls: its removal is the store's prune core called per worktree, with its liveness probe as the last check before each removal, and its rows need each worktree's absolute path. They stay uncalled only until the sweep lands, which the merge order puts after the store's step 2; if the sweep's plan changes so that nothing calls them, they are removed rather than left as scaffolding. Facilitator, on the technical delegation. <!-- record-lint: forward-looking -->
- dcn-26102719462663652026-10-27 — The worktree store's notes-entry API (NewNotesEntry, NotesEntries, WriteNew, ReadFile, Flush), built ahead of its one named caller, the sweep's patch salvage in spc-2610230730269295, leaves the store stack and is kept on the local branch park/store-notes-entry until that caller is built, decided by the technical facilitator's session abcd-e8 on the product thinker's standing delegation of 2026-10-27. Grounds: AGENTS.md's wired-or-it-isn't-done rule refuses an exported API no production code calls, the review of 2026-10-27 found none on the store or the sweep branch, and dcn-2610231114308486 covers only PruneOne, PreRemove and Row.Path. The step that builds the salvage cherry-picks it back with its caller, and fixes the kind-suffix collision the review found (a worktree named x-salvage lists as x's salvage entry). <!-- record-lint: forward-looking -->
- iss-2610142200389875The reservations ruleset forbids deleting refs/heads/abcd-reserved/** with no bypass, so every spent reservation branch stays on the forge forever, counting against its per-repository branch limits and against the intent's 'needs occasional clean-up' (decision 10 of itd-2609091034175565); the rulesets README notes the itd-118 sweep needs the ruleset amended first.
- iss-2610241239492159abcd guard has no hazard for deleting a branch or a worktree: git push origin --delete, git branch -D and git worktree remove are all allowed whatever the target holds, so the safety of a cleanup rests on the agent remembering its own checks. On 2026-10-24 a hand-run sweep's check-then-act loop invoked abcd guard check with the wrong syntax, read the nonzero exit as an objection, printed it and deleted seven remote branches anyway; the deletes were safe only because the survey before them was right, and the hook would have allowed them regardless. A guard has to be enforced at the hook, not hoped for in a script.
- iss-2611030744332925Older build runs landed spec steps without marking them landed: on 2026-11-03 four open specs held merged steps with no landed line (spc-2610240951109420 steps 1-2 from #1145 and #1193, spc-2610161949559050 steps 1-2 from #1179 and #1187, spc-2609301811532881 steps 1-6 from #1110 and #1197, spc-2610230730269295 step 1 from #1197), so the board offered them as ready from step 1 and abcd build would have rebuilt delivered work. Found only by grepping merge messages.
- itd-2609091014076309Session and agent worktrees live in a machine-scoped store that abcd lists and reclaims, never beside the user's own projects