balls 0.5.11

Git-native task tracker for parallel agent workflows
Documentation
//! §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;