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