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
//! §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.
//!
//! Close/unclaim teardown removes only the worktree DIRECTORY; the branch must
//! survive the op (§11: "re-creatable from the branch, so it is rollback-safe").
//! The reason is converge-on-retry (§14): until the squash lands — a close can
//! abort before it (gate failure, fold conflict) — the `work/<id>` branch is
//! the ONLY copy of the diff, and the retry's deliver recomputes from it.
//! Deleting it inside the op would make a retried abort silently no-op on the
//! absent branch. So branch deletion is DEFERRED, non-transactional cleanup
//! ("deleting `work/<id>` is deferred, non-transactional cleanup (`prime`)",
//! §11/§14), and `prime` — which runs outside any op, after re-materializing
//! the still-claimed set — is the cleanup site. Without it the branch namespace
//! grew monotonically with every delivered task (bl-292d: 52 had accumulated).
//!
//! 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.
use std::io;
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;
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 naming both
/// remedies is RETURNED (never printed here; the plugin binary owns
/// stderr, matching every other bl-b1be-style report in this codebase).
/// `xdg`/`plugin` are exactly the binding inputs `claim` resolves its own
/// worktree from — the caller already has them for that reason.
pub fn prune(&self, xdg: &Xdg, plugin: &str) -> 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(debris_report(id, branch));
}
}
}
}
Ok(reports)
}
}
/// The bl-c117 debris line: `branch` (`work/<id>`) is committed but its
/// worktree is gone. Names both remedies the design record specifies — re-claim
/// (the bl-65e0 contract: a later claim-and-close still delivers it) or
/// explicit discard — and prunes neither.
fn debris_report(id: &str, branch: &str) -> String {
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}"
)
}
#[cfg(test)]
#[path = "delivery_prune_tests.rs"]
mod tests;