1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
//! §11/§14 deferred branch cleanup — `prime.post` prunes settled `work/<id>`
//! branches and, beside that prune (bl-c117, piece 3 of
//! docs/design/bl-18bf-prime-convergence.md), REPORTS the debris an unsettled
//! one leaves when its worktree directory is gone.
//!
//! **This is now a BACKSTOP, not the routine path (bl-ce3b).** A close deletes
//! its own `work/<id>` at `close.post` — the op that just squashed and sealed
//! knows it delivered, so nothing has to reconstruct that later. Deferring the
//! delete to prime made prime re-derive delivery from a `[bl-<id>]` marker on
//! the INTEGRATION branch, which a NESTED ball (delivering into
//! `work/<parent>`) structurally never puts there: every closed child leaked a
//! branch permanently, and [`Project::standing`] was not failing — it was
//! handed "undelivered" and correctly refused to delete.
//!
//! What reaches this prune is therefore what `close.post` never ran on: a crash
//! or kill between the seal and the teardown, and pre-bl-ce3b branches already
//! on disk. Unclaim still leaves its branch deliberately (bl-65e0 — the next
//! claimant re-materializes onto it), and that branch is Undelivered, so this
//! prune never touches it either; it is reported as debris only once its
//! worktree is also gone. Before close.post owned the delete, the branch
//! namespace grew monotonically with every delivered task (bl-292d: 52 had
//! accumulated) — that growth is what this prune ended and what close.post now
//! prevents at the source.
//!
//! An unsettled branch (committed-but-undelivered, or diverged past a delivery)
//! is correctly never pruned — but when its worktree directory is ALSO absent
//! (release/unclaim tore it down, or a human `rm -rf`'d it), that is silent
//! debris: content sitting on a branch nobody can see without already knowing
//! its name. bl-18bf's attack record explicitly BROKE the wider "claimed ball,
//! missing worktree" variant of this report — the §7 prime payload carries no
//! claim set, and the worktree is plugin territory core cannot stat — so this
//! report is deliberately narrow: branch present, dir absent, computed by the
//! plugin alone from state it already reads for the prune. Zero new subprocess
//! spawns: the same `for-each-ref` enumeration and [`Project::standing`] call
//! serve both the delete and the report; the only addition is one `exists()`
//! per unsettled branch.
//!
//! The report has TWO ARMS, decided by the store (bl-baa0). A branch outlives
//! its ball: close deletes `tasks/<id>.md` (§10 — absence IS the record) and
//! leaves the branch for this deferred cleanup, so the debris of a CLOSED ball
//! is the common case, and the re-claim remedy is a lie there — the ball cannot
//! be claimed and no close can deliver it. Deciding costs one more `exists()`
//! on the store checkout, which for `prime.post` is simply the plugin's CWD
//! (§13 diffless — the same cwd `close.pre` recovers its id from): no wire
//! widening, no claim set, no assertion about claim state — only "is there a
//! task file". This is narrower than the "claimed ball, missing worktree"
//! variant bl-18bf's attack record broke, and does not revive it.
use std::io;
use std::path::Path;
use crate::delivery::Repo;
use crate::delivery_path::{marker, worktree_path};
use crate::delivery_repo::Project;
use crate::delivery_standing::Standing;
use crate::layout::Xdg;
use crate::taskfile;
impl Project {
/// Delete every local `work/<id>` branch that is SETTLED on the
/// integration branch (content-contained in its delivery, or carrying no
/// commit beyond its fork — nothing is lost; the delivery squash IS the
/// record, the branch a stale second copy). Committed-but-undelivered work
/// SURVIVES — both the never-delivered branch and the diverged one carrying
/// content beyond its delivery (the bl-65e0 unclaim contract: a later
/// claim-and-close delivers it — or, diverged, aborts loudly; discard is an
/// explicit `git branch -D`). A checked-out branch survives too — `git
/// branch -D` refuses it, and the delete is BEST-EFFORT precisely so a
/// live claim's branch (this actor's, or another claimant's on this
/// machine) never fails a prime. So is the whole prune: a project root
/// that is no git repo yet (a pre-claim prime) has nothing to clean.
/// Idempotent: a pruned branch simply no longer enumerates.
///
/// Every SURVIVING branch is then checked for debris (bl-c117): if its
/// worktree — the same `(xdg, plugin, invocation, id)` formula `claim`
/// derives ([`worktree_path`]) — is absent, one report line is RETURNED
/// (never printed here; the plugin binary owns stderr, matching every other
/// bl-b1be-style report in this codebase), naming the remedies that are
/// actually open given whether `store` still holds the ball
/// ([`Self::debris_report`]). `xdg`/`plugin` are exactly the binding inputs
/// `claim` resolves its own worktree from, and `store` is the store
/// checkout `prime.post` already runs in — the caller has all three for
/// reasons that predate this report.
pub fn prune(&self, xdg: &Xdg, plugin: &str, store: &Path) -> io::Result<Vec<String>> {
let Ok(integration) = self.integration() else {
return Ok(Vec::new()); // no repo / no HEAD branch — nothing to prune or report
};
let invocation = self.root.to_string_lossy().into_owned();
let refs = Self::run(&self.root, &["for-each-ref", "--format=%(refname:short)", "refs/heads/work/"])?;
let mut reports = Vec::new();
for branch in refs.lines() {
let id = branch.strip_prefix("work/").unwrap_or(branch);
match self.standing(branch, &integration, &marker(id))? {
Standing::Settled(_) => {
Self::ok(&self.root, &["branch", "-D", branch])?; // best-effort: refused while checked out
}
Standing::Undelivered | Standing::Diverged => {
if !worktree_path(xdg, plugin, &invocation, id).exists() {
reports.push(self.debris_report(store, &integration, id, branch)?);
}
}
}
}
Ok(reports)
}
/// The bl-c117 debris line for `branch` (`work/<id>`): committed, worktree
/// gone. Which remedies exist is the STORE's answer (bl-baa0), so the arm
/// turns on `tasks/<id>.md` under the `store` checkout (§10 — absence IS
/// the record):
///
/// - **Open ball** — both remedies the design record specifies: re-claim
/// (the bl-65e0 contract, a later claim-and-close still delivers it) or
/// explicit discard.
/// - **Closed ball** — re-claim is impossible and no close can deliver, so
/// ONLY discard is named. Because deletion is then the sole path, the
/// line first says whether anything would be lost: content-containment
/// against the integration TIP ([`Project::contained`]), not against a
/// `[bl-id]`-tagged delivery. That is the exact gap `Standing` cannot
/// see — content that landed inside ANOTHER ball's squash reads
/// `Undelivered` forever while being fully present on `integration`. One
/// `merge-tree` per closed-ball debris branch, off the clean path
/// entirely; a non-contained branch gets the three-dot diff to inspect
/// instead of a claim about its content.
///
/// Prunes neither arm — reporting is the whole contract.
fn debris_report(&self, store: &Path, integration: &str, id: &str, branch: &str) -> io::Result<String> {
if taskfile::exists(store, id) {
return Ok(format!(
"{branch} is committed but its worktree is gone — bl claim {id} \
re-materializes onto it (a later close still delivers, bl-65e0), \
or discard with git branch -D {branch}"
));
}
let fate = if self.contained(branch, integration)? {
format!("its content is already contained in {integration}, so discard it with git branch -D {branch}")
} else {
format!(
"its content is NOT contained in {integration} — read it with \
git diff {integration}...{branch}, then discard with git branch -D {branch}"
)
};
Ok(format!(
"{branch} is committed but its worktree is gone, and {id} is closed \
(no task file — absence is the record), so nothing can re-claim or \
deliver it: {fate}"
))
}
}
#[cfg(test)]
#[path = "delivery_prune_tests.rs"]
mod tests;