Skip to main content

onevcs/
retire.rs

1//! Retiring finished branches: deciding whether a branch provably holds no work
2//! beyond its base, and — only once that is proved — deleting it everywhere this host
3//! holds it and leaving a record that says why.
4//!
5//! **Three classes, and the default is the one that deletes nothing.** A branch is
6//! [`RetirementClass::Retirable`] only where a proof covers the tip of *every* copy
7//! of it: a merged change request whose head contains the branch's content, a
8//! recorded landing followed by nothing but commits that change no content, or every
9//! path the branch changed reading on the base exactly as it reads on the branch. A
10//! branch a retry superseded, whose retry landed, and which still differs from the
11//! base is [`RetirementClass::SupersededWithChanges`] — surfaced for a person, and
12//! removed only by `onevcs reclaim`. Everything else is [`RetirementClass::Keep`]
13//! with one [`KeepReason`] — removed, where that reason is
14//! [`KeepReason::UnmergedUniqueCommits`] alone, only by `onevcs reclaim --discard`, a
15//! person discarding work they kept on purpose — and a read that fails on the way is `unknown` rather than
16//! a proof that did not need it: a false retirement destroys work, and a false keep
17//! costs a row.
18//!
19//! **What is deleted is exactly what was classified.** The tips are read again
20//! immediately before anything is removed, every local deletion is a
21//! compare-and-delete against the tip that was classified, and the origin's is a
22//! push under a lease on it — so a branch that gained a commit after it was judged is
23//! refused by git itself, whatever was already removed is put back, and the branch is
24//! classified again from where it now stands.
25//!
26//! **A pool slot is never removed.** A slot that held the branch is *returned* the
27//! way a session close returns one — detached onto the base, reset, cleaned — and
28//! keeps its directory, its clone and its record: a warm slot is what the next
29//! dispatch needs.
30//!
31//! The records are two stream event kinds, `branch-superseded` and
32//! `branch-retired`, under `$ONEVCS_HOME/streams`. Nothing about the registry or the
33//! session record moves, which is what lets an older `onevcs` sharing the state root
34//! go on reading it: a kind it has no word for is one it passes over.
35//!
36//! **A pass remembers what it derived.** The automatic pass records each branch's
37//! verdict in [`crate::verdict`] under everything its derivation read, and a later pass
38//! whose reads are all unchanged reuses the derivation — never the checks made fresh
39//! around it, which [`Census::classify`] asks on every pass whatever is on record.
40
41use std::collections::{BTreeMap, BTreeSet};
42use std::path::{Path, PathBuf};
43
44use serde::{Deserialize, Serialize};
45use serde_json::{json, Map, Value};
46use url::Url;
47
48use crate::error::{self, Error, Result};
49use crate::event::EventKind;
50use crate::git::{self, LocalTip, ObjectId, RemoteTip};
51use crate::host::{ChangeId, ChangeRequest, Hosting, Sha};
52use crate::provenance::{self, Trailers};
53use crate::registry::Registry;
54use crate::session::{Lifecycle, Scope, SessionToken};
55use crate::store::{self, Resolution};
56use crate::stream::Stream;
57use crate::workspace::{self, Record, Ref};
58use crate::{ids, label, landed, lock, policy, pool, processes, status, workspaces};
59
60// llmlint: ignore-block[invalid_states_unrepresentable] the four request types below are
61// the retirement amendment's in `docs/contract.md`, field for field, and two other
62// repositories link them as declared — an identity key and a branch name are `String`
63// everywhere this crate's contract spells one, and a repository is the widest of the four
64// forms every `--repo` takes. Each is decided where it arrives: a repository by
65// `store::resolve`, a branch by `check-ref-format`, a landing by `SupersedingLanding::parse`,
66// and labels by `label::validate`, each refusing by name.
67/// One branch of one identity: the pair a pass is told to leave alone by.
68#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
69pub struct BranchRef {
70    /// The identity key the branch belongs to.
71    pub identity: String,
72    /// The branch name.
73    pub branch: String,
74}
75
76/// Which branch to classify.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct RetirementQuery {
79    /// The repository — an identity key, a registered alias, an origin URL or a
80    /// path — or `None` to resolve the branch across every registered identity, which
81    /// is refused, naming the candidates, where more than one holds it.
82    pub repo: Option<String>,
83    /// The branch name.
84    pub branch: String,
85}
86
87/// Which classes a retirement may act on.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "kebab-case")]
90pub enum RetireMode {
91    /// [`RetirementClass::Retirable`] only: nothing that is deleted held work beyond
92    /// its base.
93    Lossless,
94    /// [`RetirementClass::SupersededWithChanges`] too: the branch's remaining
95    /// differences are discarded, because a person decided the retry that landed
96    /// replaces them.
97    Reclaim,
98    /// [`RetirementClass::Keep`] too, where its one reason is
99    /// [`KeepReason::UnmergedUniqueCommits`]: a branch somebody kept on purpose and that
100    /// nothing will ever land, whose work is discarded because a person decided it is
101    /// not wanted. Every other reason to keep a branch still refuses it.
102    Discard,
103}
104
105/// One retirement asked for by name.
106#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
107pub struct RetireRequest {
108    /// The repository, spelled as [`RetirementQuery::repo`] is.
109    pub repo: Option<String>,
110    /// The branch name.
111    pub branch: String,
112    /// Which classes may be acted on.
113    pub mode: RetireMode,
114    /// Report what would be retired, and change nothing.
115    pub dry_run: bool,
116}
117
118/// The automatic pass over every finished branch in scope.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub struct RetirePass {
121    /// Which identities are examined.
122    pub scope: Scope,
123    /// Branches the caller wants left alone, whatever they are.
124    pub exclude: Vec<BranchRef>,
125    /// Report what would be retired, and change nothing.
126    pub dry_run: bool,
127}
128
129/// A record that a branch was superseded by a retry that landed.
130#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
131pub struct Supersession {
132    /// The repository the branch belongs to, spelled as every repository-taking
133    /// operation takes one.
134    pub repo: String,
135    /// The branch that was superseded.
136    pub branch: String,
137    /// The branch that superseded it.
138    pub superseded_by: String,
139    /// Where that branch landed: a commit, or a change request's URL.
140    pub landing: String,
141    /// What the caller says about the supersession, as `--label KEY=VALUE` spells it.
142    pub labels: BTreeMap<String, String>,
143}
144// llmlint: ignore-end[invalid_states_unrepresentable]
145
146/// What a branch is, for the question of whether it may be deleted.
147#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
148#[serde(rename_all = "kebab-case")]
149pub enum RetirementClass {
150    /// It provably holds no work beyond its base.
151    Retirable,
152    /// A retry superseded it and landed, and it still differs from the base.
153    SupersededWithChanges,
154    /// Everything else, with the one reason it is kept.
155    Keep,
156}
157
158impl RetirementClass {
159    /// The word this class travels as.
160    pub fn as_str(self) -> &'static str {
161        match self {
162            RetirementClass::Retirable => "retirable",
163            RetirementClass::SupersededWithChanges => "superseded-with-changes",
164            RetirementClass::Keep => "keep",
165        }
166    }
167}
168
169/// Why a branch is kept.
170#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
171#[serde(rename_all = "kebab-case")]
172pub enum KeepReason {
173    /// An open session whose owner is running — or whose run root something is
174    /// working in — holds it.
175    HeldByLiveSession,
176    /// The caller excluded it.
177    Excluded,
178    /// A change request opened from it is neither merged nor closed.
179    OpenChangeRequest,
180    /// A registered checkout has it checked out.
181    CheckedOut,
182    /// A worktree over it has uncommitted changes.
183    DirtyWorktree,
184    /// Nothing proves it holds no work beyond its base.
185    UnmergedUniqueCommits,
186    /// A read needed to decide failed.
187    Unknown,
188    /// It is the base, or its tip is one the base already carries.
189    IsBase,
190    /// The change request opened from it was closed without merging: nothing the host
191    /// did landed it, so whatever it holds is still work nobody published.
192    ClosedChangeRequest,
193}
194
195impl KeepReason {
196    /// The word this reason travels as.
197    pub fn as_str(self) -> &'static str {
198        match self {
199            KeepReason::HeldByLiveSession => "held-by-live-session",
200            KeepReason::Excluded => "excluded",
201            KeepReason::OpenChangeRequest => "open-change-request",
202            KeepReason::ClosedChangeRequest => "closed-change-request",
203            KeepReason::CheckedOut => "checked-out",
204            KeepReason::DirtyWorktree => "dirty-worktree",
205            KeepReason::UnmergedUniqueCommits => "unmerged-unique-commits",
206            KeepReason::Unknown => "unknown",
207            KeepReason::IsBase => "is-base",
208        }
209    }
210}
211
212/// The read that failed, for a branch kept as [`KeepReason::Unknown`]: which read it
213/// was, what it read, and the error it got — so a kept branch says what to look at
214/// rather than only that something could not be read.
215// llmlint: ignore-block[invalid_states_unrepresentable,boundary_inputs_validated] the
216// unknown-cause amendment in `docs/contract.md` fixes this type field for field as three
217// `String`s — the manager's ruling for plan `accepted-followups-1002`, which
218// `onepipeline` links — so an enum of operations would change a shared interface this
219// crate may not change alone. What is held where a document is read is what makes a
220// cause one at all: `AnyRetirement` refuses a cause beside any reason but `unknown`, and
221// one with a blank operation, subject or error. The operation's words stay open there on
222// purpose: a build that names a new read writes a document an older build still reads,
223// rather than one it refuses over the field that is only ever diagnostic. A subject
224// is a path, the origin's URL or a change request's URL by its operation, as a holder's
225// location is, and is only ever read back as words a person reads.
226#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
227pub struct UnknownCause {
228    /// The read that failed: `census`, `copy`, `live-holder`, `worked-in`, `judge`,
229    /// `supersession` or `host-consult` — and `open-change` from a build that read the
230    /// base's history for a change request, which no retirement reads any more.
231    pub operation: String,
232    /// What it read: a path, the origin, or a change request.
233    pub subject: String,
234    /// The error it got.
235    pub error: String,
236}
237// llmlint: ignore-end[invalid_states_unrepresentable,boundary_inputs_validated]
238
239impl UnknownCause {
240    fn new(
241        operation: &str,
242        subject: impl std::fmt::Display,
243        error: impl std::fmt::Display,
244    ) -> Self {
245        UnknownCause {
246            operation: operation.to_owned(),
247            subject: subject.to_string(),
248            error: error.to_string(),
249        }
250    }
251
252    /// `subject: error`, as the inputs a recorded verdict is keyed under spell a place
253    /// that could not be read.
254    fn unreadable(&self) -> String {
255        format!("{}: {}", self.subject, self.error)
256    }
257}
258
259impl std::fmt::Display for UnknownCause {
260    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
261        write!(
262            f,
263            "{} failed for {}: {}",
264            self.operation, self.subject, self.error
265        )
266    }
267}
268
269/// What proves a branch holds no work beyond its base.
270#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
271#[serde(tag = "kind", rename_all = "kebab-case")]
272pub enum RetirementProof {
273    /// A change request opened from the branch merged, at a head that contains the
274    /// branch's content.
275    MergedChangeRequest {
276        /// The change request.
277        change_url: Url,
278        /// The head that contains the branch's content.
279        head: Sha,
280    },
281    /// A landing is recorded for the branch at this commit of it, and nothing after
282    /// it changes content.
283    RecordedLanding {
284        /// The branch commit that landed.
285        commit: Sha,
286    },
287    /// Every path the branch changed reads on the base exactly as on the branch.
288    ContentIdentical {
289        /// The base commit the paths were compared against.
290        base_commit: Sha,
291    },
292}
293
294impl RetirementProof {
295    /// The word this proof travels as.
296    pub fn kind(&self) -> &'static str {
297        match self {
298            RetirementProof::MergedChangeRequest { .. } => "merged-change-request",
299            RetirementProof::RecordedLanding { .. } => "recorded-landing",
300            RetirementProof::ContentIdentical { .. } => "content-identical",
301        }
302    }
303
304    /// The commit that is the evidence.
305    pub fn commit(&self) -> &str {
306        match self {
307            RetirementProof::MergedChangeRequest { head, .. } => &head.0,
308            RetirementProof::RecordedLanding { commit } => &commit.0,
309            RetirementProof::ContentIdentical { base_commit } => &base_commit.0,
310        }
311    }
312
313    /// The proof in the words a rendering says it in.
314    pub fn describe(&self) -> String {
315        match self {
316            RetirementProof::MergedChangeRequest { change_url, head } => {
317                format!(
318                    "merged-change-request — {change_url} merged at head {}",
319                    head.0
320                )
321            }
322            RetirementProof::RecordedLanding { commit } => {
323                format!(
324                    "recorded-landing — {} landed, and nothing after it changes content",
325                    commit.0
326                )
327            }
328            RetirementProof::ContentIdentical { base_commit } => {
329                format!(
330                    "content-identical — every path it changed reads the same on {}",
331                    base_commit.0
332                )
333            }
334        }
335    }
336}
337
338// llmlint: ignore-block[invalid_states_unrepresentable] the fields below that stay text are
339// the ones this crate has no public type for, and the retirement amendment in
340// `docs/contract.md` fixes them as it fixes `Recoverable`'s: an identity key and a branch
341// name are `String` everywhere the contract spells one (`workspace::Ref` is private), a
342// holder's location is a path or an origin URL by its `kind`, and a supersession's landing is
343// a commit or a URL exactly as the caller recorded it. Every one is decided where it arrives —
344// written from git's own answers, and read back through `SupersessionRecord::read` and
345// `RetiredRecord::read`, which refuse what does not parse — and the commits and the change
346// request carry `Sha` and `Url`.
347/// The retry that superseded a branch, as its record says.
348#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
349pub struct SupersededBy {
350    /// The branch that superseded it.
351    pub branch: String,
352    /// Where that branch landed: a commit, or a change request's URL.
353    pub landing: String,
354    /// What the recording caller said about it.
355    pub labels: BTreeMap<String, String>,
356}
357
358/// Which kind of place holds a copy of a branch.
359#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
360#[serde(rename_all = "kebab-case")]
361pub enum BranchHolderKind {
362    /// A registered checkout of the identity.
363    Checkout,
364    /// A pool slot's clone.
365    Slot,
366    /// A run's clone.
367    RunClone,
368    /// The identity's origin.
369    Origin,
370}
371
372impl BranchHolderKind {
373    /// The word this kind travels as.
374    pub fn as_str(self) -> &'static str {
375        match self {
376            BranchHolderKind::Checkout => "checkout",
377            BranchHolderKind::Slot => "slot",
378            BranchHolderKind::RunClone => "run-clone",
379            BranchHolderKind::Origin => "origin",
380        }
381    }
382}
383
384/// One place a copy of a branch is.
385#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
386pub struct BranchHolder {
387    /// Which kind of place it is.
388    pub kind: BranchHolderKind,
389    /// Where: a path, or the origin's URL.
390    pub location: String,
391}
392
393/// A place a deletion did not reach, and why.
394#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
395pub struct FailedHolder {
396    /// Which kind of place it is.
397    pub kind: BranchHolderKind,
398    /// Where: a path, or the origin's URL.
399    pub location: String,
400    /// What stopped it.
401    pub error: String,
402}
403
404/// What a branch is, and everything that says so.
405// The class, the reason and the proof cannot be folded into one value without changing the
406// document two other repositories parse field by field, so the rule between them is held
407// where a document is *read*: `AnyRetirement` refuses a reason without `keep`, a proof
408// without `retirable`, and a supersession without its class.
409#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
410#[serde(try_from = "AnyRetirement")]
411pub struct Retirement {
412    /// Which class it is.
413    pub class: RetirementClass,
414    /// Why it is kept, for [`RetirementClass::Keep`] alone.
415    pub reason: Option<KeepReason>,
416    /// The read that failed, for [`KeepReason::Unknown`] alone. A document written
417    /// before this field existed reads as `None`.
418    pub cause: Option<UnknownCause>,
419    /// The identity it belongs to.
420    pub identity: String,
421    /// The branch.
422    pub branch: String,
423    /// Where it stands: the first copy's tip, in search order.
424    pub tip: Sha,
425    /// The base it is judged against.
426    pub base: String,
427    /// What proves it holds nothing beyond the base, for [`RetirementClass::Retirable`]
428    /// alone.
429    pub proof: Option<RetirementProof>,
430    /// The commits at its tip that change no content, which no proof asks about.
431    pub content_free_commits: Vec<Sha>,
432    /// The retry that superseded it, for [`RetirementClass::SupersededWithChanges`].
433    pub superseded_by: Option<SupersededBy>,
434    /// The paths it changed that differ from the base: non-empty exactly for
435    /// `superseded-with-changes` and for `keep` with `unmerged-unique-commits`.
436    pub differing_paths: Vec<String>,
437    /// Every place holding a copy of it, in search order.
438    pub holders: Vec<BranchHolder>,
439}
440
441/// A retirement as a document spells it, before its fields are held to each other.
442#[derive(Deserialize)]
443struct AnyRetirement {
444    class: RetirementClass,
445    reason: Option<KeepReason>,
446    #[serde(default)]
447    cause: Option<UnknownCause>,
448    identity: String,
449    branch: String,
450    tip: Sha,
451    base: String,
452    proof: Option<RetirementProof>,
453    content_free_commits: Vec<Sha>,
454    superseded_by: Option<SupersededBy>,
455    differing_paths: Vec<String>,
456    holders: Vec<BranchHolder>,
457}
458
459// llmlint: ignore-end[invalid_states_unrepresentable]
460
461impl TryFrom<AnyRetirement> for Retirement {
462    type Error = String;
463
464    fn try_from(any: AnyRetirement) -> std::result::Result<Self, String> {
465        if (any.class == RetirementClass::Keep) != any.reason.is_some() {
466            return Err("a retirement carries a reason exactly when its class is keep".to_owned());
467        }
468        if (any.class == RetirementClass::Retirable) != any.proof.is_some() {
469            return Err(
470                "a retirement carries a proof exactly when its class is retirable".to_owned(),
471            );
472        }
473        if let Some(cause) = &any.cause {
474            if any.reason != Some(KeepReason::Unknown) {
475                return Err(
476                    "a retirement carries a cause only when its reason is unknown".to_owned(),
477                );
478            }
479            if [&cause.operation, &cause.subject, &cause.error]
480                .iter()
481                .any(|field| field.trim().is_empty())
482            {
483                return Err(
484                    "a retirement's cause names its operation, its subject and its error"
485                        .to_owned(),
486                );
487            }
488        }
489        if any.class == RetirementClass::SupersededWithChanges && any.superseded_by.is_none() {
490            return Err("a superseded-with-changes retirement names what superseded it".to_owned());
491        }
492        Ok(Retirement {
493            class: any.class,
494            reason: any.reason,
495            cause: any.cause,
496            identity: any.identity,
497            branch: any.branch,
498            tip: any.tip,
499            base: any.base,
500            proof: any.proof,
501            content_free_commits: any.content_free_commits,
502            superseded_by: any.superseded_by,
503            differing_paths: any.differing_paths,
504            holders: any.holders,
505        })
506    }
507}
508
509impl Retirement {
510    /// A branch kept for one reason, with nothing else decided about it.
511    fn kept(census: &Census, branch: &str, reason: KeepReason, copies: &Copies) -> Self {
512        Retirement {
513            class: RetirementClass::Keep,
514            reason: Some(reason),
515            cause: None,
516            identity: census.resolution.key.clone(),
517            branch: branch.to_owned(),
518            tip: Sha(copies.first_tip()),
519            base: census.base.clone().unwrap_or_default(),
520            proof: None,
521            content_free_commits: Vec::new(),
522            superseded_by: None,
523            differing_paths: Vec::new(),
524            holders: census.holders_of(copies),
525        }
526    }
527
528    /// A branch kept because the read `cause` names failed.
529    fn unknown(census: &Census, branch: &str, cause: UnknownCause, copies: &Copies) -> Self {
530        Retirement {
531            cause: Some(cause),
532            ..Retirement::kept(census, branch, KeepReason::Unknown, copies)
533        }
534    }
535
536    /// The read that failed, for a branch kept as [`KeepReason::Unknown`].
537    pub fn cause(&self) -> Option<&UnknownCause> {
538        self.cause.as_ref()
539    }
540
541    /// The evidence a refusal prints beside the class: the proof, what superseded it,
542    /// the paths that differ, and where it is.
543    pub fn evidence(&self) -> Vec<String> {
544        let mut lines = Vec::new();
545        if let Some(proof) = &self.proof {
546            lines.push(format!("proof: {}", proof.describe()));
547        }
548        if let Some(by) = &self.superseded_by {
549            lines.push(format!(
550                "superseded by {} (landed at {})",
551                by.branch, by.landing
552            ));
553            if !by.labels.is_empty() {
554                lines.push(format!("labels: {}", spelled_labels(&by.labels)));
555            }
556        }
557        if !self.differing_paths.is_empty() {
558            lines.push(format!(
559                "differs from {} in: {}",
560                self.base,
561                self.differing_paths.join(", ")
562            ));
563        }
564        if !self.content_free_commits.is_empty() {
565            lines.push(format!(
566                "content-free commits at its tip: {}",
567                self.content_free_commits
568                    .iter()
569                    .map(|commit| commit.0.as_str())
570                    .collect::<Vec<_>>()
571                    .join(", ")
572            ));
573        }
574        for holder in &self.holders {
575            lines.push(format!(
576                "held in {} {}",
577                holder.kind.as_str(),
578                holder.location
579            ));
580        }
581        lines
582    }
583
584    /// `class` and, for a kept branch, `/ reason` — and, for one kept as `unknown`,
585    /// `: ` and the read that failed — as a line says them.
586    pub fn verdict(&self) -> String {
587        match (self.reason, &self.cause) {
588            (Some(reason), Some(cause)) => {
589                format!("{} / {}: {cause}", self.class.as_str(), reason.as_str())
590            }
591            (Some(reason), None) => format!("{} / {}", self.class.as_str(), reason.as_str()),
592            (None, _) => self.class.as_str().to_owned(),
593        }
594    }
595}
596
597/// `key=value` pairs, in key order.
598pub(crate) fn spelled_labels(labels: &BTreeMap<String, String>) -> String {
599    labels
600        .iter()
601        .map(|(key, value)| format!("{key}={value}"))
602        .collect::<Vec<_>>()
603        .join(", ")
604}
605
606/// What a retirement did.
607#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
608#[serde(rename_all = "kebab-case")]
609pub enum RetireOutcome {
610    /// Every copy was deleted.
611    Retired,
612    /// A dry run: every copy would have been deleted.
613    WouldRetire,
614    /// Nothing holds the branch any more, and a retirement of it is recorded.
615    AlreadyRetired,
616    /// Its class does not permit what was asked, so nothing was deleted.
617    Kept,
618    /// Some copies were deleted and at least one was not; a re-run finishes it.
619    Incomplete,
620}
621
622impl RetireOutcome {
623    /// The word this outcome travels as.
624    pub fn as_str(self) -> &'static str {
625        match self {
626            RetireOutcome::Retired => "retired",
627            RetireOutcome::WouldRetire => "would-retire",
628            RetireOutcome::AlreadyRetired => "already-retired",
629            RetireOutcome::Kept => "kept",
630            RetireOutcome::Incomplete => "incomplete",
631        }
632    }
633}
634
635/// Whether a pass derived a branch's verdict or reused one it recorded earlier.
636///
637/// A pass records each verdict it derives under everything the derivation read, and
638/// reuses it only while every one of those inputs is unchanged. What it reuses is the
639/// derivation — the ancestry, landing and content proofs — and nothing else: live
640/// holders, exclusions, checked-out and dirty worktrees, and every read around a
641/// deletion are asked fresh on every pass whichever this is.
642#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
643#[serde(rename_all = "kebab-case")]
644pub enum Derivation {
645    /// Decided on this pass: the proofs were run, or a check made fresh decided it
646    /// before any proof was needed.
647    #[default]
648    Derived,
649    /// The proofs were answered by the verdict recorded under inputs this pass read
650    /// unchanged.
651    Reused,
652}
653
654impl Derivation {
655    /// The word this answer travels as.
656    pub fn as_str(self) -> &'static str {
657        match self {
658            Derivation::Derived => "derived",
659            Derivation::Reused => "reused",
660        }
661    }
662}
663
664/// One branch's retirement: its classification, and what was done about it.
665// llmlint: ignore[invalid_states_unrepresentable] the retirement amendment in
666// `docs/contract.md` fixes this public type field for field — `onepipeline` links it and
667// `ai-orchestrator` reads its JSON — so folding the outcome and the lists into one enum
668// would change a shared interface this crate may not change alone. What holds them
669// together is construction: nothing else in this crate builds one, and each place here
670// (`Retired::nothing`, the dry runs in `act` and `retire_named`, `Census::finish`) derives
671// the outcome from the lists it fills.
672#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
673pub struct Retired {
674    /// What the branch is.
675    #[serde(flatten)]
676    pub retirement: Retirement,
677    /// What was done.
678    pub outcome: RetireOutcome,
679    /// The places it was deleted from — or, for a dry run, would be.
680    pub deleted: Vec<BranchHolder>,
681    /// The places a deletion did not reach.
682    pub failed: Vec<FailedHolder>,
683    /// The pool slots returned.
684    pub slots_returned: Vec<PathBuf>,
685    /// The run roots removed.
686    pub run_roots_removed: Vec<PathBuf>,
687    /// The session records closed.
688    pub sessions_closed: Vec<SessionToken>,
689    /// Whether the verdict was derived on this pass or reused from the one recorded
690    /// under unchanged inputs. `onevcs retire` and `reclaim` always derive; a document
691    /// written before this field existed reads as `derived`.
692    ///
693    /// A field rather than a map beside the entries, though a caller's exhaustive
694    /// literal of this struct stops compiling (cargo-semver-checks'
695    /// `constructible_struct_adds_field`), which before 1.0 is a minor release: the
696    /// contract fixes `derivation` on every examined entry, and a sidecar keyed by
697    /// branch would be a second place to look for one entry's answer.
698    #[serde(default)]
699    pub derivation: Derivation,
700}
701
702impl Retired {
703    fn nothing(retirement: Retirement, outcome: RetireOutcome) -> Self {
704        Retired {
705            retirement,
706            outcome,
707            deleted: Vec::new(),
708            failed: Vec::new(),
709            slots_returned: Vec::new(),
710            run_roots_removed: Vec::new(),
711            sessions_closed: Vec::new(),
712            derivation: Derivation::Derived,
713        }
714    }
715}
716
717/// What one automatic pass examined, and what became of each branch.
718#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
719pub struct RetirementPassReport {
720    /// Whether this was a dry run.
721    pub dry_run: bool,
722    /// Every branch examined, in identity then branch order.
723    pub examined: Vec<Retired>,
724}
725
726/// One retirement in the lines a person reads: what it is, what was done, and what
727/// was not.
728pub(crate) fn describe_retired(retired: &Retired, verb: &str) -> Vec<String> {
729    let retirement = &retired.retirement;
730    let named = format!("{} of {}", retirement.branch, retirement.identity);
731    let places = |holders: &[BranchHolder]| {
732        holders
733            .iter()
734            .map(|holder| format!("{} {}", holder.kind.as_str(), holder.location))
735            .collect::<Vec<_>>()
736            .join("; ")
737    };
738    let mut lines = vec![match retired.outcome {
739        RetireOutcome::Retired => format!(
740            "retired: {named} ({}) was deleted from {}",
741            retirement.verdict(),
742            places(&retired.deleted)
743        ),
744        RetireOutcome::WouldRetire => format!(
745            "would retire: {named} ({}) would be deleted from {}. Nothing was changed: this was \
746             a rehearsal",
747            retirement.verdict(),
748            places(&retired.deleted)
749        ),
750        RetireOutcome::AlreadyRetired => format!(
751            "already retired: nothing on this host holds {named} any more, and its retirement \
752             ({}) is recorded",
753            retirement.verdict()
754        ),
755        RetireOutcome::Kept => format!(
756            "refused: {named} is {}, which `onevcs {verb}` does not delete; nothing was deleted",
757            retirement.verdict()
758        ),
759        RetireOutcome::Incomplete => format!(
760            "retired in part: {named} ({}) was deleted from {} and not from {}. Re-run `onevcs \
761             {verb} {} --repo {}` once that is fixed",
762            retirement.verdict(),
763            match retired.deleted.is_empty() {
764                true => "nowhere".to_owned(),
765                false => places(&retired.deleted),
766            },
767            retired
768                .failed
769                .iter()
770                .map(|failed| format!(
771                    "{} {} ({})",
772                    failed.kind.as_str(),
773                    failed.location,
774                    failed.error
775                ))
776                .collect::<Vec<_>>()
777                .join("; "),
778            retirement.branch,
779            retirement.identity,
780        ),
781    }];
782    lines.extend(
783        retirement
784            .evidence()
785            .into_iter()
786            .map(|line| format!("  {line}")),
787    );
788    if retired.outcome == RetireOutcome::Kept
789        && retirement.class == RetirementClass::SupersededWithChanges
790    {
791        lines.push(format!(
792            "  it differs from {} only in what the retry that superseded it replaced: `onevcs \
793             reclaim {} --repo {}` discards that and deletes it",
794            retirement.base, retirement.branch, retirement.identity
795        ));
796    }
797    for slot in &retired.slots_returned {
798        lines.push(format!("  returned slot {}", slot.display()));
799    }
800    for run_root in &retired.run_roots_removed {
801        lines.push(format!("  removed run root {}", run_root.display()));
802    }
803    for token in &retired.sessions_closed {
804        lines.push(format!("  closed session {}", token.0));
805    }
806    lines
807}
808
809/// One examined branch in one line: what was done, and what decided it.
810pub(crate) fn describe_line(retired: &Retired) -> String {
811    let retirement = &retired.retirement;
812    let decided = match &retirement.proof {
813        Some(proof) => proof.describe(),
814        None => retirement.verdict(),
815    };
816    let failed = match retired.failed.is_empty() {
817        true => String::new(),
818        false => format!(
819            "; not deleted from {}",
820            retired
821                .failed
822                .iter()
823                .map(|failed| format!("{} ({})", failed.location, failed.error))
824                .collect::<Vec<_>>()
825                .join("; ")
826        ),
827    };
828    format!(
829        "{branch} [{identity}] — {outcome}: {decided}{failed}",
830        branch = retirement.branch,
831        identity = retirement.identity,
832        outcome = retired.outcome.as_str(),
833    )
834}
835
836/// A pass in the lines a person reads.
837pub(crate) fn describe_pass(report: &RetirementPassReport) -> Vec<String> {
838    let counted = |outcome: RetireOutcome| {
839        report
840            .examined
841            .iter()
842            .filter(|entry| entry.outcome == outcome)
843            .count()
844    };
845    let retired = match report.dry_run {
846        true => counted(RetireOutcome::WouldRetire),
847        false => counted(RetireOutcome::Retired),
848    };
849    let mut lines = vec![format!(
850        "{} {retired} finished branch(es), kept {}{}.",
851        match report.dry_run {
852            true => "would retire",
853            false => "retired",
854        },
855        counted(RetireOutcome::Kept),
856        match counted(RetireOutcome::Incomplete) {
857            0 => String::new(),
858            partial => format!(", and retired {partial} in part"),
859        }
860    )];
861    if report.dry_run {
862        lines.push("Nothing was changed: this was a rehearsal.".to_owned());
863    }
864    for entry in &report.examined {
865        lines.extend(describe_retired(entry, "retire"));
866    }
867    lines
868}
869
870/// Which moment acted, as the event records it.
871#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
872#[serde(rename_all = "kebab-case")]
873pub(crate) enum Trigger {
874    /// `onevcs retire` or `onevcs reclaim`.
875    Verb,
876    /// `session close`.
877    SessionClose,
878    /// `onevcs sweep`.
879    Sweep,
880    /// [`retire_finished`], which is `onevcs retire-finished` and an engine's idle
881    /// maintenance.
882    Pass,
883}
884
885impl Trigger {
886    /// The word this moment travels as.
887    pub(crate) fn as_str(self) -> &'static str {
888        match self {
889            Trigger::Verb => "verb",
890            Trigger::SessionClose => "session-close",
891            Trigger::Sweep => "sweep",
892            Trigger::Pass => "pass",
893        }
894    }
895}
896
897/// Who asked for a retirement, which decides both what it may act on and how the
898/// event records it: its `mode`.
899#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
900#[serde(rename_all = "kebab-case")]
901pub(crate) enum Acting {
902    Retire,
903    Reclaim,
904    Discard,
905    Automatic,
906}
907
908impl Acting {
909    /// The word this mode travels as.
910    pub(crate) fn as_str(self) -> &'static str {
911        match self {
912            Acting::Retire => "retire",
913            Acting::Reclaim => "reclaim",
914            Acting::Discard => "discard",
915            Acting::Automatic => "automatic",
916        }
917    }
918
919    /// Whether a branch of this class may be deleted, as far as the class alone
920    /// decides. Only a person, through `reclaim`, ever discards a difference from the
921    /// base, and only `reclaim --discard` one nothing superseded.
922    pub(crate) fn permits_class(self, class: RetirementClass) -> bool {
923        match class {
924            RetirementClass::Retirable => true,
925            RetirementClass::SupersededWithChanges => {
926                matches!(self, Acting::Reclaim | Acting::Discard)
927            }
928            RetirementClass::Keep => self == Acting::Discard,
929        }
930    }
931
932    /// Whether a branch of this class, kept for this reason where it is kept, may be
933    /// deleted: a discard takes a kept branch whose one reason is work nothing landed,
934    /// and refuses every other reason to keep one.
935    pub(crate) fn permits(self, class: RetirementClass, reason: Option<KeepReason>) -> bool {
936        self.permits_class(class)
937            && (class != RetirementClass::Keep || reason == Some(KeepReason::UnmergedUniqueCommits))
938    }
939}
940
941/// What a classification may do to find its answer.
942#[derive(Clone, Copy)]
943pub(crate) struct Ask<'a> {
944    /// The host to ask about a change request the records do not decide, or `None`
945    /// to decide from the records alone.
946    host: Option<&'a dyn Hosting>,
947    /// Fetch a copy's commits into the publication checkout's object store where only
948    /// the origin holds them — objects, and never a ref, so a dry run may too.
949    fetch_objects: bool,
950    /// Record a merge the host reports, the way a read that finds a late merge does.
951    reconcile: bool,
952    /// Ask the origin itself where the branch is, rather than the publication
953    /// checkout's remote-tracking copy.
954    remote: bool,
955    exclude: &'a [BranchRef],
956    /// Where a pass reuses the verdicts it recorded and records the ones it derives, or
957    /// `None` for a classification that derives every verdict it answers.
958    verdicts: Option<&'a crate::verdict::Store>,
959    /// What becomes of a merge a session's worktree over the branch left in progress,
960    /// once no live session holds it.
961    merges: LeftMerges,
962}
963
964/// What a census does with a merge a session left in progress in a worktree over a
965/// branch it classifies.
966#[derive(Debug, Clone, Copy, PartialEq, Eq)]
967enum LeftMerges {
968    /// Leave it: a read, and a dry run, change nothing.
969    Leave,
970    /// Abort it, keeping every commit: what a retirement that is going to act does.
971    Abort,
972}
973
974impl<'a> Ask<'a> {
975    /// The read `recoverable` makes: records and local refs, nothing else.
976    pub(crate) fn offline() -> Ask<'static> {
977        Ask {
978            host: None,
979            fetch_objects: false,
980            reconcile: false,
981            remote: false,
982            exclude: &[],
983            verdicts: None,
984            merges: LeftMerges::Leave,
985        }
986    }
987
988    fn query(host: &'a dyn Hosting) -> Self {
989        Ask {
990            host: Some(host),
991            fetch_objects: true,
992            reconcile: false,
993            remote: true,
994            exclude: &[],
995            verdicts: None,
996            merges: LeftMerges::Leave,
997        }
998    }
999
1000    fn acting(host: Option<&'a dyn Hosting>, dry_run: bool, exclude: &'a [BranchRef]) -> Self {
1001        Ask {
1002            host,
1003            fetch_objects: true,
1004            reconcile: !dry_run,
1005            remote: true,
1006            exclude,
1007            verdicts: None,
1008            merges: match dry_run {
1009                true => LeftMerges::Leave,
1010                false => LeftMerges::Abort,
1011            },
1012        }
1013    }
1014}
1015
1016/// How far a census may reach to learn where the identity's base is on its origin.
1017#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1018pub(crate) enum Reach {
1019    /// The publication checkout's own remote-tracking copy, and nothing else: the one
1020    /// reach a report that never asks anything outside this host may make.
1021    Offline,
1022    /// Ask the origin where the base is, and fetch the commit's objects where the
1023    /// checkout lacks them — without moving a ref, so a dry run may make it.
1024    Remote,
1025    /// Fetch the origin into the publication checkout, which is what a retirement that
1026    /// is going to act does first.
1027    Fetch,
1028}
1029
1030/// Where one copy of a branch can be.
1031#[derive(Debug, Clone)]
1032struct Place {
1033    kind: BranchHolderKind,
1034    repo: PathBuf,
1035    /// The slot directory, for a slot's clone.
1036    slot: Option<PathBuf>,
1037}
1038
1039/// Where a read of a branch's tips comes from.
1040#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1041enum Tips {
1042    /// The census's listings: each place's branches once, and the origin's once.
1043    Listed,
1044    /// Git itself, ref by ref and past anything the pass remembers — the read made
1045    /// immediately before a deletion.
1046    Now,
1047}
1048
1049/// Which place a copy is in.
1050#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1051enum Holding {
1052    Local(usize),
1053    Origin,
1054}
1055
1056/// One copy of the branch and the commit it stands at.
1057#[derive(Debug, Clone, PartialEq, Eq)]
1058struct Copy {
1059    at: Holding,
1060    tip: String,
1061}
1062
1063/// Every copy of one branch this host could read, and every place it could not.
1064#[derive(Debug, Clone, Default, PartialEq, Eq)]
1065struct Copies {
1066    copies: Vec<Copy>,
1067    unreadable: Vec<UnknownCause>,
1068}
1069
1070impl Copies {
1071    fn first_tip(&self) -> String {
1072        self.copies
1073            .first()
1074            .map(|copy| copy.tip.clone())
1075            .unwrap_or_default()
1076    }
1077}
1078
1079/// Everything one identity's classifications read, read once.
1080pub(crate) struct Census<'a> {
1081    registry: &'a Registry,
1082    resolution: Resolution,
1083    base: Option<String>,
1084    base_tip: Option<String>,
1085    lent: Option<PathBuf>,
1086    origin: Option<String>,
1087    places: Vec<Place>,
1088    /// Every directory a place could be in that could not be listed, so a copy there
1089    /// is one no read of this identity can rule out.
1090    unlisted: Vec<UnknownCause>,
1091    /// Why the base or where the origin has it could not be read, where it could not.
1092    unread_base: Option<UnknownCause>,
1093    /// Every place's local branches with their tips, listed once where a read may
1094    /// answer from a listing — which is every read but the one made immediately before
1095    /// deleting, where a tip is asked of git again.
1096    heads: Vec<std::cell::OnceCell<std::result::Result<BTreeMap<String, String>, String>>>,
1097    /// The publication checkout's remote-tracking branches, for a read that does not
1098    /// ask the origin itself.
1099    tracked: std::cell::OnceCell<std::result::Result<BTreeMap<String, String>, String>>,
1100    /// Every branch the origin has, from the one listing a census that reaches the
1101    /// origin makes — where the origin is asked, it is asked this once, and the tip of
1102    /// every branch and of the base is read out of the answer. `None` for a census that
1103    /// does not ask the origin.
1104    origin_heads: Option<std::result::Result<BTreeMap<String, ObjectId>, String>>,
1105    /// Whether every stream under the state root could be listed, without which no
1106    /// branch's records can be said to be unchanged.
1107    stream_listing: status::Listing,
1108    sessions: &'a [Record],
1109    streams: &'a [status::Recorded],
1110    trailers: &'a Trailers,
1111}
1112
1113impl<'a> Census<'a> {
1114    /// Read one identity: its base, and every place a copy of one of its branches can
1115    /// be — its registered checkouts, the clone of every pool slot on disk, and every
1116    /// run clone, whether or not a session record still names it.
1117    pub(crate) fn read(
1118        registry: &'a Registry,
1119        identity: &str,
1120        sessions: &'a [Record],
1121        streams: &'a [status::Recorded],
1122        trailers: &'a Trailers,
1123        reach: Reach,
1124        only: Option<&BTreeSet<PathBuf>>,
1125    ) -> Result<Self> {
1126        let resolution = store::resolve(registry, identity)?;
1127        let publication = resolution.publication.clone();
1128        // A fetch that failed leaves the base where the checkout last saw it, which
1129        // is not where the origin has it now — and every proof is asked against the
1130        // origin's base. So the base is unknown rather than stale.
1131        let fetch_failure = match reach {
1132            Reach::Fetch => git::fetch(&publication, "origin").err(),
1133            Reach::Offline | Reach::Remote => None,
1134        };
1135        let fetched = fetch_failure.is_none();
1136        let base = git::default_branch(&publication, "origin");
1137        let origin = git::remote_url(&publication, "origin").ok();
1138        // One listing of the origin's branches for the whole identity, which answers
1139        // where the origin has every branch and the base alike. A listing that failed
1140        // says nothing about any of them, so it is carried as the reason rather than as
1141        // an empty origin.
1142        let origin_heads = match (reach, &origin) {
1143            (Reach::Offline, _) | (_, None) => None,
1144            (Reach::Remote | Reach::Fetch, Some(_)) => {
1145                Some(match git::remote_branch_tips(&publication, "origin") {
1146                    Ok(Some(tips)) => Ok(tips),
1147                    Ok(None) => Err("it could not be asked which branches it has".to_owned()),
1148                    Err(failure) => Err(failure.to_string()),
1149                })
1150            }
1151        };
1152        let base_tip = match (reach, base.as_deref()) {
1153            (_, Err(_)) => None,
1154            (Reach::Remote | Reach::Fetch, Ok(base)) => fetched
1155                .then(|| remote_base(&publication, base, origin_heads.as_ref()))
1156                .flatten(),
1157            (Reach::Offline, Ok(base)) => {
1158                git::tip(&publication, &format!("refs/remotes/origin/{base}"))
1159            }
1160        };
1161        let unread_base = match (&base, &base_tip) {
1162            (Ok(_), Some(_)) => None,
1163            (Err(failure), _) => Some(UnknownCause::new(
1164                "census",
1165                publication.display(),
1166                format!("which branch the origin's HEAD names: {failure}"),
1167            )),
1168            (Ok(base), None) => Some(UnknownCause::new(
1169                "census",
1170                publication.display(),
1171                match (&fetch_failure, &origin_heads) {
1172                    (Some(failure), _) => format!("fetching the origin: {failure}"),
1173                    (None, Some(Err(said))) => format!("listing the origin's branches: {said}"),
1174                    (None, _) => format!("where the origin has {base} could not be read"),
1175                },
1176            )),
1177        };
1178        let base = base.ok();
1179        let mut places: Vec<Place> = vec![Place {
1180            kind: BranchHolderKind::Checkout,
1181            repo: publication.clone(),
1182            slot: None,
1183        }];
1184        for checkout in registry.checkouts.values() {
1185            if checkout.identity == resolution.key
1186                && !places.iter().any(|place| place.repo == checkout.path)
1187            {
1188                places.push(Place {
1189                    kind: BranchHolderKind::Checkout,
1190                    repo: checkout.path.clone(),
1191                    slot: None,
1192                });
1193            }
1194        }
1195        let root = workspace::identity_dir(&resolution.key)?;
1196        let mut unlisted = Vec::new();
1197        for (slot, _) in numbered(&pool::pool_dir(&root), &mut unlisted) {
1198            places.push(Place {
1199                kind: BranchHolderKind::Slot,
1200                repo: slot.join("clone"),
1201                slot: Some(slot),
1202            });
1203        }
1204        for (run_root, _) in listed(&root.join("runs"), &mut unlisted) {
1205            let clone = run_root.join("clone");
1206            if clone.exists() && !emptied(&clone) {
1207                places.push(Place {
1208                    kind: BranchHolderKind::RunClone,
1209                    repo: clone,
1210                    slot: None,
1211                });
1212            }
1213        }
1214        for record in sessions {
1215            if record.identity != resolution.key
1216                || places.iter().any(|place| place.repo == record.clone)
1217                || !record.clone.exists()
1218                || (record.slot.is_none() && emptied(&record.clone))
1219            {
1220                continue;
1221            }
1222            places.push(Place {
1223                kind: match record.slot {
1224                    Some(_) => BranchHolderKind::Slot,
1225                    None => BranchHolderKind::RunClone,
1226                },
1227                repo: record.clone.clone(),
1228                slot: record.slot.map(|_| record.run_root.clone()),
1229            });
1230        }
1231        // A read narrowed to some sessions looks only where those sessions can hold a
1232        // branch, which is the whole promise of the narrowing.
1233        if let Some(only) = only {
1234            places.retain(|place| only.contains(&place.repo));
1235        }
1236        Ok(Census {
1237            registry,
1238            heads: places.iter().map(|_| std::cell::OnceCell::new()).collect(),
1239            tracked: std::cell::OnceCell::new(),
1240            origin_heads,
1241            stream_listing: status::Listing::Whole,
1242            lent: git::objects_dir(&publication).ok(),
1243            origin,
1244            resolution,
1245            base,
1246            base_tip,
1247            places,
1248            unlisted,
1249            unread_base,
1250            sessions,
1251            streams,
1252            trailers,
1253        })
1254    }
1255
1256    fn publication(&self) -> &Path {
1257        &self.resolution.publication
1258    }
1259
1260    /// One repository, to ask about a commit through the store the publication
1261    /// checkout lends — which the publication checkout itself has no need to borrow, so
1262    /// it is asked the one way and a question about it is one question.
1263    fn asked<'r>(&'r self, repo: &'r Path) -> git::Asked<'r> {
1264        match repo == self.publication() {
1265            true => git::Asked::borrowing(repo, None),
1266            false => git::Asked::borrowing(repo, self.lent.as_deref()),
1267        }
1268    }
1269
1270    /// Where one place has the branch: from the listing of its branches, or — asked
1271    /// [`Tips::Now`], which is what a read immediately before deleting is — from git
1272    /// now, past anything the pass remembers.
1273    fn local_tip(&self, index: usize, branch: &str, tips: Tips) -> LocalTip {
1274        let place = &self.places[index];
1275        if tips == Tips::Now {
1276            return git::unremembered(|| git::local_tip(&place.repo, branch));
1277        }
1278        if !place.repo.exists() {
1279            return LocalTip::Absent;
1280        }
1281        let listed = self.heads[index].get_or_init(|| {
1282            git::heads(&place.repo)
1283                .map(|heads| heads.into_iter().collect())
1284                .map_err(|failure| failure.to_string())
1285        });
1286        match listed {
1287            Ok(heads) => match heads.get(branch).and_then(|tip| ObjectId::parse(tip)) {
1288                Some(tip) => LocalTip::At(tip),
1289                None => LocalTip::Absent,
1290            },
1291            Err(said) => LocalTip::Unreadable(said.clone()),
1292        }
1293    }
1294
1295    /// Where each copy of the branch stands, as the census's listings have it: each
1296    /// place's branches, and the origin's where the census asked the origin.
1297    fn copies(&self, branch: &str, ask: &Ask<'_>) -> Copies {
1298        self.copies_read(branch, ask, Tips::Listed)
1299    }
1300
1301    /// Where each copy stands now, asked of every place and of the origin one ref at a
1302    /// time: the read immediately before a deletion, and the only one that asks the
1303    /// origin about a single branch.
1304    fn copies_now(&self, branch: &str, ask: &Ask<'_>) -> Copies {
1305        self.copies_read(branch, ask, Tips::Now)
1306    }
1307
1308    fn copies_read(&self, branch: &str, ask: &Ask<'_>, tips: Tips) -> Copies {
1309        let mut read = Copies {
1310            unreadable: self.unlisted.clone(),
1311            ..Copies::default()
1312        };
1313        for (index, place) in self.places.iter().enumerate() {
1314            match self.local_tip(index, branch, tips) {
1315                LocalTip::At(tip) => read.copies.push(Copy {
1316                    at: Holding::Local(index),
1317                    tip: tip.as_str().to_owned(),
1318                }),
1319                LocalTip::Absent => {}
1320                LocalTip::Unreadable(said) => {
1321                    read.unreadable
1322                        .push(UnknownCause::new("copy", place.repo.display(), said))
1323                }
1324            }
1325        }
1326        let Some(origin) = self.origin.as_deref() else {
1327            return read;
1328        };
1329        let unread = |error: String| UnknownCause::new("copy", origin, error);
1330        match (ask.remote, tips) {
1331            (true, Tips::Now) => match git::remote_tip(self.publication(), "origin", branch, &[]) {
1332                Ok(RemoteTip::At(tip)) => read.copies.push(Copy {
1333                    at: Holding::Origin,
1334                    tip: tip.as_str().to_owned(),
1335                }),
1336                Ok(RemoteTip::Absent) => {}
1337                Ok(RemoteTip::Unknown) => read.unreadable.push(unread(
1338                    "the origin could not be asked where the branch is".to_owned(),
1339                )),
1340                Err(failure) => read.unreadable.push(unread(failure.to_string())),
1341            },
1342            (true, Tips::Listed) => match &self.origin_heads {
1343                Some(Ok(tips)) => {
1344                    if let Some(tip) = tips.get(branch) {
1345                        read.copies.push(Copy {
1346                            at: Holding::Origin,
1347                            tip: tip.as_str().to_owned(),
1348                        });
1349                    }
1350                }
1351                Some(Err(said)) => read.unreadable.push(unread(said.clone())),
1352                None => read.unreadable.push(unread(
1353                    "the origin was not asked where the branch is".to_owned(),
1354                )),
1355            },
1356            (false, _) => {
1357                // A listing that failed says nothing about where the origin has the
1358                // branch, so it is a place this read could not see rather than absence.
1359                let tracked = self.tracked.get_or_init(|| {
1360                    git::remote_heads(self.publication(), "origin")
1361                        .map_err(|failure| failure.to_string())
1362                });
1363                match tracked {
1364                    Ok(tracked) => {
1365                        if let Some(tip) = tracked.get(branch) {
1366                            read.copies.push(Copy {
1367                                at: Holding::Origin,
1368                                tip: tip.clone(),
1369                            });
1370                        }
1371                    }
1372                    Err(said) => read.unreadable.push(unread(format!(
1373                        "the origin's remote-tracking branches: {said}"
1374                    ))),
1375                }
1376            }
1377        }
1378        read
1379    }
1380
1381    fn holder(&self, at: Holding) -> BranchHolder {
1382        match at {
1383            Holding::Local(index) => BranchHolder {
1384                kind: self.places[index].kind,
1385                location: self.places[index].repo.display().to_string(),
1386            },
1387            Holding::Origin => BranchHolder {
1388                kind: BranchHolderKind::Origin,
1389                location: self.origin.clone().unwrap_or_default(),
1390            },
1391        }
1392    }
1393
1394    fn holders_of(&self, copies: &Copies) -> Vec<BranchHolder> {
1395        copies
1396            .copies
1397            .iter()
1398            .map(|copy| self.holder(copy.at))
1399            .collect()
1400    }
1401
1402    /// The newest session record of this branch, whose stream is the branch's own.
1403    fn session_of(&self, branch: &str) -> Option<&Record> {
1404        crate::vcs::latest_session(self.sessions, &self.resolution.key, branch)
1405    }
1406
1407    /// The open session holding the branch whose owner is running or whose run root
1408    /// something is working in, where one does.
1409    fn live_holder(&self, branch: &str) -> std::result::Result<Option<String>, UnknownCause> {
1410        for record in self.sessions {
1411            if record.identity != self.resolution.key
1412                || record.state != Lifecycle::Open
1413                || !self.places.iter().any(|place| place.repo == record.clone)
1414            {
1415                continue;
1416            }
1417            let over = *record.branch == *branch
1418                || git::checked_out_branch(&record.worktree).is_ok_and(|current| current == branch);
1419            if !over {
1420                continue;
1421            }
1422            let occupied = || {
1423                lock::is_occupied(&record.lease()).map_err(|failure| {
1424                    UnknownCause::new("live-holder", record.run_root.display(), failure)
1425                })
1426            };
1427            if record.owner_is_running()
1428                || occupied()?
1429                || !processes::holding(&record.run_root).is_empty()
1430            {
1431                return Ok(Some(record.token.to_string()));
1432            }
1433        }
1434        Ok(None)
1435    }
1436
1437    /// The places holding a local copy — the only places a worktree can have the
1438    /// branch checked out in, since a checked-out branch is a ref of its repository.
1439    fn holding<'c>(&'c self, copies: &'c Copies) -> impl Iterator<Item = &'c Place> + 'c {
1440        copies.copies.iter().filter_map(|copy| match copy.at {
1441            Holding::Local(index) => Some(&self.places[index]),
1442            Holding::Origin => None,
1443        })
1444    }
1445
1446    /// A registered checkout with the branch checked out in one of its worktrees.
1447    fn checked_out(
1448        &self,
1449        branch: &str,
1450        copies: &Copies,
1451    ) -> std::result::Result<Option<PathBuf>, UnknownCause> {
1452        for place in self.holding(copies) {
1453            if place.kind != BranchHolderKind::Checkout {
1454                continue;
1455            }
1456            for (worktree, on) in worktrees_of(&place.repo)? {
1457                if on.as_deref() == Some(branch) {
1458                    return Ok(Some(worktree));
1459                }
1460            }
1461        }
1462        Ok(None)
1463    }
1464
1465    /// Abort every merge a worktree over the branch — a session's, a slot's, a run's —
1466    /// left in progress, which is the merge a session opened over and nobody concluded.
1467    ///
1468    /// Asked only once no live session holds the branch, so the merge is nobody's to
1469    /// conclude any more, and before anything reads the tree: a merge left standing
1470    /// reads as a dirty worktree, keeping a branch nobody is working on, and is carried
1471    /// into whatever next takes the tree. What it discards is a resolution nobody
1472    /// committed; every commit stays. A registered checkout is the operator's own and
1473    /// is never touched, as [`Self::dirty`] never reads one.
1474    fn abort_merges(&self, branch: &str, copies: &Copies) {
1475        for place in self.holding(copies) {
1476            if place.kind == BranchHolderKind::Checkout {
1477                continue;
1478            }
1479            let Ok(heads) = git::worktree_heads(&place.repo) else {
1480                continue;
1481            };
1482            for (worktree, on) in heads {
1483                if on.as_deref() == Some(branch) && worktree != place.repo {
1484                    git::abort_merge(&worktree);
1485                }
1486            }
1487        }
1488    }
1489
1490    /// A worktree over the branch — a session's, a slot's, a run's — holding changes
1491    /// nobody committed.
1492    fn dirty(
1493        &self,
1494        branch: &str,
1495        copies: &Copies,
1496    ) -> std::result::Result<Option<PathBuf>, UnknownCause> {
1497        for place in self.holding(copies) {
1498            if place.kind == BranchHolderKind::Checkout {
1499                continue;
1500            }
1501            for (worktree, on) in worktrees_of(&place.repo)? {
1502                // A clone's own tree is cut `--no-checkout` and never populated, so its
1503                // status is every file missing rather than anybody's work.
1504                if on.as_deref() != Some(branch) || worktree == place.repo {
1505                    continue;
1506                }
1507                let dirty = worktree.is_dir()
1508                    && git::is_dirty(&worktree).map_err(|failure| {
1509                        UnknownCause::new("worked-in", worktree.display(), failure)
1510                    })?;
1511                if dirty {
1512                    return Ok(Some(worktree));
1513                }
1514            }
1515        }
1516        Ok(None)
1517    }
1518
1519    /// Classify one branch from where its copies stand.
1520    ///
1521    /// What is asked fresh comes first and last, whatever a pass remembers: whether it
1522    /// is the base, whether a live session holds it, whether the caller excluded it and
1523    /// whether every place could be read — and, for a verdict the derivation reached
1524    /// past the change request, whether a checkout has it checked out or a worktree over
1525    /// it is dirty. Between them is the derivation, which a pass may answer from the
1526    /// verdict it recorded under inputs it reads unchanged.
1527    fn classify(&self, branch: &str, copies: Copies, ask: &Ask<'_>) -> Result<Classified> {
1528        let classified = |retirement: Retirement, derivation| Classified {
1529            retirement,
1530            copies: copies.clone(),
1531            derivation,
1532        };
1533        let keep = |reason| {
1534            classified(
1535                Retirement::kept(self, branch, reason, &copies),
1536                Derivation::Derived,
1537            )
1538        };
1539        let unknown = |cause| {
1540            classified(
1541                Retirement::unknown(self, branch, cause, &copies),
1542                Derivation::Derived,
1543            )
1544        };
1545        let (Some(base), Some(base_tip)) = (self.base.as_deref(), self.base_tip.as_deref()) else {
1546            return Ok(unknown(self.unread_base.clone().unwrap_or_else(|| {
1547                UnknownCause::new(
1548                    "census",
1549                    self.publication().display(),
1550                    "the base could not be read",
1551                )
1552            })));
1553        };
1554        if branch == base {
1555            return Ok(keep(KeepReason::IsBase));
1556        }
1557        match self.live_holder(branch) {
1558            Ok(Some(_)) => return Ok(keep(KeepReason::HeldByLiveSession)),
1559            Ok(None) => {}
1560            Err(cause) => return Ok(unknown(cause)),
1561        }
1562        if ask
1563            .exclude
1564            .iter()
1565            .any(|excluded| excluded.identity == self.resolution.key && excluded.branch == branch)
1566        {
1567            return Ok(keep(KeepReason::Excluded));
1568        }
1569        if ask.merges == LeftMerges::Abort {
1570            self.abort_merges(branch, &copies);
1571        }
1572        if let Some(cause) = copies.unreadable.first() {
1573            return Ok(unknown(cause.clone()));
1574        }
1575        let (verdict, derivation) = self.verdict(branch, base, base_tip, &copies, ask);
1576        if verdict.reached == crate::verdict::Reached::Early {
1577            return Ok(classified(verdict.retirement, derivation));
1578        }
1579        // Every read below that fails is a proof that did not hold, and the answer to
1580        // that is `unknown` — never a class a read that was not made decided.
1581        let retirement = match self.worked_in(branch, &copies) {
1582            Ok(Some(reason)) => Retirement::kept(self, branch, reason, &copies),
1583            Ok(None) => verdict.retirement,
1584            Err(cause) => Retirement::unknown(self, branch, cause, &copies),
1585        };
1586        Ok(classified(retirement, derivation))
1587    }
1588
1589    /// Whether somebody is working on the branch where it is: a registered checkout
1590    /// has it checked out, or a worktree over it holds changes nobody committed.
1591    fn worked_in(
1592        &self,
1593        branch: &str,
1594        copies: &Copies,
1595    ) -> std::result::Result<Option<KeepReason>, UnknownCause> {
1596        if self.checked_out(branch, copies)?.is_some() {
1597            return Ok(Some(KeepReason::CheckedOut));
1598        }
1599        if self.dirty(branch, copies)?.is_some() {
1600            return Ok(Some(KeepReason::DirtyWorktree));
1601        }
1602        Ok(None)
1603    }
1604
1605    /// The branch's derived verdict: the one recorded under exactly the inputs this
1606    /// pass reads, where there is one and asking the host again hears what it heard —
1607    /// otherwise derived now, and recorded where nothing it read was unreadable.
1608    fn verdict(
1609        &self,
1610        branch: &str,
1611        base: &str,
1612        base_tip: &str,
1613        copies: &Copies,
1614        ask: &Ask<'_>,
1615    ) -> (crate::verdict::Verdict, Derivation) {
1616        let recorded = ask
1617            .verdicts
1618            .and_then(|store| Some((store, self.key(branch, base, base_tip, copies, ask)?)));
1619        let mut replies = None;
1620        if let Some((store, key)) = &recorded {
1621            if let Some(verdict) = store
1622                .read(key)
1623                .filter(|verdict| self.describes(&verdict.retirement, branch, base, copies))
1624            {
1625                if !verdict.consulted {
1626                    return (verdict, Derivation::Reused);
1627                }
1628                // The derivation asked the host, so this pass asks it the same
1629                // questions; a different answer is a changed input, and one it could not
1630                // hear is not an unchanged one.
1631                let mut evidence = self.evidence(branch);
1632                let repo = copies
1633                    .copies
1634                    .first()
1635                    .and_then(|copy| self.readable(copy, branch, ask));
1636                let (heard, _) = self.consult(
1637                    branch,
1638                    &mut evidence,
1639                    &copies.first_tip(),
1640                    repo.as_deref(),
1641                    ask,
1642                    None,
1643                );
1644                if !heard.failed() && heard.same_as(&verdict.host) {
1645                    return (verdict, Derivation::Reused);
1646                }
1647                replies = Some(heard);
1648            }
1649        }
1650        let derived = self.derive(branch, base_tip, copies, ask, replies.as_ref());
1651        let reusable =
1652            derived.retirement.reason != Some(KeepReason::Unknown) && !derived.host.failed();
1653        if let (Some((store, key)), true) = (&recorded, reusable) {
1654            store.write(key, &derived);
1655        }
1656        (derived, Derivation::Derived)
1657    }
1658
1659    /// Everything a branch's derivation reads, or `None` where one of those inputs
1660    /// could not be read — which is never an unchanged input.
1661    fn key(
1662        &self,
1663        branch: &str,
1664        base: &str,
1665        base_tip: &str,
1666        copies: &Copies,
1667        ask: &Ask<'_>,
1668    ) -> Option<crate::verdict::Key> {
1669        if self.stream_listing == status::Listing::Partial {
1670            return None;
1671        }
1672        let session = self
1673            .session_of(branch)
1674            .map(|record| record.token.to_string());
1675        let records = status::recorded_digest(
1676            self.streams,
1677            &self.resolution.key,
1678            branch,
1679            session.as_deref(),
1680        )?;
1681        Some(crate::verdict::Key {
1682            identity: self.resolution.key.clone(),
1683            branch: branch.to_owned(),
1684            base: base.to_owned(),
1685            base_tip: Sha(base_tip.to_owned()),
1686            copies: copies
1687                .copies
1688                .iter()
1689                .map(|copy| {
1690                    let holder = self.holder(copy.at);
1691                    crate::verdict::KeyedCopy {
1692                        kind: holder.kind,
1693                        location: holder.location,
1694                        tip: Sha(copy.tip.clone()),
1695                    }
1696                })
1697                .collect(),
1698            unreadable: copies
1699                .unreadable
1700                .iter()
1701                .map(UnknownCause::unreadable)
1702                .collect(),
1703            records,
1704            session,
1705            landed_trailer: self.trailers.landed().to_owned(),
1706            asking: match ask.host {
1707                Some(_) => crate::verdict::Asking::Host,
1708                None => crate::verdict::Asking::Records,
1709            },
1710        })
1711    }
1712
1713    /// Whether a recorded retirement is about this branch as its copies now stand — the
1714    /// fields a key does not spell, held to what a derivation would write.
1715    fn describes(
1716        &self,
1717        retirement: &Retirement,
1718        branch: &str,
1719        base: &str,
1720        copies: &Copies,
1721    ) -> bool {
1722        retirement.identity == self.resolution.key
1723            && retirement.branch == branch
1724            && retirement.base == base
1725            && retirement.tip.0 == copies.first_tip()
1726            && retirement.holders == self.holders_of(copies)
1727    }
1728
1729    fn evidence(&self, branch: &str) -> Evidence {
1730        let session = self
1731            .session_of(branch)
1732            .map(|record| record.token.to_string());
1733        Evidence::of(self, branch, session.as_deref())
1734    }
1735
1736    /// Derive a branch's verdict: every copy judged against the base, the change
1737    /// request asked about where the records do not decide it, and the proofs.
1738    /// `replies` are the host's answers where this pass already asked it.
1739    fn derive(
1740        &self,
1741        branch: &str,
1742        base_tip: &str,
1743        copies: &Copies,
1744        ask: &Ask<'_>,
1745        replies: Option<&crate::verdict::HostAnswer>,
1746    ) -> crate::verdict::Verdict {
1747        let kept = |reason| Retirement::kept(self, branch, reason, copies);
1748        let unknown = |cause| Retirement::unknown(self, branch, cause, copies);
1749        let mut verdict = crate::verdict::Verdict {
1750            retirement: kept(KeepReason::Unknown),
1751            reached: crate::verdict::Reached::Early,
1752            consulted: false,
1753            host: crate::verdict::HostAnswer::not_asked(),
1754        };
1755        let mut evidence = self.evidence(branch);
1756        let mut judged = match self.judged(branch, base_tip, copies, &evidence, ask) {
1757            Ok(judged) => judged,
1758            Err(cause) => {
1759                verdict.retirement = unknown(cause);
1760                return verdict;
1761            }
1762        };
1763        if judged.iter().all(|(_, one)| one.at_base) {
1764            verdict.retirement = kept(KeepReason::IsBase);
1765            return verdict;
1766        }
1767        let (open, consulted, heard) =
1768            self.open_change(branch, &mut evidence, &judged, ask, replies);
1769        verdict.consulted = consulted;
1770        verdict.host = heard;
1771        match open {
1772            Ok(None) => {}
1773            Ok(Some(reason)) => {
1774                verdict.retirement = kept(reason);
1775                return verdict;
1776            }
1777            Err(cause) => {
1778                verdict.retirement = unknown(cause);
1779                return verdict;
1780            }
1781        }
1782        verdict.reached = crate::verdict::Reached::Concluded;
1783        // The change request may have been found merged since the copies were judged,
1784        // so they are judged again under what is now known.
1785        if evidence.changed {
1786            match self.judged(branch, base_tip, copies, &evidence, ask) {
1787                Ok(again) => judged = again,
1788                Err(cause) => {
1789                    verdict.retirement = unknown(cause);
1790                    return verdict;
1791                }
1792            }
1793        }
1794        verdict.retirement = match self.conclude(branch, base_tip, copies, &judged, ask) {
1795            Ok(concluded) => concluded,
1796            Err(cause) => unknown(cause),
1797        };
1798        verdict
1799    }
1800
1801    /// Every copy judged against the base's tip, or the read that failed where one of
1802    /// them could not be judged.
1803    fn judged(
1804        &self,
1805        branch: &str,
1806        base_tip: &str,
1807        copies: &Copies,
1808        evidence: &Evidence,
1809        ask: &Ask<'_>,
1810    ) -> std::result::Result<Vec<(Copy, Judged)>, UnknownCause> {
1811        let mut judged = Vec::new();
1812        for copy in &copies.copies {
1813            let Some(repo) = self.readable(copy, branch, ask) else {
1814                return Err(UnknownCause::new(
1815                    "judge",
1816                    self.holder(copy.at).location,
1817                    format!("no repository on this host has its commit {}", copy.tip),
1818                ));
1819            };
1820            let one = self
1821                .judge_copy(self.asked(&repo), &copy.tip, base_tip, evidence)
1822                .map_err(|failure| UnknownCause::new("judge", repo.display(), failure))?;
1823            judged.push((copy.clone(), one));
1824        }
1825        Ok(judged)
1826    }
1827
1828    /// The class the judged copies add up to.
1829    fn conclude(
1830        &self,
1831        branch: &str,
1832        base_tip: &str,
1833        copies: &Copies,
1834        judged: &[(Copy, Judged)],
1835        ask: &Ask<'_>,
1836    ) -> std::result::Result<Retirement, UnknownCause> {
1837        let kept = |reason| Retirement::kept(self, branch, reason, copies);
1838        let mut free: Vec<String> = Vec::new();
1839        let mut differing: BTreeSet<String> = BTreeSet::new();
1840        let mut proof: Option<RetirementProof> = None;
1841        let mut uncovered: Option<(PathBuf, String, Option<String>)> = None;
1842        for (copy, one) in judged {
1843            for commit in &one.content_free {
1844                if !free.contains(commit) {
1845                    free.push(commit.clone());
1846                }
1847            }
1848            if one.at_base {
1849                continue;
1850            }
1851            match &one.proof {
1852                Some(found) => {
1853                    proof.get_or_insert_with(|| found.clone());
1854                }
1855                None => {
1856                    differing.extend(one.differing.iter().cloned());
1857                    if uncovered.is_none() {
1858                        let repo = self.readable(copy, branch, ask).unwrap_or_default();
1859                        uncovered = Some((repo, copy.tip.clone(), one.fork.clone()));
1860                    }
1861                }
1862            }
1863        }
1864        let mut retirement = kept(KeepReason::UnmergedUniqueCommits);
1865        retirement.content_free_commits = free.into_iter().map(Sha).collect();
1866        let Some((repo, tip, fork)) = uncovered else {
1867            retirement.class = RetirementClass::Retirable;
1868            retirement.reason = None;
1869            retirement.proof = proof;
1870            // A proof that answered for a copy at the base alone is still content that
1871            // is identical, since a copy the base carries changed nothing.
1872            if retirement.proof.is_none() {
1873                retirement.proof = Some(RetirementProof::ContentIdentical {
1874                    base_commit: Sha(base_tip.to_owned()),
1875                });
1876            }
1877            return Ok(retirement);
1878        };
1879        retirement.differing_paths = differing.into_iter().collect();
1880        // llmlint: ignore[changed_behavior_has_e2e] uncovered: this read is of a repository
1881        // whose history the judge has just read whole, against the same base tip, inside one
1882        // invocation — nothing a host does to its files fails the second read and not the
1883        // first. The cause it records is built as the six reads `retire::unknown_causes`
1884        // and `tests/e2e/retire.rs` drive are.
1885        let superseded = self
1886            .superseded(self.asked(&repo), branch, base_tip, &tip, fork.as_deref())
1887            .map_err(|failure| UnknownCause::new("supersession", repo.display(), failure))?;
1888        if let Some(by) = superseded {
1889            retirement.class = RetirementClass::SupersededWithChanges;
1890            retirement.reason = None;
1891            retirement.superseded_by = Some(by);
1892        }
1893        Ok(retirement)
1894    }
1895
1896    /// A repository holding one copy's tip, to ask about it: the copy's own for a
1897    /// local one, and for the origin's the first local repository that has its commit
1898    /// — or, where a classification may fetch, the publication checkout after
1899    /// fetching it.
1900    fn readable(&self, copy: &Copy, branch: &str, ask: &Ask<'_>) -> Option<PathBuf> {
1901        if let Holding::Local(index) = copy.at {
1902            return Some(self.places[index].repo.clone());
1903        }
1904        let wanted = Sha(copy.tip.clone());
1905        // A local copy at the same commit first, which asks git nothing; then the
1906        // publication checkout, which a fetch of the origin fills; then every other.
1907        let local_twin = self.places.iter().enumerate().find(|(index, _)| {
1908            matches!(self.local_tip(*index, branch, Tips::Listed), LocalTip::At(tip) if tip.as_str() == copy.tip)
1909        });
1910        if let Some((_, place)) = local_twin {
1911            return Some(place.repo.clone());
1912        }
1913        let found = self
1914            .places
1915            .iter()
1916            .find(|place| place.repo.exists() && git::has_commit(self.asked(&place.repo), &wanted))
1917            .map(|place| place.repo.clone());
1918        if found.is_some() || !ask.fetch_objects {
1919            return found;
1920        }
1921        let publication = self.publication().to_path_buf();
1922        match git::fetch_objects_of(&publication, "origin", branch) {
1923            Ok(true) if git::has_commit(self.asked(&publication), &wanted) => Some(publication),
1924            _ => None,
1925        }
1926    }
1927
1928    /// What one copy's tip is, against the base's tip.
1929    fn judge_copy(
1930        &self,
1931        asked: git::Asked<'_>,
1932        tip: &str,
1933        base_tip: &str,
1934        evidence: &Evidence,
1935    ) -> Result<Judged> {
1936        // The fork point answers both questions: a tip the base already carries is its
1937        // own merge base with the base.
1938        let fork = git::merge_base(asked, base_tip, tip)?;
1939        if fork.as_deref() == Some(tip) {
1940            return Ok(Judged::at_base());
1941        }
1942        let (content_free, content_tip) =
1943            git::content_free_tail(asked, tip, fork.as_deref().unwrap_or(""))?;
1944        let history = match &fork {
1945            Some(fork) => git::log_messages(asked, fork, base_tip)?,
1946            None => Vec::new(),
1947        };
1948        let mut proof = None;
1949        // Merged only on the host's own word about this identity's own change request,
1950        // and only where the head it merged carries this copy's content: the head is
1951        // what landed, so a copy that went on after it holds work the merge never saw.
1952        if let Some(merged) = &evidence.merged {
1953            if git::known_to_reach(asked, &content_tip, merged.head.as_str())? {
1954                proof = Some(RetirementProof::MergedChangeRequest {
1955                    change_url: merged.change.clone(),
1956                    head: Sha(merged.head.as_str().to_owned()),
1957                });
1958            }
1959        }
1960        if proof.is_none() {
1961            proof = self.recorded_landing(asked, tip, base_tip, &history, evidence)?;
1962        }
1963        // The comparison of content last, and only where no record already proved the
1964        // branch: it is the expensive question, and a proof that held needs no paths.
1965        let mut differing = Vec::new();
1966        if proof.is_none() {
1967            differing = match &fork {
1968                Some(fork) => {
1969                    let paths = git::changed_paths(asked, fork, tip)?;
1970                    git::differing_among(asked, tip, base_tip, &paths)?
1971                }
1972                None => git::changed_paths(asked, base_tip, tip)?,
1973            };
1974            if differing.is_empty() {
1975                proof = Some(RetirementProof::ContentIdentical {
1976                    base_commit: Sha(base_tip.to_owned()),
1977                });
1978            }
1979        }
1980        Ok(Judged {
1981            at_base: false,
1982            fork,
1983            content_free,
1984            differing,
1985            proof,
1986        })
1987    }
1988
1989    /// The recorded landing that covers `tip`, where one does: a branch commit a
1990    /// landing on the base names — by the trailer a landing writes, on a base commit
1991    /// or on the commit this host recorded the landing at — which the tip descends
1992    /// from through nothing but commits that change no content.
1993    fn recorded_landing(
1994        &self,
1995        asked: git::Asked<'_>,
1996        tip: &str,
1997        base_tip: &str,
1998        history: &[git::CommitMessage],
1999        evidence: &Evidence,
2000    ) -> Result<Option<RetirementProof>> {
2001        let key = self.trailers.landed();
2002        let mut landed_points: Vec<ObjectId> = Vec::new();
2003        for commit in history {
2004            landed_points.extend(landed::trailer_values(&commit.message, key));
2005        }
2006        if let Some(landing) = evidence.landing.as_ref().map(|landing| &landing.commit) {
2007            if git::known_to_reach(asked, landing.as_str(), base_tip)? {
2008                if let Some(message) = git::commit_message(asked, landing.as_str())? {
2009                    landed_points.extend(landed::trailer_values(&message, key));
2010                }
2011            }
2012        }
2013        for point in landed_points {
2014            if !git::known_to_reach(asked, point.as_str(), tip)? {
2015                continue;
2016            }
2017            let mut after = git::commits_in(asked, &format!("{}..{tip}", point.as_str()))?;
2018            after.retain(|commit| commit != point.as_str());
2019            let mut all_free = true;
2020            for commit in &after {
2021                if !git::changes_no_content(asked, commit)? {
2022                    all_free = false;
2023                    break;
2024                }
2025            }
2026            if all_free {
2027                return Ok(Some(RetirementProof::RecordedLanding {
2028                    commit: Sha(point.as_str().to_owned()),
2029                }));
2030            }
2031        }
2032        Ok(None)
2033    }
2034
2035    /// Whether a change request opened from the branch keeps it, asked of the records
2036    /// first and of the host only where they do not decide it — and whether the host
2037    /// answered that it merged at a head carrying the branch, which is then evidence of
2038    /// its own. Answers the keep reason where there is one — or the host's read that
2039    /// failed — whether the host was consulted, and what it said.
2040    ///
2041    /// The records decide it only where a landing this host recorded from the host's
2042    /// own report names this change request and its head carries every copy. Nothing
2043    /// in the base's history stands in for that: a `(#N)` subject anybody could write
2044    /// never answers for the host.
2045    fn open_change(
2046        &self,
2047        branch: &str,
2048        evidence: &mut Evidence,
2049        judged: &[(Copy, Judged)],
2050        ask: &Ask<'_>,
2051        replies: Option<&crate::verdict::HostAnswer>,
2052    ) -> (
2053        std::result::Result<Option<KeepReason>, UnknownCause>,
2054        bool,
2055        crate::verdict::HostAnswer,
2056    ) {
2057        if evidence.change.is_none() {
2058            return (Ok(None), false, crate::verdict::HostAnswer::not_asked());
2059        }
2060        let covered = evidence.merged.is_some()
2061            && judged.iter().all(|(_, one)| {
2062                one.at_base
2063                    || matches!(one.proof, Some(RetirementProof::MergedChangeRequest { .. }))
2064            });
2065        if covered {
2066            return (Ok(None), false, crate::verdict::HostAnswer::not_asked());
2067        }
2068        let first = judged.first().map(|(copy, _)| copy);
2069        let head = first.map(|copy| copy.tip.clone()).unwrap_or_default();
2070        let repo = first.and_then(|copy| self.readable(copy, branch, ask));
2071        let (heard, open) = self.consult(branch, evidence, &head, repo.as_deref(), ask, replies);
2072        (open, true, heard)
2073    }
2074
2075    /// Put to the host what the derivation asks it about the branch's change request —
2076    /// or take the answers from `replies`, where this pass already answered them.
2077    /// Answers what was heard, and the keep reason it decides — or the read of the host
2078    /// that failed, which decides `unknown`.
2079    ///
2080    /// Only the change request this identity's own records opened, in this identity's
2081    /// own repository, is asked about, and only of that repository's host. Merged is
2082    /// asked first, in the one read that also says which head merged: a merge whose
2083    /// head carries the branch's content is evidence the copies are judged again under,
2084    /// and a pass that may record takes it up as a late merge; a merge whose head does
2085    /// not carry it is neither, and leaves the branch to the proofs. Not merged is then
2086    /// open or closed without merging, and either keeps the branch.
2087    fn consult(
2088        &self,
2089        branch: &str,
2090        evidence: &mut Evidence,
2091        head: &str,
2092        repo: Option<&Path>,
2093        ask: &Ask<'_>,
2094        replies: Option<&crate::verdict::HostAnswer>,
2095    ) -> (
2096        crate::verdict::HostAnswer,
2097        std::result::Result<Option<KeepReason>, UnknownCause>,
2098    ) {
2099        use crate::verdict::{ChangeState, HostAnswer, Reply};
2100        let mut heard = HostAnswer::not_asked();
2101        // What the host said where it could not be asked, for the cause it decides.
2102        let failure = std::cell::RefCell::new(String::new());
2103        let failed = |said: Error| *failure.borrow_mut() = said.to_string();
2104        let session = self
2105            .session_of(branch)
2106            .map(|record| record.token.to_string());
2107        let opened = status::opened_change(
2108            self.streams,
2109            &self.resolution.key,
2110            branch,
2111            session.as_deref(),
2112        );
2113        if ask.host.is_none() {
2114            return (heard, Ok(Some(KeepReason::OpenChangeRequest)));
2115        }
2116        let Some(opened) = opened else {
2117            return (heard, Ok(Some(KeepReason::OpenChangeRequest)));
2118        };
2119        let unanswered = |error: &str| UnknownCause::new("host-consult", &opened.url, error);
2120        let (Some(id), Some(target)) = (&opened.id, &opened.base) else {
2121            return (
2122                heard,
2123                Err(unanswered(
2124                    "its opening is recorded without the change request's id or base",
2125                )),
2126            );
2127        };
2128        let host = ask.host.map(|hosting| {
2129            crate::publish::change_host(&self.resolution.key)
2130                .and_then(|slug| hosting.for_repo(&slug))
2131        });
2132        let host = match host {
2133            Some(Ok(host)) => host,
2134            Some(Err(said)) => return (heard, Err(unanswered(&said.to_string()))),
2135            None => return (heard, Ok(Some(KeepReason::OpenChangeRequest))),
2136        };
2137        let change = change_request(&opened, id, target, head);
2138        heard.merged = match replies {
2139            Some(replied) if matches!(replied.merged, Reply::Answered(_)) => replied.merged.clone(),
2140            _ => match host.merged_head(&change) {
2141                Ok(merged) => Reply::Answered(merged),
2142                Err(said) => {
2143                    failed(said);
2144                    Reply::Failed
2145                }
2146            },
2147        };
2148        match &heard.merged {
2149            Reply::Answered(Some(merged)) => {
2150                let carried = repo.is_some_and(|repo| {
2151                    git::carries_content_of(self.asked(repo), &merged.head.0, head).unwrap_or(false)
2152                });
2153                if let (true, Some(repo)) = (carried && ask.reconcile, repo) {
2154                    let _ = crate::publish::record_late_merge(
2155                        self.registry,
2156                        &crate::publish::Watched {
2157                            identity: &self.resolution.key,
2158                            branch,
2159                            url: &opened.url,
2160                            id: &id.0,
2161                            base: target,
2162                            head,
2163                            repo,
2164                            stream: opened.stream.as_deref(),
2165                        },
2166                        merged,
2167                        crate::publish::late_merge_time(host.as_ref(), &change),
2168                    );
2169                }
2170                if let Some(merged_head) = ObjectId::parse(&merged.head.0) {
2171                    evidence.merged = Some(Merged {
2172                        change: opened.url.clone(),
2173                        head: merged_head,
2174                    });
2175                    evidence.changed = true;
2176                }
2177                // Merged is not open, whatever head it merged: the proofs decide the
2178                // branch from here, and a head that does not carry it proves nothing.
2179                return (heard, Ok(None));
2180            }
2181            Reply::Answered(None) => {}
2182            Reply::Failed => {
2183                let cause = unanswered(&format!("whether it merged: {}", failure.borrow()));
2184                return (heard, Err(cause));
2185            }
2186            Reply::NotAsked => {}
2187        }
2188        heard.open = match replies {
2189            Some(replied) if matches!(replied.open, Reply::Answered(_)) => replied.open.clone(),
2190            _ => match host.find_changes(branch, target) {
2191                Ok(open) => {
2192                    Reply::Answered(match open.iter().any(|found| found.url == opened.url) {
2193                        true => ChangeState::Open,
2194                        false => ChangeState::NotOpen,
2195                    })
2196                }
2197                Err(said) => {
2198                    failed(said);
2199                    Reply::Failed
2200                }
2201            },
2202        };
2203        let open = match heard.open {
2204            Reply::Answered(ChangeState::Open) => Ok(Some(KeepReason::OpenChangeRequest)),
2205            // Neither merged nor open is closed without merging: nothing the host did
2206            // landed it, so the branch is kept for whoever closed it to decide on.
2207            Reply::Answered(ChangeState::NotOpen) => Ok(Some(KeepReason::ClosedChangeRequest)),
2208            Reply::Failed => Err(unanswered(&format!(
2209                "whether it is still open: {}",
2210                failure.borrow()
2211            ))),
2212            Reply::NotAsked => Err(unanswered("whether it is still open was not asked")),
2213        };
2214        (heard, open)
2215    }
2216
2217    /// The supersession that makes a branch `superseded-with-changes`: the newest one
2218    /// recorded for it whose landing the base's origin tip reaches.
2219    fn superseded(
2220        &self,
2221        asked: git::Asked<'_>,
2222        branch: &str,
2223        base_tip: &str,
2224        tip: &str,
2225        fork: Option<&str>,
2226    ) -> Result<Option<SupersededBy>> {
2227        let recorded = status::supersessions(self.streams, &self.resolution.key, branch);
2228        for record in recorded.iter().rev() {
2229            let reached = match &record.landing {
2230                SupersedingLanding::Commit(commit) => {
2231                    git::known_to_reach(asked, commit.as_str(), base_tip)?
2232                }
2233                SupersedingLanding::Change(url) => {
2234                    let from = match fork {
2235                        Some(fork) => fork.to_owned(),
2236                        None => git::merge_base(asked, base_tip, tip)?.unwrap_or_default(),
2237                    };
2238                    let history = match from.is_empty() {
2239                        true => Vec::new(),
2240                        false => git::log_messages(asked, &from, base_tip)?,
2241                    };
2242                    landed::names_the_change(&history, url.as_str(), &record.superseded_by)
2243                        .is_some()
2244                }
2245            };
2246            if reached {
2247                return Ok(Some(SupersededBy {
2248                    branch: record.superseded_by.to_string(),
2249                    landing: record.landing.to_string(),
2250                    labels: record.labels.clone(),
2251                }));
2252            }
2253        }
2254        Ok(None)
2255    }
2256}
2257
2258/// Where the origin has the base now, read out of the one listing of its branches,
2259/// with its commit readable in the publication checkout — or `None` where either could
2260/// not be had, which leaves the base unknown.
2261fn remote_base(
2262    publication: &Path,
2263    base: &str,
2264    listed: Option<&std::result::Result<BTreeMap<String, ObjectId>, String>>,
2265) -> Option<String> {
2266    let tip = listed?.as_ref().ok()?.get(base)?;
2267    let wanted = Sha(tip.as_str().to_owned());
2268    if !git::has_commit(publication, &wanted) {
2269        git::fetch_objects_of(publication, "origin", base).ok()?;
2270    }
2271    git::has_commit(publication, &wanted).then(|| tip.as_str().to_owned())
2272}
2273
2274/// The worktrees of one repository and the branch each has checked out, for the reads
2275/// that ask whether somebody is working on a branch.
2276fn worktrees_of(repo: &Path) -> std::result::Result<Vec<(PathBuf, Option<String>)>, UnknownCause> {
2277    git::worktree_heads(repo)
2278        .map_err(|failure| UnknownCause::new("worked-in", repo.display(), failure))
2279}
2280
2281/// A classification, the copies it was made from, and whether its verdict was
2282/// derived or reused.
2283struct Classified {
2284    retirement: Retirement,
2285    copies: Copies,
2286    derivation: Derivation,
2287}
2288
2289/// What one copy's tip turned out to be.
2290struct Judged {
2291    at_base: bool,
2292    fork: Option<String>,
2293    content_free: Vec<String>,
2294    differing: Vec<String>,
2295    proof: Option<RetirementProof>,
2296}
2297
2298impl Judged {
2299    fn at_base() -> Self {
2300        Judged {
2301            at_base: true,
2302            fork: None,
2303            content_free: Vec::new(),
2304            differing: Vec::new(),
2305            proof: None,
2306        }
2307    }
2308}
2309
2310/// What this host's own records say about one branch.
2311struct Evidence {
2312    /// The change request opened from it, in this identity's own repository.
2313    change: Option<Url>,
2314    /// The landing recorded for it, which a landing trailer is read off.
2315    landing: Option<landed::RecordedLanding>,
2316    /// The merge the host reported for that change request, with the head it merged —
2317    /// from a landing recorded from such a report, or from asking the host now.
2318    merged: Option<Merged>,
2319    /// Whether the host was found to have merged the change during this
2320    /// classification, which is new evidence every copy has to be judged under.
2321    changed: bool,
2322}
2323
2324/// A merge the host reported for the branch's own change request.
2325struct Merged {
2326    change: Url,
2327    head: ObjectId,
2328}
2329
2330impl Evidence {
2331    fn of(census: &Census<'_>, branch: &str, session: Option<&str>) -> Self {
2332        let recorded =
2333            status::recorded_for(census.streams, &census.resolution.key, branch, session);
2334        // A recorded landing stands in for the host's answer only where it was written
2335        // from that answer: it carries the head the host merged, and names this very
2336        // change request. One with no head — every landing written before the head was —
2337        // proves nothing, and the host is asked instead.
2338        let merged = recorded.landing.as_ref().and_then(|landing| {
2339            let change = landing.change.clone()?;
2340            (Some(&change) == recorded.change.as_ref()).then_some(())?;
2341            Some(Merged {
2342                change,
2343                head: landing.head.clone()?,
2344            })
2345        });
2346        Evidence {
2347            change: recorded.change,
2348            landing: recorded.landing,
2349            merged,
2350            changed: false,
2351        }
2352    }
2353}
2354
2355fn change_request(
2356    opened: &status::OpenedChange,
2357    id: &ChangeId,
2358    base: &Ref,
2359    head: &str,
2360) -> ChangeRequest {
2361    ChangeRequest {
2362        id: id.clone(),
2363        url: opened.url.clone(),
2364        head_sha: Sha(head.to_owned()),
2365        base: base.to_string(),
2366    }
2367}
2368
2369/// The numbered directories under one directory, in number order.
2370fn numbered(directory: &Path, unlisted: &mut Vec<UnknownCause>) -> Vec<(PathBuf, u32)> {
2371    let mut found: Vec<(PathBuf, u32)> = listed(directory, unlisted)
2372        .into_iter()
2373        .filter_map(|(path, name)| {
2374            name.parse::<u32>()
2375                .ok()
2376                .filter(|number| *number > 0)
2377                .map(|number| (path, number))
2378        })
2379        .collect();
2380    found.sort_by_key(|(_, number)| *number);
2381    found
2382}
2383
2384/// The directories under one directory, by name. A directory that is not there holds
2385/// nothing; one that is there and cannot be listed, or an entry of it that cannot be
2386/// read, is said in `unlisted`, because what it holds is unknown rather than nothing.
2387fn listed(directory: &Path, unlisted: &mut Vec<UnknownCause>) -> Vec<(PathBuf, String)> {
2388    let entries = match std::fs::read_dir(directory) {
2389        Ok(entries) => entries,
2390        Err(failure) if failure.kind() == std::io::ErrorKind::NotFound => return Vec::new(),
2391        Err(failure) => {
2392            unlisted.push(UnknownCause::new("census", directory.display(), failure));
2393            return Vec::new();
2394        }
2395    };
2396    let mut found = Vec::new();
2397    for entry in entries {
2398        match entry {
2399            Ok(entry) if entry.path().is_dir() => found.push((
2400                entry.path(),
2401                entry.file_name().to_string_lossy().into_owned(),
2402            )),
2403            Ok(_) => {}
2404            Err(failure) => {
2405                unlisted.push(UnknownCause::new("census", directory.display(), failure))
2406            }
2407        }
2408    }
2409    found.sort();
2410    found
2411}
2412
2413/// Whether `identity` is spelled the way a registry keys one: `host/owner/name` for a
2414/// hosted origin, or an absolute path for a local one. A record read back from a
2415/// stream is matched against those keys, so one naming anything else names nothing.
2416fn is_identity_key(identity: &str) -> bool {
2417    if identity.trim() != identity || identity.chars().any(char::is_control) {
2418        return false;
2419    }
2420    let normalized = store::normalize(identity);
2421    match normalized.hosted {
2422        Some(_) => normalized.key == identity,
2423        None => Path::new(identity).is_absolute(),
2424    }
2425}
2426
2427/// A retry's landing, as a supersession records it.
2428#[derive(Debug, Clone, PartialEq, Eq)]
2429pub(crate) enum SupersedingLanding {
2430    Commit(ObjectId),
2431    Change(Url),
2432}
2433
2434impl SupersedingLanding {
2435    fn parse(value: &str) -> Option<Self> {
2436        if let Some(commit) = ObjectId::parse(value) {
2437            return Some(SupersedingLanding::Commit(commit));
2438        }
2439        Url::parse(value)
2440            .ok()
2441            .filter(|url| matches!(url.scheme(), "http" | "https"))
2442            .map(SupersedingLanding::Change)
2443    }
2444}
2445
2446impl std::fmt::Display for SupersedingLanding {
2447    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2448        match self {
2449            SupersedingLanding::Commit(commit) => f.write_str(commit.as_str()),
2450            SupersedingLanding::Change(url) => f.write_str(url.as_str()),
2451        }
2452    }
2453}
2454
2455/// One `branch-superseded` record, read back.
2456#[derive(Debug, Clone, PartialEq, Eq)]
2457pub(crate) struct SupersessionRecord {
2458    identity: String,
2459    branch: Ref,
2460    superseded_by: Ref,
2461    landing: SupersedingLanding,
2462    labels: BTreeMap<String, String>,
2463}
2464
2465impl SupersessionRecord {
2466    /// A payload read back, where every field is what its kind has to be; a stream is
2467    /// a file whichever process wrote it, and each of these goes on to git or onto a
2468    /// line an operator reads.
2469    pub(crate) fn read(payload: &Map<String, Value>) -> Option<Self> {
2470        let text = |name: &str| payload.get(name).and_then(Value::as_str).map(str::to_owned);
2471        let labels: BTreeMap<String, String> = match payload.get("labels") {
2472            Some(Value::Object(map)) => map
2473                .iter()
2474                .map(|(key, value)| Some((key.clone(), value.as_str()?.to_owned())))
2475                .collect::<Option<_>>()?,
2476            None | Some(Value::Null) => BTreeMap::new(),
2477            Some(_) => return None,
2478        };
2479        label::validate(&labels).ok()?;
2480        Some(SupersessionRecord {
2481            identity: text("identity").filter(|identity| is_identity_key(identity))?,
2482            branch: Ref::try_from(text("branch")?).ok()?,
2483            superseded_by: Ref::try_from(text("superseded_by")?).ok()?,
2484            landing: SupersedingLanding::parse(&text("landing")?)?,
2485            labels,
2486        })
2487    }
2488
2489    pub(crate) fn names(&self, identity: &str, branch: &str) -> bool {
2490        self.identity == identity && *self.branch == *branch
2491    }
2492
2493    pub(crate) fn branch(&self) -> &str {
2494        &self.branch
2495    }
2496}
2497
2498/// The payload a `branch-retired` event carries, which is also what it is read back
2499/// as.
2500// llmlint: ignore-block[invalid_states_unrepresentable] the amendment fixes this payload
2501// field for field, and a stream is a file whichever process wrote it — so what is typed is
2502// what a reader routes on (the class, reason, proof, mode, trigger and tip), and the rest is
2503// checked where a record is read: `RetiredRecord::read` refuses a branch git would not
2504// accept, a tip that is not a commit id, fields that contradict each other, and an account
2505// of what was done that this crate would not have written (`accounts_plainly`).
2506#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
2507pub(crate) struct RetiredPayload {
2508    identity: String,
2509    branch: String,
2510    tip: Sha,
2511    class: RetirementClass,
2512    reason: Option<KeepReason>,
2513    proof: Option<RetirementProof>,
2514    superseded_by: Option<SupersededBy>,
2515    differing_paths: Vec<String>,
2516    mode: Acting,
2517    trigger: Trigger,
2518    deleted: Vec<BranchHolder>,
2519    failed: Vec<FailedHolder>,
2520    slots_returned: Vec<PathBuf>,
2521    run_roots_removed: Vec<PathBuf>,
2522    sessions_closed: Vec<String>,
2523}
2524// llmlint: ignore-end[invalid_states_unrepresentable]
2525
2526impl RetiredPayload {
2527    /// Whether what the record says it did is spelled the way this crate writes it: every
2528    /// place a path this host could have held — or, for the origin, a location on one line
2529    /// — every session a token, and every path and error something. A record that says
2530    /// otherwise is one nothing here wrote, and its account is not repeated to anybody.
2531    fn accounts_plainly(&self) -> bool {
2532        let one_line = |text: &str| !text.is_empty() && !text.chars().any(char::is_control);
2533        let place = |kind: BranchHolderKind, location: &str| {
2534            one_line(location)
2535                && (kind == BranchHolderKind::Origin || Path::new(location).is_absolute())
2536        };
2537        let path = |path: &PathBuf| path.to_str().is_some_and(one_line) && path.is_absolute();
2538        self.deleted
2539            .iter()
2540            .all(|holder| place(holder.kind, &holder.location))
2541            && self.failed.iter().all(|holder| {
2542                place(holder.kind, &holder.location) && !holder.error.trim().is_empty()
2543            })
2544            && self.slots_returned.iter().all(path)
2545            && self.run_roots_removed.iter().all(path)
2546            && self
2547                .sessions_closed
2548                .iter()
2549                .all(|token| ids::is_safe_name(token))
2550            && self.differing_paths.iter().all(|path| !path.is_empty())
2551            && self.evidence_is_plain()
2552    }
2553
2554    /// Whether the evidence the record names is evidence this crate could have
2555    /// written: every commit a proof names a commit id, and a supersession naming a
2556    /// branch git accepts, a landing that is a commit or a change request, and labels
2557    /// a caller could have recorded — the same checks `SupersessionRecord::read`
2558    /// makes of the record it came from.
2559    fn evidence_is_plain(&self) -> bool {
2560        let commit = |sha: &Sha| ObjectId::parse(&sha.0).is_some();
2561        let proof = match &self.proof {
2562            None => true,
2563            Some(RetirementProof::MergedChangeRequest { head, .. }) => commit(head),
2564            Some(RetirementProof::RecordedLanding { commit: at }) => commit(at),
2565            Some(RetirementProof::ContentIdentical { base_commit }) => commit(base_commit),
2566        };
2567        let superseded = self.superseded_by.as_ref().is_none_or(|by| {
2568            Ref::try_from(by.branch.clone()).is_ok()
2569                && SupersedingLanding::parse(&by.landing).is_some()
2570                && label::validate(&by.labels).is_ok()
2571        });
2572        proof && superseded
2573    }
2574}
2575
2576/// One `branch-retired` record, read back with the moment it was written.
2577#[derive(Debug, Clone, PartialEq, Eq)]
2578pub(crate) struct RetiredRecord {
2579    payload: RetiredPayload,
2580    at: String,
2581}
2582
2583impl RetiredRecord {
2584    pub(crate) fn read(payload: &Map<String, Value>, at: &status::Stamp) -> Option<Self> {
2585        let payload: RetiredPayload =
2586            serde_json::from_value(Value::Object(payload.clone())).ok()?;
2587        Ref::try_from(payload.branch.clone()).ok()?;
2588        ObjectId::parse(&payload.tip.0)?;
2589        if !is_identity_key(&payload.identity) || !payload.accounts_plainly() {
2590            return None;
2591        }
2592        // A retirement acts only on a class its mode permits, and the class decides
2593        // which of the reason, the proof and the supersession it carries — so a record
2594        // whose fields contradict each other is one nothing wrote, and is no record.
2595        let consistent = payload.mode.permits(payload.class, payload.reason)
2596            && (payload.class == RetirementClass::Keep) == payload.reason.is_some()
2597            && (payload.class == RetirementClass::Retirable) == payload.proof.is_some()
2598            && (payload.class == RetirementClass::SupersededWithChanges)
2599                == payload.superseded_by.is_some();
2600        if !consistent {
2601            return None;
2602        }
2603        Some(RetiredRecord {
2604            payload,
2605            at: String::from(at.clone()),
2606        })
2607    }
2608
2609    pub(crate) fn names(&self, identity: &str, branch: &str) -> bool {
2610        self.payload.identity == identity && self.payload.branch == branch
2611    }
2612
2613    pub(crate) fn identity(&self) -> &str {
2614        &self.payload.identity
2615    }
2616
2617    pub(crate) fn branch(&self) -> &str {
2618        &self.payload.branch
2619    }
2620
2621    pub(crate) fn class(&self) -> RetirementClass {
2622        self.payload.class
2623    }
2624
2625    pub(crate) fn proof(&self) -> Option<&RetirementProof> {
2626        self.payload.proof.as_ref()
2627    }
2628
2629    pub(crate) fn tip(&self) -> &Sha {
2630        &self.payload.tip
2631    }
2632
2633    pub(crate) fn mode(&self) -> Acting {
2634        self.payload.mode
2635    }
2636
2637    pub(crate) fn trigger(&self) -> Trigger {
2638        self.payload.trigger
2639    }
2640
2641    pub(crate) fn at(&self) -> &str {
2642        &self.at
2643    }
2644
2645    /// The classification the retirement acted on, as a caller asking about a branch
2646    /// nothing holds any more is answered with.
2647    fn retirement(&self, base: &str) -> Retirement {
2648        Retirement {
2649            class: self.payload.class,
2650            reason: self.payload.reason,
2651            cause: None,
2652            identity: self.payload.identity.clone(),
2653            branch: self.payload.branch.clone(),
2654            tip: self.payload.tip.clone(),
2655            base: base.to_owned(),
2656            proof: self.payload.proof.clone(),
2657            content_free_commits: Vec::new(),
2658            superseded_by: self.payload.superseded_by.clone(),
2659            differing_paths: self.payload.differing_paths.clone(),
2660            holders: Vec::new(),
2661        }
2662    }
2663}
2664
2665/// The stream one identity's records about one branch are written to.
2666///
2667/// Its own rather than a session's: a branch outlives every session that worked on it,
2668/// and what became of it afterwards is not any one of theirs. The identity's digest is
2669/// in the name because one branch name can belong to two identities.
2670fn stream_token(identity: &str, branch: &str) -> String {
2671    format!(
2672        "branch-{}-{}",
2673        policy::branch_slug(branch),
2674        ids::short_digest(identity)
2675    )
2676}
2677
2678fn open_stream(identity: &str, branch: &str) -> Result<Stream> {
2679    let mut stream = Stream::open(&stream_token(identity, branch))?;
2680    stream.label("identity", identity);
2681    Ok(stream)
2682}
2683
2684/// Everything read once for a whole operation.
2685struct Host {
2686    registry: Registry,
2687    sessions: Vec<Record>,
2688    streams: Vec<status::Recorded>,
2689    /// Whether the streams directory was listed whole.
2690    stream_listing: status::Listing,
2691    trailers: Trailers,
2692}
2693
2694impl Host {
2695    fn read() -> Result<Self> {
2696        Self::with(None)
2697    }
2698
2699    /// The same, over a listing of the session records a caller already made.
2700    fn with(sessions: Option<Vec<Record>>) -> Result<Self> {
2701        let registry = store::load()?;
2702        let (rules, _) = crate::policy::load(&registry)?;
2703        let sessions = match sessions {
2704            Some(listed) => listed,
2705            None => workspace::all()?,
2706        };
2707        let (streams, stream_listing) = status::recorded_streams_whole(&mut Vec::new())?;
2708        Ok(Host {
2709            sessions,
2710            streams,
2711            stream_listing,
2712            trailers: provenance::from_rules(&rules),
2713            registry,
2714        })
2715    }
2716
2717    fn census(&self, identity: &str, reach: Reach) -> Result<Census<'_>> {
2718        let mut census = Census::read(
2719            &self.registry,
2720            identity,
2721            &self.sessions,
2722            &self.streams,
2723            &self.trailers,
2724            reach,
2725            None,
2726        )?;
2727        census.stream_listing = self.stream_listing;
2728        Ok(census)
2729    }
2730
2731    /// Every identity with a registered checkout, in key order.
2732    fn identities(&self) -> Vec<String> {
2733        let mut keys: Vec<String> = self
2734            .registry
2735            .checkouts
2736            .values()
2737            .map(|checkout| checkout.identity.clone())
2738            .collect();
2739        keys.sort_unstable();
2740        keys.dedup();
2741        keys
2742    }
2743
2744    /// The identity a branch belongs to: the one `repo` names, or — with none — the
2745    /// one identity anything on this host holds it in.
2746    fn identity_of(&self, repo: Option<&str>, branch: &str) -> Result<String> {
2747        if !git::is_valid_branch_name(branch) {
2748            return Err(error::invalid(format!(
2749                "{branch:?} is not a valid branch name; `onevcs recoverable --all` lists every \
2750                 preserved branch by name"
2751            )));
2752        }
2753        if let Some(repo) = repo {
2754            return Ok(store::resolve(&self.registry, repo)?.key);
2755        }
2756        let mut holding = Vec::new();
2757        for identity in self.identities() {
2758            let census = self.census(&identity, Reach::Offline)?;
2759            let held = !census.copies(branch, &Ask::offline()).copies.is_empty()
2760                || status::retirement_of(&self.streams, &identity, branch).is_some();
2761            if held {
2762                holding.push(identity);
2763            }
2764        }
2765        match holding.len() {
2766            1 => Ok(holding.remove(0)),
2767            0 => Err(Error::UnresolvableReference {
2768                reference: branch.to_owned(),
2769                reason: format!(
2770                    "no checkout, pool slot or run clone of a registered identity holds \
2771                     {branch:?}, and no retirement of it is recorded; `onevcs recoverable --all` \
2772                     lists every preserved branch by name"
2773                ),
2774            }),
2775            _ => Err(error::invalid(format!(
2776                "{branch:?} is held by {count} identities — {named} — so which one is meant \
2777                 cannot be decided; name it with `--repo`",
2778                count = holding.len(),
2779                named = holding.join(", "),
2780            ))),
2781        }
2782    }
2783}
2784
2785/// Classify one branch.
2786pub(crate) fn classify(hosting: &dyn Hosting, query: &RetirementQuery) -> Result<Retirement> {
2787    let host = Host::read()?;
2788    let identity = host.identity_of(query.repo.as_deref(), &query.branch)?;
2789    let census = host.census(&identity, Reach::Remote)?;
2790    let ask = Ask::query(hosting);
2791    let classified = census.classify(&query.branch, census.copies(&query.branch, &ask), &ask)?;
2792    if !classified.copies.copies.is_empty() || !classified.copies.unreadable.is_empty() {
2793        return Ok(classified.retirement);
2794    }
2795    match status::retirement_of(&host.streams, &identity, &query.branch) {
2796        Some(record) => Ok(record.retirement(census.base.as_deref().unwrap_or_default())),
2797        None => Err(nowhere(&identity, &query.branch)),
2798    }
2799}
2800
2801fn nowhere(identity: &str, branch: &str) -> Error {
2802    Error::UnresolvableReference {
2803        reference: branch.to_owned(),
2804        reason: format!(
2805            "nothing this host keeps for {identity} holds {branch:?} — no registered checkout, \
2806             pool slot, run clone or the origin — and no retirement of it is recorded"
2807        ),
2808    }
2809}
2810
2811/// The classification `recoverable` reports on each row, made from records and
2812/// local refs alone.
2813pub(crate) fn classify_offline(census: &Census<'_>, branch: &str) -> Option<Retirement> {
2814    let ask = Ask::offline();
2815    census
2816        .classify(branch, census.copies(branch, &ask), &ask)
2817        .ok()
2818        .map(|classified| classified.retirement)
2819}
2820
2821/// The offline census sweep judges one identity's records against: the same read
2822/// recovery makes, made once per identity for a whole pass rather than once per record.
2823pub(crate) fn offline_census<'a>(
2824    registry: &'a Registry,
2825    identity: &str,
2826    sessions: &'a [Record],
2827    streams: &'a [status::Recorded],
2828    trailers: &'a Trailers,
2829) -> Result<Census<'a>> {
2830    Census::read(
2831        registry,
2832        identity,
2833        sessions,
2834        streams,
2835        trailers,
2836        Reach::Offline,
2837        None,
2838    )
2839}
2840
2841/// Sweep asks the same offline proof as recovery. A recorded preservation is
2842/// still work to judge when its local refs are gone; absence is not a landing.
2843pub(crate) fn record_has_work(census: &Census<'_>, record: &Record) -> Result<bool> {
2844    let streams = census.streams;
2845    let ask = Ask::offline();
2846    let mut copies = census.copies(&record.branch, &ask);
2847    if copies.copies.is_empty() && copies.unreadable.is_empty() {
2848        let Some(preserved) = status::preserved_for(
2849            streams,
2850            &record.identity,
2851            &record.branch,
2852            Some(&record.token),
2853        ) else {
2854            return Ok(false);
2855        };
2856        copies.copies.push(Copy {
2857            at: Holding::Origin,
2858            tip: preserved.commit,
2859        });
2860    }
2861    // `is-base` is the proof that every copy stands at or behind the base: nothing
2862    // beyond it to keep. A session never names its base as its branch, so that
2863    // answer here comes from the tips rather than from the name.
2864    let retirement = census.classify(&record.branch, copies, &ask)?.retirement;
2865    Ok(retirement.class != RetirementClass::Retirable
2866        && retirement.reason != Some(KeepReason::IsBase))
2867}
2868
2869/// Retire one branch by name.
2870pub(crate) fn retire_named(hosting: &dyn Hosting, request: &RetireRequest) -> Result<Retired> {
2871    let host = Host::read()?;
2872    let identity = host.identity_of(request.repo.as_deref(), &request.branch)?;
2873    let census = host.census(&identity, reach_for(request.dry_run))?;
2874    let ask = Ask::acting(Some(hosting), request.dry_run, &[]);
2875    let acting = match request.mode {
2876        RetireMode::Lossless => Acting::Retire,
2877        RetireMode::Reclaim => Acting::Reclaim,
2878        RetireMode::Discard => Acting::Discard,
2879    };
2880    let copies = census.copies(&request.branch, &ask);
2881    if copies.copies.is_empty() && copies.unreadable.is_empty() {
2882        let Some(record) = status::retirement_of(&host.streams, &identity, &request.branch) else {
2883            return Err(nowhere(&identity, &request.branch));
2884        };
2885        let classified = Classified {
2886            retirement: record.retirement(census.base.as_deref().unwrap_or_default()),
2887            copies,
2888            derivation: Derivation::Derived,
2889        };
2890        // Every copy is gone, and what a retirement stopped part way can still have left
2891        // is beside them: a run root it could not remove, or a session record it could
2892        // not close. Those hold no copy of the branch, so the re-run finishes them.
2893        let plan = census.plan(&request.branch, &classified);
2894        let unfinished = !plan.run_roots.is_empty()
2895            || plan
2896                .sessions
2897                .iter()
2898                .any(|record| record.state == Lifecycle::Open);
2899        return Ok(match (unfinished, request.dry_run) {
2900            (false, _) => Retired::nothing(classified.retirement, RetireOutcome::AlreadyRetired),
2901            (true, true) => Retired {
2902                retirement: classified.retirement,
2903                outcome: RetireOutcome::WouldRetire,
2904                deleted: Vec::new(),
2905                failed: Vec::new(),
2906                slots_returned: Vec::new(),
2907                run_roots_removed: plan.run_roots,
2908                sessions_closed: plan
2909                    .sessions
2910                    .iter()
2911                    .filter(|record| record.state == Lifecycle::Open)
2912                    .map(session_token)
2913                    .collect(),
2914                derivation: Derivation::Derived,
2915            },
2916            (true, false) => census.finish(
2917                &request.branch,
2918                classified,
2919                plan,
2920                Done {
2921                    deleted: Vec::new(),
2922                    failed: Vec::new(),
2923                },
2924                (acting, Trigger::Verb),
2925            ),
2926        });
2927    }
2928    act(
2929        &census,
2930        &request.branch,
2931        copies,
2932        acting,
2933        Trigger::Verb,
2934        request.dry_run,
2935        &ask,
2936    )
2937}
2938
2939/// How far an operation that may be a dry run reaches: a rehearsal moves no ref, and
2940/// still judges the base where the origin has it now.
2941fn reach_for(dry_run: bool) -> Reach {
2942    match dry_run {
2943        true => Reach::Remote,
2944        false => Reach::Fetch,
2945    }
2946}
2947
2948/// The automatic pass.
2949pub(crate) fn pass(
2950    hosting: Option<&dyn Hosting>,
2951    request: &RetirePass,
2952    trigger: Trigger,
2953) -> Result<RetirementPassReport> {
2954    pass_over(hosting, request, trigger, None)
2955}
2956
2957/// The automatic pass over a listing of the session records the caller already made,
2958/// or over a fresh one.
2959pub(crate) fn pass_over(
2960    hosting: Option<&dyn Hosting>,
2961    request: &RetirePass,
2962    trigger: Trigger,
2963    sessions: Option<Vec<Record>>,
2964) -> Result<RetirementPassReport> {
2965    // Names a caller supplied, refused here where they arrive rather than compared
2966    // against as though they were branches: a pair that names no branch excludes
2967    // nothing, and a caller who meant to protect one would never learn it was not.
2968    for excluded in &request.exclude {
2969        if excluded.identity.trim().is_empty() || !git::is_valid_branch_name(&excluded.branch) {
2970            return Err(error::invalid(format!(
2971                "{branch:?} of {identity:?} is not a branch a pass can leave alone: an \
2972                 exclusion names an identity and a valid branch name",
2973                branch = excluded.branch,
2974                identity = excluded.identity,
2975            )));
2976        }
2977    }
2978    let host = Host::with(sessions)?;
2979    // …and each identity it names resolved the way a `--repo` is, so an exclusion that
2980    // names no registered identity is refused rather than protecting nothing.
2981    let exclude = request
2982        .exclude
2983        .iter()
2984        .map(|excluded| {
2985            let resolved = store::resolve(&host.registry, &excluded.identity).map_err(|why| {
2986                error::invalid(format!(
2987                    "{branch:?} of {identity:?} is not a branch a pass can leave alone: the \
2988                     exclusion names no registered identity ({why}); `onevcs repos` lists them",
2989                    branch = excluded.branch,
2990                    identity = excluded.identity,
2991                ))
2992            })?;
2993            Ok(BranchRef {
2994                identity: resolved.key,
2995                branch: excluded.branch.clone(),
2996            })
2997        })
2998        .collect::<Result<Vec<_>>>()?;
2999    let identities = match &request.scope {
3000        Scope::All => host.identities(),
3001        Scope::Repo(repo) => vec![store::resolve(&host.registry, repo)?.key],
3002    };
3003    let verdicts = crate::verdict::Store::open()?;
3004    let mut ask = Ask::acting(hosting, request.dry_run, &exclude);
3005    ask.verdicts = Some(&verdicts);
3006    let mut report = RetirementPassReport {
3007        dry_run: request.dry_run,
3008        examined: Vec::new(),
3009    };
3010    // One scope for the whole pass, so no existence or ancestry question about one
3011    // repository's objects is asked twice across every branch it examines.
3012    git::memoized(|| -> Result<()> {
3013        for identity in identities {
3014            let census = host.census(&identity, reach_for(request.dry_run))?;
3015            for branch in candidates(&census, &host)? {
3016                let copies = census.copies(&branch, &ask);
3017                if copies.copies.is_empty() && copies.unreadable.is_empty() {
3018                    verdicts.forget(&identity, &branch);
3019                    continue;
3020                }
3021                report.examined.push(act(
3022                    &census,
3023                    &branch,
3024                    copies,
3025                    Acting::Automatic,
3026                    trigger,
3027                    request.dry_run,
3028                    &ask,
3029                )?);
3030            }
3031        }
3032        Ok(())
3033    })?;
3034    verdicts.report();
3035    Ok(report)
3036}
3037
3038/// The branches a pass examines for one identity: every local branch of a place it
3039/// keeps work in that this host's own records name — a session record, a stream, a
3040/// supersession — or that holds commits no origin ref has, which is what
3041/// `recoverable` lists. The base is never one of them, and neither is a branch only
3042/// the origin holds: a pass acts host-wide, and a branch nothing here ever cut is
3043/// somebody else's.
3044fn candidates(census: &Census<'_>, host: &Host) -> Result<Vec<String>> {
3045    let mut named: BTreeSet<String> = host
3046        .sessions
3047        .iter()
3048        .filter(|record| record.identity == census.resolution.key)
3049        .map(|record| record.branch.to_string())
3050        .collect();
3051    named.extend(status::recorded_branches(
3052        &host.streams,
3053        &census.resolution.key,
3054    ));
3055    let mut found: BTreeSet<String> = BTreeSet::new();
3056    for place in &census.places {
3057        if !git::is_repo(&place.repo) {
3058            continue;
3059        }
3060        let Ok(heads) = git::heads(&place.repo) else {
3061            continue;
3062        };
3063        // Whether a branch no record names holds commits no origin ref has, asked of
3064        // the whole place at once — and not at all where the records name every one
3065        // but the base, which is never a candidate however much it holds.
3066        let unpublished: BTreeSet<String> = match heads.iter().all(|(branch, _)| {
3067            named.contains(branch) || census.base.as_deref() == Some(branch.as_str())
3068        }) {
3069            true => BTreeSet::new(),
3070            false => git::unpublished_heads(&place.repo).unwrap_or_default(),
3071        };
3072        for (branch, _) in heads {
3073            if named.contains(&branch) || unpublished.contains(&branch) {
3074                found.insert(branch);
3075            }
3076        }
3077    }
3078    if let Some(base) = &census.base {
3079        found.remove(base);
3080    }
3081    Ok(found.into_iter().collect())
3082}
3083
3084/// Whether a retirement of this session's branch is recorded.
3085pub(crate) fn retired(record: &Record) -> bool {
3086    status::recorded_streams(&mut Vec::new())
3087        .map(|streams| status::retirement_of(&streams, &record.identity, &record.branch).is_some())
3088        .unwrap_or(false)
3089}
3090
3091/// Retire the branch a session that just closed worked on, where its landing is
3092/// recorded and it is retirable. Best effort: the close has happened, and a
3093/// retirement that could not run is a warning rather than a failed close.
3094pub(crate) fn after_close(record: &Record) {
3095    let retired = (|| -> Result<Option<Retired>> {
3096        let host = Host::read()?;
3097        // Only a branch whose landing this host recorded is asked about at all, so an
3098        // ordinary close — work nobody has landed — fetches nothing and decides nothing.
3099        let recorded = status::recorded_for(
3100            &host.streams,
3101            &record.identity,
3102            &record.branch,
3103            Some(&record.token),
3104        );
3105        if recorded.landing.is_none() {
3106            return Ok(None);
3107        }
3108        let census = host.census(&record.identity, Reach::Fetch)?;
3109        let ask = Ask::acting(None, false, &[]);
3110        let copies = census.copies(&record.branch, &ask);
3111        let classified = census.classify(&record.branch, copies.clone(), &ask)?;
3112        if classified.retirement.class != RetirementClass::Retirable {
3113            return Ok(None);
3114        }
3115        act(
3116            &census,
3117            &record.branch,
3118            copies,
3119            Acting::Automatic,
3120            Trigger::SessionClose,
3121            false,
3122            &ask,
3123        )
3124        .map(Some)
3125    })();
3126    match retired {
3127        Ok(Some(retired)) if retired.outcome == RetireOutcome::Retired => eprintln!(
3128            "onevcs: retired {branch}: {proof}",
3129            branch = record.branch,
3130            proof = retired
3131                .retirement
3132                .proof
3133                .as_ref()
3134                .map(RetirementProof::describe)
3135                .unwrap_or_default(),
3136        ),
3137        Ok(Some(retired)) if retired.outcome == RetireOutcome::Incomplete => eprintln!(
3138            "onevcs: warning: {branch} was retired in part; `onevcs retire {branch} --repo {repo}` \
3139             finishes it: {failed}",
3140            branch = record.branch,
3141            repo = record.publication_checkout.display(),
3142            failed = retired
3143                .failed
3144                .iter()
3145                .map(|failed| format!("{} ({})", failed.location, failed.error))
3146                .collect::<Vec<_>>()
3147                .join("; "),
3148        ),
3149        Ok(_) => {}
3150        Err(failure) => eprintln!(
3151            "onevcs: warning: whether {branch} could be retired was not decided: {failure}",
3152            branch = record.branch,
3153        ),
3154    }
3155}
3156
3157/// How many times a branch that moved under a retirement is classified again before
3158/// the retirement gives up on it.
3159const RECLASSIFICATIONS: usize = 3;
3160
3161/// Classify a branch from where its copies stand and, where its class permits what
3162/// was asked, retire it.
3163#[allow(clippy::too_many_arguments)] // each is one fact about the one act, named where it is asked
3164fn act(
3165    census: &Census<'_>,
3166    branch: &str,
3167    copies: Copies,
3168    acting: Acting,
3169    trigger: Trigger,
3170    dry_run: bool,
3171    ask: &Ask<'_>,
3172) -> Result<Retired> {
3173    let mut copies = copies;
3174    let mut attempts = 0;
3175    loop {
3176        let classified = census.classify(branch, copies, ask)?;
3177        let derivation = classified.derivation;
3178        if !acting.permits(classified.retirement.class, classified.retirement.reason) {
3179            return Ok(Retired {
3180                derivation,
3181                ..Retired::nothing(classified.retirement, RetireOutcome::Kept)
3182            });
3183        }
3184        let plan = census.plan(branch, &classified);
3185        if dry_run {
3186            return Ok(Retired {
3187                retirement: classified.retirement,
3188                outcome: RetireOutcome::WouldRetire,
3189                deleted: census.holders_of(&classified.copies),
3190                failed: Vec::new(),
3191                slots_returned: plan.slots,
3192                run_roots_removed: plan.run_roots,
3193                sessions_closed: plan.sessions.iter().map(session_token).collect(),
3194                derivation,
3195            });
3196        }
3197        // Immediately before anything is deleted, where every copy stands now — asked
3198        // of each place and of the origin itself, whatever the pass listed or recorded:
3199        // a copy that moved since it was judged is judged again, and nothing is deleted
3200        // on the strength of the tip it used to have.
3201        let now = census.copies_now(branch, ask);
3202        let moved = now != classified.copies;
3203        if !moved {
3204            match census.delete(branch, &classified.copies) {
3205                Deletion::Moved => {}
3206                Deletion::Done(done) => {
3207                    let finished = census.finish(branch, classified, plan, done, (acting, trigger));
3208                    if let (Some(verdicts), RetireOutcome::Retired) =
3209                        (ask.verdicts, finished.outcome)
3210                    {
3211                        verdicts.forget(&census.resolution.key, branch);
3212                    }
3213                    return Ok(Retired {
3214                        derivation,
3215                        ..finished
3216                    });
3217                }
3218            }
3219        }
3220        attempts += 1;
3221        if attempts >= RECLASSIFICATIONS {
3222            let mut retirement = classified.retirement;
3223            retirement.class = RetirementClass::Keep;
3224            retirement.reason = Some(KeepReason::Unknown);
3225            // llmlint: ignore[changed_behavior_has_e2e] uncovered: reaching it takes a copy
3226            // that moves between each of three reads made back to back inside one
3227            // invocation, a race no journey holds deterministically.
3228            // `a_tip_that_moves_during_the_deletion_puts_back_what_was_deleted_and_keeps_the_branch`
3229            // drives the one move a journey can.
3230            retirement.cause = Some(UnknownCause::new(
3231                "copy",
3232                branch,
3233                format!(
3234                    "a copy of it moved on each of {RECLASSIFICATIONS} reads made immediately \
3235                     before deleting it"
3236                ),
3237            ));
3238            retirement.proof = None;
3239            retirement.superseded_by = None;
3240            retirement.differing_paths = Vec::new();
3241            return Ok(Retired::nothing(retirement, RetireOutcome::Kept));
3242        }
3243        copies = census.copies_now(branch, ask);
3244    }
3245}
3246
3247fn session_token(record: &Record) -> SessionToken {
3248    SessionToken(record.token.to_string())
3249}
3250
3251/// What retiring a branch does beside deleting it: the session records it closes,
3252/// the run roots it removes, and the slots it returns.
3253struct Plan {
3254    sessions: Vec<Record>,
3255    run_roots: Vec<PathBuf>,
3256    slots: Vec<PathBuf>,
3257}
3258
3259/// What deleting the copies did.
3260enum Deletion {
3261    /// A copy stood somewhere other than where it was judged, so everything deleted
3262    /// was put back.
3263    Moved,
3264    /// Every copy that could be deleted was.
3265    Done(Done),
3266}
3267
3268/// The places a deletion reached, and the ones it did not.
3269struct Done {
3270    deleted: Vec<BranchHolder>,
3271    failed: Vec<FailedHolder>,
3272}
3273
3274impl Census<'_> {
3275    fn plan(&self, branch: &str, classified: &Classified) -> Plan {
3276        let runs = workspace::identity_dir(&self.resolution.key)
3277            .map(|root| root.join("runs"))
3278            .ok();
3279        let sessions: Vec<Record> = self
3280            .sessions
3281            .iter()
3282            .filter(|record| {
3283                record.identity == self.resolution.key
3284                    && *record.branch == *branch
3285                    && !record.owner_is_running()
3286            })
3287            .cloned()
3288            .collect();
3289        let run_roots = sessions
3290            .iter()
3291            .filter(|record| {
3292                record.slot.is_none()
3293                    && record.run_root.is_dir()
3294                    && runs
3295                        .as_ref()
3296                        .is_some_and(|runs| record.run_root.starts_with(runs))
3297            })
3298            .map(|record| record.run_root.clone())
3299            .collect();
3300        let mut slots: Vec<PathBuf> = Vec::new();
3301        for copy in &classified.copies.copies {
3302            if let Holding::Local(index) = copy.at {
3303                if let Some(slot) = &self.places[index].slot {
3304                    if !slots.contains(slot) {
3305                        slots.push(slot.clone());
3306                    }
3307                }
3308            }
3309        }
3310        for place in &self.places {
3311            let Some(slot) = &place.slot else { continue };
3312            let on_branch = git::checked_out_branch(&slot.join("worktree"))
3313                .is_ok_and(|current| current == branch);
3314            if on_branch && !slots.contains(slot) {
3315                slots.push(slot.clone());
3316            }
3317        }
3318        Plan {
3319            sessions,
3320            run_roots,
3321            slots,
3322        }
3323    }
3324
3325    /// Delete every copy, locally by compare-and-delete and on the origin under a
3326    /// lease, putting back what was deleted if any copy turns out to have moved.
3327    fn delete(&self, branch: &str, copies: &Copies) -> Deletion {
3328        let mut deleted: Vec<(BranchHolder, Option<(PathBuf, String)>)> = Vec::new();
3329        let mut failed: Vec<FailedHolder> = Vec::new();
3330        let restore = |deleted: &[(BranchHolder, Option<(PathBuf, String)>)]| {
3331            for (_, local) in deleted {
3332                if let Some((repo, tip)) = local {
3333                    if let Err(failure) = git::restore_branch(repo, branch, tip) {
3334                        eprintln!(
3335                            "onevcs: warning: {branch} was deleted from {} and could not be put \
3336                             back at {tip} after another copy moved: {failure}",
3337                            repo.display()
3338                        );
3339                    }
3340                }
3341            }
3342        };
3343        for copy in &copies.copies {
3344            let Holding::Local(index) = copy.at else {
3345                continue;
3346            };
3347            let place = &self.places[index];
3348            let holder = self.holder(copy.at);
3349            // A worktree standing on the branch is stood off it first — at the commit it
3350            // is on, which is clean or this branch would have been kept — because a
3351            // branch a worktree has checked out is not one git lets go of quietly.
3352            if let Err(failure) = stand_off(&place.repo, branch) {
3353                failed.push(FailedHolder {
3354                    kind: holder.kind,
3355                    location: holder.location,
3356                    error: failure.to_string(),
3357                });
3358                continue;
3359            }
3360            match git::delete_branch_at(&place.repo, branch, &copy.tip) {
3361                Ok(()) => deleted.push((holder, Some((place.repo.clone(), copy.tip.clone())))),
3362                Err(failure) => match git::local_tip(&place.repo, branch) {
3363                    LocalTip::At(now) if now.as_str() != copy.tip => {
3364                        restore(&deleted);
3365                        return Deletion::Moved;
3366                    }
3367                    LocalTip::Absent => deleted.push((holder, None)),
3368                    _ => failed.push(FailedHolder {
3369                        kind: holder.kind,
3370                        location: holder.location,
3371                        error: failure.to_string(),
3372                    }),
3373                },
3374            }
3375        }
3376        if let Some(origin) = copies.copies.iter().find(|copy| copy.at == Holding::Origin) {
3377            let holder = self.holder(Holding::Origin);
3378            let answered =
3379                git::delete_remote_branch(self.publication(), "origin", branch, &origin.tip);
3380            let refused = match answered {
3381                Ok(pushed) if pushed.accepted() => None,
3382                Ok(pushed) => Some(
3383                    pushed
3384                        .refusal()
3385                        .map(str::to_owned)
3386                        .unwrap_or_else(|| pushed.output().trim().to_owned()),
3387                ),
3388                Err(failure) => Some(failure.to_string()),
3389            };
3390            match refused {
3391                None => deleted.push((holder, None)),
3392                Some(why) => match git::remote_tip(self.publication(), "origin", branch, &[]) {
3393                    Ok(RemoteTip::Absent) => deleted.push((holder, None)),
3394                    Ok(RemoteTip::At(now)) if now.as_str() != origin.tip => {
3395                        restore(&deleted);
3396                        return Deletion::Moved;
3397                    }
3398                    _ => failed.push(FailedHolder {
3399                        kind: holder.kind,
3400                        location: holder.location,
3401                        error: why,
3402                    }),
3403                },
3404            }
3405        }
3406        Deletion::Done(Done {
3407            deleted: deleted.into_iter().map(|(holder, _)| holder).collect(),
3408            failed,
3409        })
3410    }
3411
3412    /// Close the records, remove the run roots, return the slots, and record it all.
3413    fn finish(
3414        &self,
3415        branch: &str,
3416        classified: Classified,
3417        plan: Plan,
3418        done: Done,
3419        (acting, trigger): (Acting, Trigger),
3420    ) -> Retired {
3421        let Done {
3422            deleted,
3423            mut failed,
3424        } = done;
3425        let mut sessions_closed = Vec::new();
3426        for record in &plan.sessions {
3427            if record.state != Lifecycle::Open {
3428                continue;
3429            }
3430            // Closed on the record as it stands now rather than on the copy this census
3431            // read: a session opened over the branch since has recorded on it which
3432            // session continued the work, and that link outlives this retirement.
3433            if let Err(failure) =
3434                workspace::update(&record.token, |stored| stored.state = Lifecycle::Closed)
3435            {
3436                failed.push(FailedHolder {
3437                    kind: match record.slot {
3438                        Some(_) => BranchHolderKind::Slot,
3439                        None => BranchHolderKind::RunClone,
3440                    },
3441                    location: record.run_root.display().to_string(),
3442                    error: format!(
3443                        "its session record {} could not be closed: {failure}",
3444                        record.token
3445                    ),
3446                });
3447                continue;
3448            }
3449            if let Ok(mut stream) = Stream::open(&record.token) {
3450                stream.emit(
3451                    EventKind::SessionClosed,
3452                    workspace::object(json!({"token": record.token, "branch": record.branch})),
3453                );
3454            }
3455            sessions_closed.push(session_token(record));
3456        }
3457        let mut run_roots_removed = Vec::new();
3458        for run_root in &plan.run_roots {
3459            match remove_run_root(run_root) {
3460                Ok(true) => run_roots_removed.push(run_root.clone()),
3461                Ok(false) => {}
3462                Err(why) => failed.push(FailedHolder {
3463                    kind: BranchHolderKind::RunClone,
3464                    location: run_root.display().to_string(),
3465                    error: why,
3466                }),
3467            }
3468        }
3469        let mut slots_returned = Vec::new();
3470        for slot in &plan.slots {
3471            match self.return_slot(slot) {
3472                Ok(true) => slots_returned.push(slot.clone()),
3473                Ok(false) => {}
3474                Err(why) => failed.push(FailedHolder {
3475                    kind: BranchHolderKind::Slot,
3476                    location: slot.display().to_string(),
3477                    error: why,
3478                }),
3479            }
3480        }
3481        let outcome = match failed.is_empty() {
3482            true => RetireOutcome::Retired,
3483            false => RetireOutcome::Incomplete,
3484        };
3485        let retired = Retired {
3486            retirement: classified.retirement,
3487            outcome,
3488            deleted,
3489            failed,
3490            slots_returned,
3491            run_roots_removed,
3492            sessions_closed,
3493            derivation: Derivation::Derived,
3494        };
3495        record_retirement(&retired, branch, acting, trigger);
3496        retired
3497    }
3498
3499    /// Return one slot the way a close returns one, where it is idle: detached onto
3500    /// the base, reset, cleaned of untracked files and the configured delete paths.
3501    /// Its directory, clone and record stay. A slot a session or a maintenance run is
3502    /// in is left to them.
3503    fn return_slot(&self, slot: &Path) -> std::result::Result<bool, String> {
3504        let pool = slot
3505            .parent()
3506            .ok_or_else(|| format!("{} has no pool above it", slot.display()))?;
3507        let _serial =
3508            lock::exclusive(&pool::placement_identity(pool)).map_err(|e| e.to_string())?;
3509        if !pool::is_idle(&self.resolution.key, slot).map_err(|e| e.to_string())? {
3510            return Ok(false);
3511        }
3512        let Some(_inside) =
3513            lock::try_exclusive(&workspace::occupancy_identity(slot)).map_err(|e| e.to_string())?
3514        else {
3515            return Err("a command is working in it right now".to_owned());
3516        };
3517        let clone = slot.join("clone");
3518        let worktree = slot.join("worktree");
3519        // Onto the base as the origin has it now, which is where the next session
3520        // would be cut from: a slot's clone has seen the base only as of the last
3521        // session placed on it. A fetch that fails leaves it where it was, which is the
3522        // base a close would have returned it onto.
3523        let _ = git::fetch(&clone, "origin");
3524        let base = Ref::from_git(self.base.clone().unwrap_or_else(|| "HEAD".to_owned()));
3525        let delete = workspaces::resolve_for(&self.resolution, workspaces::Overrides::default())
3526            .map(|resolved| resolved.delete)
3527            .map_err(|e| e.to_string())?;
3528        workspace::reset_onto_base(
3529            &worktree,
3530            &workspace::integrated_base(&clone, &base),
3531            &delete,
3532        )
3533        .map_err(|e| e.to_string())?;
3534        Ok(true)
3535    }
3536}
3537
3538/// Stand every worktree of `repo` that has the branch checked out — the repository's
3539/// own `HEAD` included — off it, at the commit it is on.
3540fn stand_off(repo: &Path, branch: &str) -> Result<()> {
3541    for (worktree, on) in git::worktree_heads(repo)? {
3542        if on.as_deref() == Some(branch) {
3543            git::detach_head(&worktree)?;
3544        }
3545    }
3546    Ok(())
3547}
3548
3549/// A directory that can be listed and holds nothing — what a run root's removal that
3550/// stopped part way leaves of its clone. It holds no ref, so it holds no copy; a clone
3551/// that cannot be listed is not this, and stays a place nobody could read.
3552fn emptied(path: &Path) -> bool {
3553    std::fs::read_dir(path).is_ok_and(|mut entries| entries.next().is_none())
3554}
3555
3556/// Remove one run root whose clone holds nothing else nobody has published, under
3557/// the exclusive take that proves no command is in it. `false` where it is kept.
3558fn remove_run_root(run_root: &Path) -> std::result::Result<bool, String> {
3559    if !processes::holding(run_root).is_empty() {
3560        return Err("a process is working inside it".to_owned());
3561    }
3562    let Some(_exclusive) =
3563        lock::try_exclusive(&workspace::occupancy_identity(run_root)).map_err(|e| e.to_string())?
3564    else {
3565        return Err("a command is working in it right now".to_owned());
3566    };
3567    let clone = run_root.join("clone");
3568    if git::is_repo(&clone)
3569        && !git::unpublished_branches(&clone)
3570            .unwrap_or_default()
3571            .is_empty()
3572    {
3573        return Ok(false);
3574    }
3575    std::fs::remove_dir_all(run_root)
3576        .map(|()| true)
3577        .map_err(|e| format!("it could not be removed: {e}"))
3578}
3579
3580/// Write the `branch-retired` event for one retirement.
3581fn record_retirement(retired: &Retired, branch: &str, acting: Acting, trigger: Trigger) {
3582    let retirement = &retired.retirement;
3583    let payload = RetiredPayload {
3584        identity: retirement.identity.clone(),
3585        branch: branch.to_owned(),
3586        tip: retirement.tip.clone(),
3587        class: retirement.class,
3588        reason: retirement.reason,
3589        proof: retirement.proof.clone(),
3590        superseded_by: retirement.superseded_by.clone(),
3591        differing_paths: retirement.differing_paths.clone(),
3592        mode: acting.to_owned(),
3593        trigger: trigger.to_owned(),
3594        deleted: retired.deleted.clone(),
3595        failed: retired.failed.clone(),
3596        slots_returned: retired.slots_returned.clone(),
3597        run_roots_removed: retired.run_roots_removed.clone(),
3598        sessions_closed: retired
3599            .sessions_closed
3600            .iter()
3601            .map(|token| token.0.clone())
3602            .collect(),
3603    };
3604    let Ok(Value::Object(payload)) = serde_json::to_value(&payload) else {
3605        return;
3606    };
3607    match open_stream(&retirement.identity, branch) {
3608        Ok(mut stream) => {
3609            let result = if retired.outcome == RetireOutcome::Incomplete {
3610                crate::event::EmissionResult::Failed
3611            } else {
3612                crate::event::EmissionResult::Success
3613            };
3614            stream.emit_result(EventKind::BranchRetired, payload, Vec::new(), result);
3615        }
3616        Err(failure) => eprintln!(
3617            "onevcs: warning: {branch} was retired and the record of it was not written: {failure}"
3618        ),
3619    }
3620}
3621
3622/// Record a supersession, once.
3623pub(crate) fn supersede(record: &Supersession) -> Result<()> {
3624    let registry = store::load()?;
3625    let identity = store::resolve(&registry, &record.repo)?.key;
3626    for (name, value) in [("branch", &record.branch), ("--by", &record.superseded_by)] {
3627        if !git::is_valid_branch_name(value) {
3628            return Err(error::invalid(format!(
3629                "{value:?} is not a valid branch name, so it cannot be the {name} of a supersession"
3630            )));
3631        }
3632    }
3633    if record.branch == record.superseded_by {
3634        return Err(error::invalid(format!(
3635            "{branch:?} cannot supersede itself",
3636            branch = record.branch
3637        )));
3638    }
3639    let landing = SupersedingLanding::parse(&record.landing).ok_or_else(|| {
3640        error::invalid(format!(
3641            "{landing:?} is neither a full commit id nor a change request's http(s) URL, so it \
3642             names no landing",
3643            landing = record.landing
3644        ))
3645    })?;
3646    label::validate(&record.labels)?;
3647    let streams = status::recorded_streams(&mut Vec::new())?;
3648    let already = status::supersessions(&streams, &identity, &record.branch)
3649        .into_iter()
3650        .any(|recorded| {
3651            *recorded.superseded_by == *record.superseded_by && recorded.landing == landing
3652        });
3653    if already {
3654        return Ok(());
3655    }
3656    let mut stream = open_stream(&identity, &record.branch)?;
3657    stream.emit(
3658        EventKind::BranchSuperseded,
3659        workspace::object(json!({
3660            "identity": identity,
3661            "branch": record.branch,
3662            "superseded_by": record.superseded_by,
3663            "landing": landing.to_string(),
3664            "labels": record.labels,
3665        })),
3666    );
3667    Ok(())
3668}
3669
3670/// Each kind of read a classification makes, failing, and the cause it is kept for.
3671///
3672/// One of this crate's `#[cfg(test)]` modules, for the reason the others exist: the reads
3673/// are the census's own private ones — its places, a live holder's lease, the worktrees
3674/// over a branch, a copy's history, the host — and `tests/e2e/retire.rs`, which drives the
3675/// same failures through the binary, can only see the one cause a whole classification
3676/// answers. Here each read is asked by itself, so a cause that went missing from one read
3677/// is not hidden behind another read that failed first.
3678///
3679/// Everything is real: a state root of its own, a bare origin, a registered `local-direct`
3680/// checkout, a branch worked in a session and landed by the library's own
3681/// `publish_branch`, and a second session over the landed branch whose opener has gone —
3682/// the record is read with no owner start, which is what a record whose process exited
3683/// reads as. The failures are what a host does to files: a mode that closes a directory,
3684/// and a ref naming a commit nobody has. The state root is the process's, which is why
3685/// the suite runs one test per process, as `cargo nextest` does.
3686#[cfg(all(test, unix))]
3687mod unknown_causes {
3688    use std::os::unix::fs::PermissionsExt;
3689    use std::path::{Path, PathBuf};
3690    use std::process::Command;
3691
3692    use serde_json::json;
3693
3694    use super::{Ask, Census, Copies, Host, Reach, UnknownCause};
3695    use crate::event::EventKind;
3696    use crate::ops::{publish_branch, register_checkout, BranchPublishRequest};
3697    use crate::stream::Stream;
3698    use crate::workspace::Record;
3699    use crate::{lock, Lifecycle, Providers, SessionRequest};
3700
3701    const BRANCH: &str = "feature/done";
3702
3703    fn git(cwd: &Path, args: &[&str]) -> String {
3704        let output = Command::new("git")
3705            .args(args)
3706            .current_dir(cwd)
3707            .output()
3708            .expect("git runs");
3709        assert!(
3710            output.status.success(),
3711            "git {} failed in {}: {}",
3712            args.join(" "),
3713            cwd.display(),
3714            String::from_utf8_lossy(&output.stderr)
3715        );
3716        String::from_utf8_lossy(&output.stdout).trim().to_owned()
3717    }
3718
3719    fn commit(worktree: &Path, file: &str) {
3720        std::fs::write(worktree.join(file), format!("{file}\n")).expect("a file to commit");
3721        git(worktree, &["add", "-A"]);
3722        git(
3723            worktree,
3724            &["commit", "-q", "-m", &format!("feat: write {file}")],
3725        );
3726    }
3727
3728    fn opened(providers: &Providers<'_>) -> crate::Session {
3729        providers
3730            .vcs
3731            .open_session(SessionRequest {
3732                repo: "project".to_owned(),
3733                branch: Some(BRANCH.to_owned()),
3734                branch_name: None,
3735                branch_prefix: None,
3736                base: None,
3737                execution_checkout: None,
3738                pool: None,
3739                overflow: None,
3740                labels: Default::default(),
3741                refuse_conflicts: false,
3742            })
3743            .expect("a session over the branch")
3744    }
3745
3746    /// A host on which `feature/done` landed and a session left open over it.
3747    struct Landed {
3748        /// Removed when the test ends.
3749        _root: tempfile::TempDir,
3750        /// The open session's record, as a census reads it once its opener has gone.
3751        stale: Record,
3752    }
3753
3754    impl Landed {
3755        fn new() -> Self {
3756            let root = tempfile::tempdir().expect("a scratch host");
3757            let at = root.path().canonicalize().expect("a canonical root");
3758            // Auto-maintenance off: after a commit git detaches a maintenance child into
3759            // the worktree, and a `close_session` that follows sees a process inside its
3760            // run root and rightly refuses — a race no test here sets out to run.
3761            std::fs::write(
3762                at.join(".gitconfig"),
3763                "[user]\n\tname = Unit\n\temail = unit@example.invalid\n[init]\n\t\
3764                 defaultBranch = main\n[commit]\n\tgpgsign = false\n[maintenance]\n\t\
3765                 auto = false\n",
3766            )
3767            .expect("a git configuration");
3768            std::env::set_var("HOME", &at);
3769            std::env::set_var(crate::home::HOME_ENV, at.join(".onevcs"));
3770            let seed = at.join("seed");
3771            std::fs::create_dir_all(&seed).expect("a seed");
3772            git(&seed, &["init", "-q", "-b", "main"]);
3773            commit(&seed, "README.md");
3774            let origin = at.join("project.git");
3775            git(&at, &["init", "-q", "--bare", &origin.to_string_lossy()]);
3776            git(&seed, &["push", "-q", &origin.to_string_lossy(), "main"]);
3777            let checkout = at.join("project");
3778            git(
3779                &at,
3780                &[
3781                    "clone",
3782                    "-q",
3783                    &origin.to_string_lossy(),
3784                    &checkout.to_string_lossy(),
3785                ],
3786            );
3787            // Registered under a hosted origin with no host implementation behind it, so
3788            // a change request in its own repository is one nobody can be asked about.
3789            let hosted = url::Url::parse("https://gitlab.com/acme/project.git").expect("a URL");
3790            register_checkout(&checkout, Some(&hosted)).expect("the checkout registers");
3791            std::fs::write(
3792                at.join(".onevcs/rules.yml"),
3793                "version: 1\nrules: []\ndefault: {publication: local-direct, approvals: none}\n",
3794            )
3795            .expect("a rules file");
3796            let providers = Providers::real();
3797            let worked = opened(&providers);
3798            commit(&worked.worktree, "done.txt");
3799            crate::close_session(&providers, &worked.token).expect("the session closes");
3800            publish_branch(
3801                &providers,
3802                &BranchPublishRequest {
3803                    repo: checkout.clone(),
3804                    branch: BRANCH.to_owned(),
3805                    title: None,
3806                    body: None,
3807                    policy: None,
3808                    term_scope: Default::default(),
3809                },
3810            )
3811            .expect("the branch lands");
3812            let left = opened(&providers);
3813            let mut stale = crate::workspace::all()
3814                .expect("the session records")
3815                .into_iter()
3816                .find(|record| record.token.to_string() == left.token.0)
3817                .expect("the open session's record");
3818            assert_eq!(stale.state, Lifecycle::Open);
3819            stale.owner_started = None;
3820            Landed { _root: root, stale }
3821        }
3822
3823        /// Every record this host has, with the open one read as its opener gone.
3824        fn host(&self) -> Host {
3825            let records = crate::workspace::all()
3826                .expect("the session records")
3827                .into_iter()
3828                .map(|record| match record.token == self.stale.token {
3829                    true => self.stale.clone(),
3830                    false => record,
3831                })
3832                .collect();
3833            Host::with(Some(records)).expect("the host reads")
3834        }
3835
3836        fn identity(&self) -> String {
3837            self.stale.identity.clone()
3838        }
3839    }
3840
3841    /// Close `path` to this user for as long as `read` runs.
3842    fn closed<T>(path: &Path, read: impl FnOnce() -> T) -> T {
3843        let original = std::fs::metadata(path).expect("a path").permissions();
3844        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o000))
3845            .expect("the path is closed");
3846        assert!(
3847            std::fs::File::open(path).is_err(),
3848            "the premise: this suite runs as a user the mode binds"
3849        );
3850        let answer = read();
3851        std::fs::set_permissions(path, original).expect("the path is open again");
3852        answer
3853    }
3854
3855    fn census<'h>(host: &'h Host, identity: &str) -> Census<'h> {
3856        host.census(identity, Reach::Offline)
3857            .expect("the identity is read")
3858    }
3859
3860    fn says(cause: &UnknownCause, operation: &str, subject: &Path) {
3861        assert_eq!(cause.operation, operation, "{cause}");
3862        assert_eq!(cause.subject, subject.display().to_string(), "{cause}");
3863        assert!(
3864            !cause.error.trim().is_empty(),
3865            "a cause with no error: {cause}"
3866        );
3867    }
3868
3869    /// The classification a read like this one would decide, which names the same read.
3870    fn classified(census: &Census<'_>, copies: Copies) -> UnknownCause {
3871        let retirement = census
3872            .classify(BRANCH, copies, &Ask::offline())
3873            .expect("a classification")
3874            .retirement;
3875        retirement
3876            .cause()
3877            .cloned()
3878            .unwrap_or_else(|| panic!("kept without a cause: {retirement:?}"))
3879    }
3880
3881    #[test]
3882    fn a_place_whose_branches_cannot_be_read_is_a_copy_cause_naming_it() {
3883        let landed = Landed::new();
3884        let host = landed.host();
3885        let identity = landed.identity();
3886        let clone = landed.stale.clone.clone();
3887        let (copies, cause) = closed(&clone, || {
3888            let census = census(&host, &identity);
3889            let copies = census.copies(BRANCH, &Ask::offline());
3890            let cause = classified(&census, copies.clone());
3891            (copies, cause)
3892        });
3893        says(&copies.unreadable[0], "copy", &clone);
3894        assert_eq!(cause, copies.unreadable[0]);
3895    }
3896
3897    #[test]
3898    fn a_directory_of_run_roots_that_cannot_be_listed_is_a_census_cause_naming_it() {
3899        let landed = Landed::new();
3900        let host = landed.host();
3901        let identity = landed.identity();
3902        let runs = landed
3903            .stale
3904            .run_root
3905            .parent()
3906            .expect("the run roots")
3907            .to_path_buf();
3908        let (unlisted, cause) = closed(&runs, || {
3909            let census = census(&host, &identity);
3910            let copies = census.copies(BRANCH, &Ask::offline());
3911            (census.unlisted.clone(), classified(&census, copies))
3912        });
3913        says(&unlisted[0], "census", &runs);
3914        assert_eq!(cause, unlisted[0]);
3915    }
3916
3917    #[test]
3918    fn a_lease_that_cannot_be_read_is_a_live_holder_cause_naming_the_run_root() {
3919        let landed = Landed::new();
3920        let host = landed.host();
3921        let identity = landed.identity();
3922        let lease = lock::path_for(&landed.stale.lease()).expect("the lease's path");
3923        assert!(
3924            lease.is_file(),
3925            "the premise: opening the session took its lease"
3926        );
3927        let read = closed(&lease, || census(&host, &identity).live_holder(BRANCH));
3928        says(
3929            &read.expect_err("a lease nobody can open answers no holder"),
3930            "live-holder",
3931            &landed.stale.run_root,
3932        );
3933    }
3934
3935    #[test]
3936    fn a_worktree_that_cannot_be_read_is_a_worked_in_cause_naming_it() {
3937        let landed = Landed::new();
3938        let host = landed.host();
3939        let identity = landed.identity();
3940        let census = census(&host, &identity);
3941        let copies = census.copies(BRANCH, &Ask::offline());
3942        assert!(copies.unreadable.is_empty(), "{copies:?}");
3943        let worktree = landed.stale.worktree.clone();
3944        let read = closed(&worktree, || census.worked_in(BRANCH, &copies));
3945        says(
3946            &read.expect_err("a worktree nobody can read answers no state"),
3947            "worked-in",
3948            &worktree,
3949        );
3950    }
3951
3952    #[test]
3953    fn a_copy_whose_commit_is_missing_is_a_judge_cause_naming_where_it_is() {
3954        let landed = Landed::new();
3955        let clone = landed.stale.clone.clone();
3956        let refs = PathBuf::from(git(&clone, &["rev-parse", "--absolute-git-dir"]))
3957            .join("refs/heads")
3958            .join(BRANCH);
3959        std::fs::write(&refs, "1111111111111111111111111111111111111111\n")
3960            .expect("the clone's copy names a commit nobody has");
3961        let host = landed.host();
3962        let identity = landed.identity();
3963        let census = census(&host, &identity);
3964        let copies = census.copies(BRANCH, &Ask::offline());
3965        let base_tip = census.base_tip.clone().expect("the base is read");
3966        let read = census.judged(
3967            BRANCH,
3968            &base_tip,
3969            &copies,
3970            &census.evidence(BRANCH),
3971            &Ask::offline(),
3972        );
3973        let Err(cause) = read else {
3974            panic!("a commit nobody has was judged");
3975        };
3976        says(&cause, "judge", &clone);
3977        assert_eq!(classified(&census, copies).operation, "judge");
3978    }
3979
3980    #[test]
3981    fn a_change_request_the_host_cannot_be_asked_about_is_a_host_consult_cause_naming_it() {
3982        let landed = Landed::new();
3983        // The record `publish` writes when it opens a change request, on the open
3984        // session's own stream: the identity is hosted where this build has no host, and
3985        // whether the change request merged or is still open is a question nobody can
3986        // answer.
3987        let url = "https://gitlab.com/acme/project/-/merge_requests/7";
3988        let mut stream = Stream::open(&landed.stale.token.to_string()).expect("its stream");
3989        stream.emit(
3990            EventKind::ChangeOpened,
3991            json!({"url": url, "host": "github", "id": "7", "base": "main"})
3992                .as_object()
3993                .expect("an object")
3994                .clone(),
3995        );
3996        drop(stream);
3997        let host = landed.host();
3998        let identity = landed.identity();
3999        let census = census(&host, &identity);
4000        let providers = Providers::real();
4001        let ask = Ask::acting(Some(providers.hosting), true, &[]);
4002        let copies = census.copies(BRANCH, &Ask::offline());
4003        let (_, decided) = census.consult(
4004            BRANCH,
4005            &mut census.evidence(BRANCH),
4006            &copies.first_tip(),
4007            None,
4008            &ask,
4009            None,
4010        );
4011        let cause = decided.expect_err("a host nobody can ask decides nothing");
4012        assert_eq!(
4013            (cause.operation.as_str(), cause.subject.as_str()),
4014            ("host-consult", url)
4015        );
4016        assert!(!cause.error.trim().is_empty(), "{cause}");
4017    }
4018}
4019
4020/// What each mode permits, over every class and every reason to keep a branch. The
4021/// journeys in `tests/e2e/retire.rs` drive each reason a request by name can reach; this
4022/// is where `excluded` is asked too, which only a pass can classify and a pass never
4023/// discards.
4024#[cfg(test)]
4025mod permits {
4026    use super::{Acting, KeepReason, RetirementClass};
4027
4028    const KEPT: [KeepReason; 9] = [
4029        KeepReason::HeldByLiveSession,
4030        KeepReason::Excluded,
4031        KeepReason::OpenChangeRequest,
4032        KeepReason::ClosedChangeRequest,
4033        KeepReason::CheckedOut,
4034        KeepReason::DirtyWorktree,
4035        KeepReason::UnmergedUniqueCommits,
4036        KeepReason::Unknown,
4037        KeepReason::IsBase,
4038    ];
4039
4040    #[test]
4041    fn a_discard_takes_a_kept_branch_only_for_work_nothing_landed() {
4042        for reason in KEPT {
4043            assert_eq!(
4044                Acting::Discard.permits(RetirementClass::Keep, Some(reason)),
4045                reason == KeepReason::UnmergedUniqueCommits,
4046                "{}",
4047                reason.as_str()
4048            );
4049        }
4050        assert!(Acting::Discard.permits(RetirementClass::Retirable, None));
4051        assert!(Acting::Discard.permits(RetirementClass::SupersededWithChanges, None));
4052    }
4053
4054    #[test]
4055    fn no_other_mode_takes_a_kept_branch_for_any_reason() {
4056        for acting in [Acting::Retire, Acting::Reclaim, Acting::Automatic] {
4057            for reason in KEPT {
4058                assert!(
4059                    !acting.permits(RetirementClass::Keep, Some(reason)),
4060                    "{} took {}",
4061                    acting.as_str(),
4062                    reason.as_str()
4063                );
4064            }
4065        }
4066        assert!(Acting::Reclaim.permits(RetirementClass::SupersededWithChanges, None));
4067        assert!(!Acting::Retire.permits(RetirementClass::SupersededWithChanges, None));
4068        assert!(!Acting::Automatic.permits(RetirementClass::SupersededWithChanges, None));
4069    }
4070}