Skip to main content

onevcs/
vcs.rs

1//! The repository side of the seam.
2
3use std::collections::{BTreeMap, BTreeSet};
4use std::path::{Path, PathBuf};
5
6use crate::error::{self, Error, Result};
7use crate::event::EventKind;
8use crate::host::{Hosting, Sha};
9use crate::landed::{self, Landed};
10use crate::publish::{Publication, PublicationCancellation, PublishRequest};
11use crate::registry::Identity;
12use crate::session::{
13    HeldBy, Holding, Lifecycle, LineChange, Liveness, NetNegative, OnOrigin, PreservedBranch,
14    Provenance, Recoverable, Scope, Selection, Session, SessionRecord, SessionRequest,
15    SessionToken,
16};
17use crate::stream::Stream;
18use crate::workspace::{self, object, Ref};
19use crate::{git, label, lock, provenance, publish, publishing, store};
20
21use serde_json::json;
22use url::Url;
23
24/// Everything `onevcs` does to a repository, independent of which version
25/// control system is underneath.
26///
27/// The session record belongs here rather than beside it. It was written by [`Git`]
28/// directly for three releases, and the cost was exact: a session a supplied
29/// implementation had just opened was refused by `publish` and by `session close`
30/// as a session nobody opened, so the seam was declared and those commands went
31/// around it.
32pub trait Vcs {
33    /// Resolve an origin URL or a checkout path to the repository identity it
34    /// belongs to.
35    fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity>;
36
37    /// Open a session: a clone of an execution checkout and an isolated worktree cut
38    /// from it — a warm pool slot where the host keeps one, else cut for this run —
39    /// held under an occupancy lease.
40    fn open_session(&self, req: SessionRequest) -> Result<Session>;
41
42    /// Re-attach to a session that already exists, claiming its free occupancy
43    /// lease.
44    fn adopt_session(&self, token: SessionToken) -> Result<Session>;
45
46    /// What this implementation recorded about a session it opened.
47    fn session(&self, token: &SessionToken) -> Result<SessionRecord>;
48
49    /// Release a session's worktree and its lease, keeping its branch.
50    fn close_session(&self, token: &SessionToken) -> Result<Session>;
51
52    /// Commit the session's work onto a branch that outlives it, recording why.
53    fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch>;
54
55    /// Verify a session's branch and land it under its repository's policy.
56    ///
57    /// The one operation that reaches both interfaces — the repository side lands
58    /// the change and the host side opens and merges the change request — so the
59    /// host factory travels with the request rather than being reached for.
60    fn publish(
61        &self,
62        token: &SessionToken,
63        request: &PublishRequest,
64        hosting: &dyn Hosting,
65    ) -> Result<Publication>;
66
67    /// [`publish`](Self::publish), asking `cancellation` while it waits and ending as
68    /// [`FailureKind::Cancelled`](crate::FailureKind::Cancelled) once it says so.
69    ///
70    /// Defaulted to [`publish`](Self::publish), so an implementation written before
71    /// cancellation existed still compiles and publishes — and is never cancelled,
72    /// which is the answer an implementation that cannot stop has to give: a backend
73    /// that does not override this cannot be cancelled mid-watch. [`Git`]
74    /// overrides it: every phase of its publication that waits asks the cancellation
75    /// at least once a second.
76    fn publish_with_cancellation(
77        &self,
78        token: &SessionToken,
79        request: &PublishRequest,
80        hosting: &dyn Hosting,
81        cancellation: &dyn PublicationCancellation,
82    ) -> Result<Publication> {
83        let _ = cancellation;
84        self.publish(token, request, hosting)
85    }
86
87    /// Every preserved-but-unpublished branch in scope, and what would land each.
88    ///
89    /// Unpublished is decided from history rather than inferred from content, and a
90    /// branch whose work reached the base is not one of these — including one the
91    /// base has moved a long way past since. The row that offered such a branch a
92    /// paste-ready `publish-branch` is what this excludes: following it re-opens a
93    /// change request for work the base already carries. A branch nothing can decide
94    /// about is still here, because it may be work nobody published; what it does not
95    /// carry is a command anybody should paste without looking.
96    fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>>;
97
98    /// Every preserved branch in scope, whatever became of its work, each saying
99    /// what did become of it and what says so.
100    ///
101    /// [`recoverable`](Self::recoverable) is this without the ones whose work is on
102    /// the base, and is what somebody asking "what is left to publish" wants. This is
103    /// what somebody asking "what became of all of it" wants, and it is the one place
104    /// a withheld branch is reported rather than silently dropped — an exclusion
105    /// nobody can see is how preserved work goes missing.
106    ///
107    /// Required rather than defaulted to [`recoverable`](Self::recoverable): the
108    /// default would answer the *narrower* question under this one's name, and an
109    /// implementation whose wider answer is the same one has only to say so.
110    fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>>;
111
112    /// [`recoverable`](Self::recoverable), narrowed to the sessions a
113    /// [`Selection`] names.
114    ///
115    /// Defaulted rather than required, and the default is the whole meaning: a row is
116    /// answered with when some session record of this host naming its branch is one
117    /// the selection picked — by token, by carrying every label pair asked for, or by
118    /// both. So every implementation narrows identically, and what an override buys is
119    /// not a different answer but a cheaper one: an implementation that knows *where*
120    /// it looks can decline to look, which is what [`Git`] does with it. A selection
121    /// that asks nothing is the whole report at `Detail::Full`. `Detail::Decision`
122    /// retains identical rows and semantic fields, including `tip` and the recovery
123    /// command; only presentation fields may be omitted. This is what every caller before this
124    /// existed asked for.
125    fn recoverable_matching(
126        &self,
127        scope: Scope,
128        selection: &Selection,
129    ) -> Result<Vec<Recoverable>> {
130        retain(self.recoverable(scope)?, selection)
131    }
132
133    /// [`preserved`](Self::preserved), narrowed the same way and for the same reason.
134    /// `Selection::detail` changes presentation cost only: both detail modes keep
135    /// the same rows, tip, landing evidence, holds, retirement and recovery command.
136    fn preserved_matching(&self, scope: Scope, selection: &Selection) -> Result<Vec<Recoverable>> {
137        retain(self.preserved(scope)?, selection)
138    }
139}
140
141/// The git implementation of [`Vcs`].
142///
143/// Stateless by design: everything it needs is the registry and the workspaces
144/// under the one state root, so two processes driving it see the same host.
145#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
146pub struct Git;
147
148impl Vcs for Git {
149    fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
150        let registry = store::load()?;
151        Ok(store::resolve(&registry, origin_or_path)?.identity)
152    }
153
154    fn open_session(&self, req: SessionRequest) -> Result<Session> {
155        let registry = store::load()?;
156        let (record, _stream, conflict) = workspace::open(&registry, &req)?;
157        Ok(Session {
158            conflict,
159            ..record.session()
160        })
161    }
162
163    fn adopt_session(&self, token: SessionToken) -> Result<Session> {
164        let (record, _stream, _preserved) = workspace::adopt(&token.0)?;
165        Ok(record.session())
166    }
167
168    fn session(&self, token: &SessionToken) -> Result<SessionRecord> {
169        let record = workspace::load(&token.0)?;
170        let trailers = provenance::configured()?;
171        // Where the branch is now. A session's clone holds it for the session's
172        // life, and a run root's clone keeps holding it after the close — but a slot's
173        // clone lets a copied branch go when the slot is returned, and the execution
174        // checkout the hand-back copied it to is then where the branch lives.
175        let held_in = match git::branch_exists(&record.clone, &record.branch) {
176            true => record.clone.clone(),
177            false => record.execution_checkout.clone(),
178        };
179        // A branch retired since the session closed is held nowhere, and what it
180        // carried was proved to be on the base before it was deleted: there is no
181        // marker left for anything to act on.
182        if !git::branch_exists(&held_in, &record.branch) && crate::retire::retired(&record) {
183            return Ok(SessionRecord {
184                session: record.session(),
185                identity: record.identity.clone(),
186                lifecycle: record.state,
187                provenance: Provenance::Complete,
188                retried_by: record
189                    .retried_by
190                    .as_ref()
191                    .map(|token| SessionToken(token.to_string())),
192            });
193        }
194        let target = publish::standing_target(&record)?;
195        let base = base_ref(&held_in, target.base());
196        Ok(SessionRecord {
197            session: record.session(),
198            identity: record.identity.clone(),
199            lifecycle: record.state,
200            // Read off the branch rather than remembered: an adoption that met a
201            // dirty tree writes the marker, and a recovery clears it, so what the
202            // branch carries now is the only answer that stays true.
203            provenance: provenance::provenance_of(&held_in, &base, &record.branch, &trailers)?,
204            retried_by: record
205                .retried_by
206                .as_ref()
207                .map(|token| SessionToken(token.to_string())),
208        })
209    }
210
211    fn close_session(&self, token: &SessionToken) -> Result<Session> {
212        let record = workspace::close(&token.0)?;
213        // The moment the facts about this branch are known: its session is done with
214        // it, and where its landing is recorded and it provably holds nothing beyond
215        // its base, it is retired here rather than left for somebody to find.
216        crate::retire::after_close(&record);
217        Ok(record.session())
218    }
219
220    fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
221        let record = workspace::load(&s.token.0)?;
222        let mut stream = Stream::open(&s.token.0)?;
223        preserve_into(&record, &mut stream, provenance)
224    }
225
226    fn publish(
227        &self,
228        token: &SessionToken,
229        request: &PublishRequest,
230        hosting: &dyn Hosting,
231    ) -> Result<Publication> {
232        publish::run_for_session(token, request, hosting, &publish::NeverCancelled)
233    }
234
235    fn publish_with_cancellation(
236        &self,
237        token: &SessionToken,
238        request: &PublishRequest,
239        hosting: &dyn Hosting,
240        cancellation: &dyn PublicationCancellation,
241    ) -> Result<Publication> {
242        publish::run_for_session(token, request, hosting, cancellation)
243    }
244
245    fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
246        collect(&scope, Reporting::UnpublishedOnly)
247    }
248
249    fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>> {
250        collect(&scope, Reporting::Everything)
251    }
252
253    fn recoverable_matching(
254        &self,
255        scope: Scope,
256        selection: &Selection,
257    ) -> Result<Vec<Recoverable>> {
258        collect_matching(&scope, Reporting::UnpublishedOnly, selection)
259    }
260
261    fn preserved_matching(&self, scope: Scope, selection: &Selection) -> Result<Vec<Recoverable>> {
262        collect_matching(&scope, Reporting::Everything, selection)
263    }
264}
265
266/// Commit whatever the worktree holds onto the branch, and hand the branch back to
267/// the execution checkout.
268///
269/// The session's clone is disposable, so a branch that is not copied out is lost
270/// with it. The copy is fast-forward only, to protect a concurrent session holding
271/// the same branch name, and a refusal is reported rather than swallowed: the
272/// branch a caller is then told about would name something nothing outside this
273/// session carries.
274///
275/// A worktree holding an unfinished merge is refused rather than committed: `git add
276/// -A && git commit` there would conclude the merge with whatever the files hold,
277/// git's markers included, and record that as the session's work.
278pub fn preserve_into(
279    record: &workspace::Record,
280    stream: &mut Stream,
281    kind: Provenance,
282) -> Result<PreservedBranch> {
283    refuse_unfinished_merge(
284        &record.worktree,
285        &record.branch,
286        "close the session, which abandons the merge and keeps every commit, or ask again",
287    )?;
288    let trailers = provenance::configured()?;
289    if git::is_dirty(&record.worktree)? {
290        git::add_all(&record.worktree)?;
291        let message = match kind {
292            Provenance::Complete => format!("chore: preserve work on {}", record.branch),
293            Provenance::IncompleteStep => provenance::incomplete_message(
294                &format!("work on {}", record.branch),
295                record.change_base.as_deref(),
296                &trailers,
297            ),
298        };
299        let sha = git::commit(&record.worktree, &message)?;
300        stream.emit(
301            EventKind::CommitPreserved,
302            object(json!({
303                "branch": record.branch,
304                "sha": sha,
305                "provenance": spell_provenance(kind),
306            })),
307        );
308    }
309
310    let copied = git::copy_branch(&record.clone, &record.execution_checkout, &record.branch)?;
311    if !copied {
312        return Err(Error::Invalid {
313            reason: format!(
314                "the execution checkout {} refused branch {:?}; it holds work this session's \
315                 clone does not, so nothing outside the session carries this branch",
316                record.execution_checkout.display(),
317                record.branch
318            ),
319        });
320    }
321
322    let base = judging_base(record)?;
323    Ok(PreservedBranch {
324        branch: record.branch.to_string(),
325        base: record.base.to_string(),
326        provenance: provenance::provenance_of(&record.clone, &base, &record.branch, &trailers)?,
327        change_url: None,
328        change_base: record.change_base.as_ref().map(ToString::to_string),
329    })
330}
331
332/// Refuse to commit or publish `worktree` while it holds a merge nobody concluded.
333///
334/// Both halves of an unfinished merge are refused, and the second is why this asks
335/// `MERGE_HEAD` rather than the index: paths git still lists as unmerged, and a merge
336/// whose every conflict has been staged but which nothing has committed. A guard that
337/// read only the first would let the second through to a commit that concludes the
338/// merge implicitly. `then` is what to do once the merge is concluded.
339pub(crate) fn refuse_unfinished_merge(worktree: &Path, branch: &str, then: &str) -> Result<()> {
340    if !worktree.is_dir() {
341        return Ok(());
342    }
343    let Some(merging) = git::merge_in_progress(worktree)? else {
344        return Ok(());
345    };
346    let state = match merging.unmerged.is_empty() {
347        true => "every conflicted path is staged, but the merge is not committed".to_owned(),
348        false => format!(
349            "{} still unmerged",
350            crate::guidance::listed(&merging.unmerged)
351        ),
352    };
353    let at = worktree.to_string_lossy();
354    Err(Error::SyncConflict {
355        reason: format!(
356            "the worktree {at} of branch {branch:?} holds an unfinished merge of {merging}: \
357             {state}. The merge must be concluded first — committing or publishing the tree \
358             now would conclude it with whatever the files hold, markers included — so \
359             nothing was committed or pushed. Resolve each path, stage it and commit the merge \
360             with `{commit}`, or abandon it with `{abort}`; then {then}",
361            merging = merging.merging,
362            commit = crate::guidance::command(["git", "-C", &at, "commit", "--no-edit"]),
363            abort = crate::guidance::command(["git", "-C", &at, "merge", "--abort"]),
364        ),
365    })
366}
367
368/// How a provenance kind is spelled in an event payload.
369pub fn spell_provenance(kind: Provenance) -> &'static str {
370    match kind {
371        Provenance::Complete => "complete",
372        Provenance::IncompleteStep => "incomplete-step",
373    }
374}
375
376/// The ref a session's branch is judged against: the base its publication resolves
377/// from what the clone holds now — the branch below for a stacked session, until the
378/// clone has seen the root carry it — as [`base_ref`] spells it.
379fn judging_base(record: &workspace::Record) -> Result<String> {
380    let target = publish::standing_target(record)?;
381    Ok(base_ref(&record.clone, target.base()))
382}
383
384/// The ref a branch's commits are counted against: the remote-tracking base when
385/// the repository has one, and the local base otherwise.
386pub fn base_ref(repo: &Path, base: &str) -> String {
387    let remote = format!("origin/{base}");
388    if git::ref_exists(repo, &format!("refs/remotes/{remote}")) {
389        remote
390    } else {
391        base.to_owned()
392    }
393}
394
395/// What a branch held in `repo` is judged against: the identity's base *now*,
396/// named by a commit `repo` can reach.
397///
398/// A checkout's remote-tracking refs are frozen at its last fetch, so its own
399/// `origin/main` can be many merges behind. Judged against that, a branch whose work
400/// landed weeks ago still looks like work nobody published — and a name whose meaning
401/// is spent still looks like a name that means something. Which is why the repository
402/// is asked *through* the object store of the checkout every publication
403/// fast-forwards: the commit the base stands at is one that checkout has by
404/// construction, so a copy that never fetched it can still read it. What is left over
405/// — a repository that cannot reach the base even so — is judged against its own view
406/// and reported as behind, which is what keeps the tiers below a record from closing
407/// the question from a history that stops short of the evidence.
408// The two states this answers with are deliberately one type, as `base_ref` beside it
409// and `Landing::compared_change_base` already are: what comes back is a *comparison
410// target*, and git resolves a ref name and a commit id identically at every call that
411// takes one. Distinguishing them in the type would only oblige each of those call
412// sites to collapse the distinction again, and the thing that must not be confused
413// with either — a branch name this crate writes — is `Ref`, which neither of these is.
414// llmlint: ignore[invalid_states_unrepresentable] a comparison target git resolves either way
415pub fn judged_against<'a>(
416    repo: impl Into<git::Asked<'a>>,
417    base: &str,
418    current: Option<&Sha>,
419) -> String {
420    let repo = repo.into();
421    match current {
422        Some(sha) if git::has_commit(repo, sha) => sha.0.clone(),
423        _ => base_ref(repo.path(), base),
424    }
425}
426
427/// The commit a checkout's base ref stands at.
428///
429/// Asked of the publication checkout, which is the one every publication
430/// fast-forwards and therefore the freshest view of the base this host keeps — not
431/// a guarantee of the newest there is, which only the remote can answer for.
432pub fn base_commit(checkout: &Path, base: &str) -> Option<Sha> {
433    git::tip(checkout, &base_ref(checkout, base)).map(Sha)
434}
435
436/// Which branches a report is asked to carry.
437#[derive(Debug, Clone, Copy, PartialEq, Eq)]
438pub enum Reporting {
439    /// Every branch whose work is not known to have reached the base, which is what
440    /// [`Vcs::recoverable`] answers.
441    UnpublishedOnly,
442    /// Every preserved branch, whatever became of its work — the ones that reached
443    /// the base included, which is what [`Vcs::preserved`] answers.
444    Everything,
445}
446
447/// Which preserved branches a [`Selection`] asks about, read out of this host's
448/// session records, and where a process has to look to answer.
449///
450/// The three sets are one question asked three ways, and the last two are what makes
451/// a filter cheap rather than merely narrow: a report that selected its rows at the
452/// end would still have walked every checkout of every identity and decided every
453/// branch in them, which is exactly the cost a filtered read exists to avoid. So a
454/// selection names sessions, the sessions name branches, and the branches name the
455/// few places they can be — and everything else is never opened.
456struct Narrowed {
457    /// The identities the selected sessions belong to.
458    identities: BTreeSet<String>,
459    /// The `(identity, branch)` pairs they hold or held.
460    branches: BTreeSet<(String, String)>,
461    /// The checkouts that can be holding one of those branches: each selected
462    /// session's own clone, and the execution checkout its branch is handed back to
463    /// when it closes. A branch this host knows about is in one of the two.
464    checkouts: BTreeSet<PathBuf>,
465}
466
467impl Narrowed {
468    /// Whether a branch is one of the ones asked about.
469    fn wants(&self, identity: &str, branch: &str) -> bool {
470        self.branches
471            .contains(&(identity.to_owned(), branch.to_owned()))
472    }
473}
474
475/// What a selection asks for, or `None` where it asks nothing.
476///
477/// A session token no record on this host names is refused **by name**: "nothing of
478/// that session is left to publish" and "there is no such session" are different
479/// answers, and a consumer sequencing its own work behind the first must never be
480/// handed it in place of the second. A label pair nothing carries is the first of
481/// those and answers an empty report, because a run whose branches all landed is
482/// exactly the run that has nothing here.
483fn narrowed(selection: &Selection, sessions: &[workspace::Record]) -> Result<Option<Narrowed>> {
484    if selection.is_empty() {
485        return Ok(None);
486    }
487    for token in &selection.sessions {
488        if !sessions.iter().any(|record| *record.token == *token.0) {
489            return Err(error::invalid(format!(
490                "no session record on this host names {}; \
491                 `onevcs session holders REPO` lists the sessions of one repository",
492                token.0
493            )));
494        }
495    }
496    let mut narrowed = Narrowed {
497        identities: BTreeSet::new(),
498        branches: BTreeSet::new(),
499        checkouts: BTreeSet::new(),
500    };
501    for record in sessions {
502        let named = selection.sessions.is_empty()
503            || selection
504                .sessions
505                .iter()
506                .any(|token| *record.token == *token.0);
507        if !named || !label::matches(&record.labels, &selection.labels) {
508            continue;
509        }
510        narrowed.identities.insert(record.identity.clone());
511        narrowed
512            .branches
513            .insert((record.identity.clone(), record.branch.to_string()));
514        narrowed.checkouts.insert(record.clone.clone());
515        narrowed.checkouts.insert(record.execution_checkout.clone());
516    }
517    Ok(Some(narrowed))
518}
519
520/// The rows of an answer a selection asks about, for an implementation that has
521/// already made the whole answer.
522///
523/// The one definition of what a selection *means*, so the narrowing [`Git`] does to
524/// its scan and the narrowing every other implementation gets by default cannot come
525/// to disagree about which rows an answer holds.
526pub(crate) fn retain(rows: Vec<Recoverable>, selection: &Selection) -> Result<Vec<Recoverable>> {
527    let Some(narrowed) = narrowed(selection, &workspace::all()?)? else {
528        return Ok(rows);
529    };
530    Ok(rows
531        .into_iter()
532        .filter(|row| narrowed.wants(&row.identity, &row.branch.branch))
533        .collect())
534}
535
536/// Every preserved branch in scope, newest first, and whether its work landed.
537///
538/// Read-only in the strongest sense: it opens repositories to ask questions, writes
539/// nothing, and takes no lease, so it is safe to run beside live work — which is
540/// exactly when somebody reaches for it.
541pub fn collect(scope: &Scope, reporting: Reporting) -> Result<Vec<Recoverable>> {
542    collect_matching(scope, reporting, &Selection::default())
543}
544
545/// The same report, narrowed to what a [`Selection`] asks about before it is made.
546///
547/// Every git read inside one invocation is answered once ([`git::memoized`]), because
548/// this asks the same questions of the same repositories over and over — one base tip
549/// per checkout read by every branch in it, one branch log read by four provenance
550/// readers — and each asking used to be a process. Each identity is scanned under a
551/// memo of its own, on its own thread, since no two identities share a repository to
552/// ask about. The memo is this call's and ends with it: the repositories a library caller holds move between invocations, and a
553/// fact remembered across one would be this report answering from a tree that has
554/// changed.
555pub fn collect_matching(
556    scope: &Scope,
557    reporting: Reporting,
558    selection: &Selection,
559) -> Result<Vec<Recoverable>> {
560    crate::recovery_cache::scope(|| git::memoized(|| collected(scope, reporting, selection)))
561}
562
563fn collected(
564    scope: &Scope,
565    reporting: Reporting,
566    selection: &Selection,
567) -> Result<Vec<Recoverable>> {
568    let registry = store::load()?;
569    let (rules, _source) = crate::policy::load(&registry)?;
570    let trailers = provenance::from_rules(&rules);
571    let sessions = crate::recovery_sessions::read(selection)?;
572    // Which sessions were asked about, and therefore which identities, checkouts and
573    // branch names the scan below may stop at. Read before anything is opened, so a
574    // token naming no record is refused before a single repository is.
575    let narrowed = narrowed(selection, &sessions)?;
576    // What this host's own runs recorded, read once: the change request each branch
577    // opened and any landing seen for it. A gap in the streams is not reported here —
578    // this report has nowhere to say one — and costs only certainty: a branch whose
579    // record could not be read falls to a lower tier and is judged from the base's own
580    // history instead.
581    let streams = crate::status::recorded_streams_about(
582        narrowed.as_ref().map(|only| &only.branches),
583        &sessions,
584    )?;
585    let wanted = match scope {
586        Scope::All => None,
587        Scope::Repo(repo) => Some(store::resolve(&registry, repo)?.key),
588    };
589
590    // Once per identity rather than once per checkout of one, because the places a
591    // branch of it can be are a property of the identity — and they are read from
592    // the one list the verbs that go on to *land* a branch read, so this report
593    // cannot come to offer branches nothing can reach, or miss ones something can.
594    let mut identities: Vec<&str> = registry
595        .checkouts
596        .values()
597        .map(|checkout| checkout.identity.as_str())
598        .collect();
599    identities.sort_unstable();
600    identities.dedup();
601    identities.retain(|&identity| {
602        if wanted.as_ref().is_some_and(|key| key != identity) {
603            return false;
604        }
605        // An identity none of the selected sessions belongs to holds none of the
606        // branches asked about, so nothing of it is opened at all — not its
607        // publication checkout, not its base, not one of its clones.
608        if narrowed
609            .as_ref()
610            .is_some_and(|only| !only.identities.contains(identity))
611        {
612            return false;
613        }
614        true
615    });
616    let scan = Scan {
617        registry: &registry,
618        streams: &streams,
619        sessions: &sessions,
620        trailers: &trailers,
621        narrowed: &narrowed,
622        reporting,
623        detail: selection.detail,
624    };
625    // Each identity is scanned on its own thread, and the answers are joined in the
626    // order the identities are named. That changes no verdict and no row: every
627    // question below is asked of one identity's own checkouts, and every key the scan
628    // deduplicates on names the identity, so no identity's answer reads another's.
629    // What it changes is the clock — the report is a long series of git reads against
630    // repositories that share nothing, and a host with a dozen identities used to wait
631    // for each of them in turn.
632    let mut rows: Vec<(Option<u64>, Recoverable)> = Vec::new();
633    // The branches whose work reached the base, kept aside rather than dropped: they
634    // are what `preserved` adds, and a copy of a name whose work the base already
635    // carries must not answer for a copy of it elsewhere that still holds work, so
636    // they join the answer only where no such copy did.
637    let mut withheld_rows: Vec<(Option<u64>, Recoverable)> = Vec::new();
638    let mut seen: Vec<(String, String)> = Vec::new();
639    for scanned in concurrently(&identities, |identity| {
640        crate::recovery_cache::scope(|| git::memoized(|| scanned(identity, &scan)))
641    }) {
642        let scanned = scanned?;
643        rows.extend(scanned.rows);
644        withheld_rows.extend(scanned.withheld_rows);
645        seen.extend(scanned.seen);
646    }
647    // A landed copy answers only where nothing holding work under that name did.
648    let mut kept: Vec<(String, String)> = Vec::new();
649    for (at, row) in withheld_rows {
650        let key = (row.identity.clone(), row.branch.branch.clone());
651        if seen.contains(&key) || kept.contains(&key) {
652            continue;
653        }
654        kept.push(key);
655        rows.push((at, row));
656    }
657    rows.sort_by_key(|(at, row)| {
658        (
659            std::cmp::Reverse(at.unwrap_or(0)),
660            row.branch.branch.clone(),
661        )
662    });
663    Ok(rows.into_iter().map(|(_, row)| row).collect())
664}
665
666/// What one `recoverable` read holds for the whole of its scan, lent to each
667/// identity's.
668struct Scan<'a> {
669    detail: crate::Detail,
670    registry: &'a crate::registry::Registry,
671    streams: &'a [crate::status::Recorded],
672    sessions: &'a [workspace::Record],
673    trailers: &'a provenance::Trailers,
674    narrowed: &'a Option<Narrowed>,
675    reporting: Reporting,
676}
677
678/// One identity's share of the report: the rows it answers with, the rows it
679/// withheld because their work landed, and the names it answered for.
680struct Scanned {
681    rows: Vec<(Option<u64>, Recoverable)>,
682    withheld_rows: Vec<(Option<u64>, Recoverable)>,
683    seen: Vec<(String, String)>,
684}
685
686/// Scan every checkout of one identity for its preserved branches.
687fn scanned(identity: &str, scan: &Scan<'_>) -> Result<Scanned> {
688    let Scan {
689        registry,
690        streams,
691        sessions,
692        trailers,
693        narrowed,
694        reporting,
695        ..
696    } = *scan;
697    let mut rows: Vec<(Option<u64>, Recoverable)> = Vec::new();
698    let mut withheld_rows: Vec<(Option<u64>, Recoverable)> = Vec::new();
699    let mut seen: Vec<(String, String)> = Vec::new();
700    // Every copy of a name this report has already put the question to, with the
701    // commit that copy stands at. A branch of one identity lives in as many clones as
702    // ever held it — a busy host keeps dozens — and deciding it is the expensive part
703    // of this report: the landing tiers, the provenance reads and, at the bottom, a
704    // content comparison, all of which answer the same for two copies standing at the
705    // same commit. So the second copy of one is not asked. The tip is in the key
706    // rather than assumed away, because two clones of a name that has been retried do
707    // *not* hold the same work, and a verdict borrowed across that difference would be
708    // this report answering about commits it never looked at.
709    let mut decided: BTreeSet<(String, String, String)> = BTreeSet::new();
710    let resolution = store::resolve(registry, identity)?;
711    let publication = resolution.publication.clone();
712    let current = git::default_branch(&publication, "origin")
713        .ok()
714        .and_then(|base| base_commit(&publication, &base));
715    // Every publication fast-forwards this checkout, so it is where a landing's
716    // evidence is — and lending its objects is what lets a checkout that has not
717    // fetched since read the commit that carries them.
718    let lent = git::objects_dir(&publication).ok();
719    // The branches this host itself put on the origin with `onevcs preserve`, read
720    // once per identity: see `reported_branches` for why the listing below needs
721    // them.
722    let preserved_here = crate::status::preserved_branches(streams, identity);
723    // What each branch is for the question of whether it may be deleted, read from the
724    // same records and local refs this report reads and nothing else — no host, no
725    // fetch — and decided once per branch, since the answer is about every copy of it.
726    let census = crate::retire::Census::read(
727        registry,
728        identity,
729        sessions,
730        streams,
731        trailers,
732        crate::retire::Reach::Offline,
733        narrowed.as_ref().map(|only| &only.checkouts),
734    )?;
735    let mut classified: BTreeMap<String, Option<crate::retire::Retirement>> = BTreeMap::new();
736    for repo in workspace::checkouts_among(registry, &resolution, sessions) {
737        // A checkout none of the selected sessions can be holding a branch in is
738        // not opened: that is the difference between a filter that narrows the
739        // answer and one that narrows the work, and on a host with forty retained
740        // clones per identity it is the whole difference.
741        if narrowed
742            .as_ref()
743            .is_some_and(|only| !only.checkouts.contains(&repo))
744        {
745            continue;
746        }
747        if !git::is_repo(&repo) {
748            if narrowed.is_some() && !matches!(repo.try_exists(), Ok(false)) {
749                let refused = git::run(&["rev-parse", "--is-inside-work-tree"], Some(&repo))?;
750                return Err(error::invalid(format!(
751                    "selected checkout {} could not be read as a Git worktree: {}; restore its readability and retry recovery",
752                    repo.display(), refused.diagnostic()
753                )));
754            }
755            continue;
756        }
757        let base = match git::default_branch(&repo, "origin") {
758            Ok(base) => base,
759            Err(_) => continue,
760        };
761        let asked = git::Asked::borrowing(
762            &repo,
763            if repo == publication {
764                None
765            } else {
766                lent.as_deref()
767            },
768        );
769        let compared = judged_against(asked, &base, current.as_ref());
770        // Only the names asked about are counted against their remote-tracking
771        // refs, which is a process per branch per checkout that a filtered read
772        // has no reason to spend — and the ones `onevcs preserve` put on the
773        // origin are listed whatever those refs say: see `reported_branches`.
774        let listed = reported_branches(
775            &repo,
776            &preserved_here,
777            |branch| {
778                narrowed
779                    .as_ref()
780                    .is_none_or(|only| only.wants(identity, branch))
781            },
782            |branch| recorded_base(sessions, identity, branch).map(|base| base.to_string()),
783        )?;
784        for (branch, tip) in listed {
785            let key = (identity.to_owned(), branch.clone());
786            if seen.contains(&key) {
787                continue;
788            }
789            // The clone of a session something superseded holds the work that was
790            // taken over rather than the work that went on, so it answers for this
791            // name no more here than it does in `onevcs status` — and a row from it
792            // is a paste-ready publication of commits a later session already
793            // replaced.
794            if superseded_copy(sessions, &repo, identity, &branch) {
795                continue;
796            }
797            // A copy of this name standing at this commit has already been
798            // decided, in another checkout of the same identity — and the answer
799            // is a property of the two, so asking again would spend the whole
800            // decision to be told what is already known. Which copy answers is
801            // unchanged: the first one reached is the one whose row survives the
802            // deduplication below, and it is now also the only one asked.
803            if !decided.insert((identity.to_owned(), branch.clone(), tip.clone())) {
804                continue;
805            }
806            // Judged against the base the session that cut it recorded — a stack base,
807            // for a change drafted on one — and against the root only where no session
808            // recorded one, or where this checkout cannot name the one it did: the rule
809            // `onevcs status` judges the same branch by.
810            let own_base = recorded_base(sessions, identity, &branch)
811                .map(|recorded| recorded.to_string())
812                .filter(|recorded| *recorded != base && names_a_base(&repo, recorded));
813            let (base, current, compared) = match own_base {
814                Some(own) => {
815                    let current = base_commit(&publication, &own);
816                    let compared = judged_against(asked, &own, current.as_ref());
817                    (own, current, compared)
818                }
819                None => (base.clone(), current.clone(), compared.clone()),
820            };
821            // Unpublished by ref is not the same as unfinished: publication
822            // squashes, so a branch that landed is never an ancestor of the base
823            // afterwards. What answers the question is what the base's own history
824            // records about this branch — and, only where it records nothing, what
825            // the base carries of what the branch changed.
826            let recorded = crate::status::recorded_for(
827                streams,
828                identity,
829                &branch,
830                session_holding(sessions, identity, &branch),
831            );
832            let recorded = landed::Recorded {
833                change: recorded
834                    .change
835                    .or_else(|| change_url_of(asked, &compared, &branch, trailers)),
836                ..recorded
837            };
838            let change_url = recorded.change.clone();
839            // A chain of retries this host cannot follow leaves nothing decided
840            // about the branch — the same answer `onevcs status` gives, through
841            // the same reading of the same records, because a row that said `no`
842            // here and `unknown` there would be the disagreement this report
843            // exists to end. Asked before the tiers rather than after them, because
844            // whatever they found would be replaced by it: one such branch on the
845            // consuming host spent seventy-five content merges on a verdict that
846            // was then discarded.
847            let verdict = if unfollowable_chain(sessions, identity, &branch) {
848                Landed::Unknown
849            } else {
850                landed::decide(
851                    asked,
852                    &compared,
853                    current.as_ref(),
854                    &tip,
855                    &recorded,
856                    trailers,
857                )?
858            };
859            // Withheld unless every branch was asked for, and only where the
860            // work *reached the base*: that is the row whose command must not be
861            // pasted. A row nothing can decide about is the opposite case — it
862            // may be work nobody published, so withholding it is how preserved
863            // work goes missing — and it is listed, saying so, with no line that
864            // reads as "paste this".
865            let withheld = verdict.is_landed();
866            if withheld && reporting == Reporting::UnpublishedOnly {
867                continue;
868            }
869            // A stack base is not preserved work in either report: it merges branches
870            // that are landed by their own change requests and is never landed itself,
871            // so a row for it would hand a landing verb to the one branch nothing should
872            // land. Which branches are stack bases is their name, never what a branch's
873            // commits carry or what this host recorded assembling, so every host reads
874            // one ref one way and a change cut from a stack base is judged as itself.
875            if crate::stack_base::is_stack_base(&branch) {
876                continue;
877            }
878            let retirement = classified
879                .entry(branch.clone())
880                .or_insert_with(|| crate::retire::classify_offline(&census, &branch))
881                .clone();
882            // A branch that provably holds nothing beyond its base is finished work
883            // exactly as a landed one is, and is left out of the default report for
884            // the same reason: its row would be read as work left to publish.
885            let retirable = retirement
886                .as_ref()
887                .is_some_and(|found| found.class == crate::retire::RetirementClass::Retirable);
888            if retirable && reporting == Reporting::UnpublishedOnly {
889                continue;
890            }
891            // Marked seen only once it is a row this report is answering with, so
892            // that one repository's spent copy of a name cannot answer for
893            // another's: a branch published out of the checkout and re-cut in a
894            // later run has both, and the first has nothing left in it.
895            if !withheld {
896                seen.push(key);
897            }
898            let row = preserved_row(
899                &Preserved {
900                    identity,
901                    repo: asked,
902                    publication: &publication,
903                    base: &base,
904                    compared: &compared,
905                    branch: &branch,
906                    change_url,
907                    verdict,
908                    // Read through the same reader `onevcs status` reads it
909                    // through, so the two reports cannot come to disagree about
910                    // where one branch is.
911                    on_origin: crate::status::preserved_for(
912                        streams,
913                        identity,
914                        &branch,
915                        session_holding(sessions, identity, &branch),
916                    ),
917                    retirement,
918                },
919                sessions,
920                trailers,
921                scan.detail,
922                Some(tip.clone()),
923            )?;
924            if withheld {
925                withheld_rows.push(row);
926            } else {
927                rows.push(row);
928            }
929        }
930    }
931    Ok(Scanned {
932        rows,
933        withheld_rows,
934        seen,
935    })
936}
937
938/// `work` asked of every item, on at most as many threads as this host runs at
939/// once, and answered in the items' own order.
940///
941/// Scoped threads, so each borrows what the caller holds and none outlives the
942/// call; a bounded number of them, because an item here is a stream of git
943/// processes (or, for `status`, a stream file to parse) and a host with more items
944/// than cores gains nothing from starting them all at once.
945pub(crate) fn concurrently<T: Sync, R: Send>(items: &[T], work: impl Fn(&T) -> R + Sync) -> Vec<R> {
946    concurrently_on(usize::MAX, items, work)
947}
948
949/// [`concurrently`] on at most `most` threads, for work that is many small reads of
950/// one filesystem: past a few threads it goes no faster, and each thread a call
951/// starts gives the allocator an arena of its own that a long-lived caller keeps.
952pub(crate) fn concurrently_on<T: Sync, R: Send>(
953    most: usize,
954    items: &[T],
955    work: impl Fn(&T) -> R + Sync,
956) -> Vec<R> {
957    let workers = std::thread::available_parallelism()
958        .map_or(1, std::num::NonZeroUsize::get)
959        .min(most)
960        .min(items.len());
961    if workers <= 1 {
962        return items.iter().map(&work).collect();
963    }
964    let next = std::sync::atomic::AtomicUsize::new(0);
965    let mut answered: Vec<(usize, R)> = std::thread::scope(|scope| {
966        let handles: Vec<_> = (0..workers)
967            .map(|_| {
968                scope.spawn(|| {
969                    let mut mine = Vec::new();
970                    loop {
971                        let at = next.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
972                        let Some(item) = items.get(at) else {
973                            return mine;
974                        };
975                        mine.push((at, work(item)));
976                    }
977                })
978            })
979            .collect();
980        handles
981            .into_iter()
982            .flat_map(|handle| match handle.join() {
983                Ok(mine) => mine,
984                Err(panic) => std::panic::resume_unwind(panic),
985            })
986            .collect()
987    });
988    answered.sort_by_key(|(at, _)| *at);
989    answered.into_iter().map(|(_, answer)| answer).collect()
990}
991
992/// One preserved branch, and everything a row about it is read out of.
993///
994/// One value rather than seven arguments, for the reason `status`'s own decision
995/// takes one: every field is read by the one function below, and two of them
996/// transposed would produce a row that reads perfectly and names another branch's
997/// repository.
998struct Preserved<'a> {
999    identity: &'a str,
1000    /// The repository holding the branch, asked through whatever store lends it the
1001    /// base — so every read below judges against the base the row names.
1002    repo: git::Asked<'a>,
1003    publication: &'a Path,
1004    base: &'a str,
1005    compared: &'a str,
1006    branch: &'a str,
1007    change_url: Option<Url>,
1008    verdict: Landed,
1009    on_origin: Option<OnOrigin>,
1010    retirement: Option<crate::retire::Retirement>,
1011}
1012
1013/// The row one preserved branch answers with.
1014fn preserved_row(
1015    preserved: &Preserved<'_>,
1016    sessions: &[workspace::Record],
1017    trailers: &provenance::Trailers,
1018    detail: crate::Detail,
1019    tip: Option<String>,
1020) -> Result<(Option<u64>, Recoverable)> {
1021    let Preserved {
1022        identity,
1023        repo,
1024        publication,
1025        base,
1026        compared,
1027        branch,
1028        ref change_url,
1029        ref verdict,
1030        ref on_origin,
1031        ref retirement,
1032    } = *preserved;
1033    // A marker under a prefix this host does not read is still a marker:
1034    // reporting the branch as complete is what would let somebody hand
1035    // interrupted work to the verb that publishes a finished one.
1036    let unrecognized = provenance::unrecognized(repo, compared, branch, trailers)?;
1037    let kind = match unrecognized.first() {
1038        Some(_) => Provenance::IncompleteStep,
1039        None => provenance::provenance_of(repo, compared, branch, trailers)?,
1040    };
1041    let incomplete = kind == Provenance::IncompleteStep;
1042    let decision = detail == crate::Detail::Decision;
1043    let change_base = if decision {
1044        None
1045    } else {
1046        provenance::recorded_change_base(repo, compared, branch, trailers)?
1047    };
1048    // Asked before the row says anything about why the work stopped,
1049    // because a branch a live session is still writing to has not stopped.
1050    let held_by = held_by(sessions, identity, branch)?;
1051    let mut stopped = match verdict {
1052        // Nothing stopped it: it finished. Which tier says so travels with the
1053        // sentence, because "it landed" is exactly the claim that used to be an
1054        // inference and was wrong whenever the base had moved.
1055        Landed::Yes { evidence } => format!(
1056            "nothing stopped: this branch's work reached {base}, and {tier} ({commit}) says so",
1057            tier = verdict.tier(),
1058            commit = evidence.commit(),
1059        ),
1060        _ => match &held_by {
1061            Some(held) => format!(
1062                "nothing has stopped: {holder} still holds this branch and \
1063                 {because}. Its work is being made in {worktree}",
1064                holder = holder(held),
1065                because = held.holding.because(),
1066                worktree = held.worktree.display(),
1067            ),
1068            None => sessions
1069                .iter()
1070                .find(|record| *record.branch == *branch)
1071                .map(|record| {
1072                    if record.state == Lifecycle::Open {
1073                        format!("session {} was left open", record.token)
1074                    } else {
1075                        format!("session {} closed without publishing", record.token)
1076                    }
1077                })
1078                .unwrap_or_else(|| {
1079                    "no session record names this branch; it stopped before recording one"
1080                        .to_owned()
1081                }),
1082        },
1083    };
1084    if let Landed::InPart { evidence, unlanded } = verdict {
1085        stopped.push_str(&format!(
1086            ". Part of this branch's work reached {base}, and {tier} ({commit}) says so — the \
1087             {unlanded} commit(s) it has gained since are what is left to publish",
1088            tier = verdict.tier(),
1089            commit = evidence.commit(),
1090        ));
1091    }
1092    if *verdict == Landed::Unknown {
1093        stopped.push_str(&format!(
1094            ". Whether it landed cannot be decided from history: nothing records that it \
1095             reached {base} — no landing, no change request's number in the base's history, \
1096             and no landing trailer — and comparing content settles nothing here, so {base} \
1097             may already carry this work"
1098        ));
1099    }
1100    if let Some(prefix) = unrecognized.first() {
1101        stopped.push_str(&format!(
1102            ". Its provenance is written under the trailer prefix {prefix:?}, which \
1103             this host is not configured to read: set trailer_prefix in the rules \
1104             file to {prefix:?} before publishing it"
1105        ));
1106    }
1107    // The verb its provenance earns, taking the repository by path so
1108    // that the command runs wherever the row is read. A branch whose work
1109    // reached the base earns none: the row is read to be pasted, and pasting
1110    // one for finished work re-opens a change request for what the base has.
1111    let verb = if incomplete {
1112        "recover"
1113    } else {
1114        "publish-branch"
1115    };
1116    // llmlint: ignore[names_match_behavior] the name is the public
1117    // `Recoverable::recover_command` field this fills, which the recorded
1118    // surface in docs/inferred-surface.md fixes; renaming that field is a
1119    // break of the published surface, and a local that disagreed with it
1120    // would be the drift. Which verb the argv holds is the two lines above.
1121    let recover_command = match verdict.is_landed() {
1122        true => Vec::new(),
1123        false => vec![
1124            "onevcs".to_owned(),
1125            verb.to_owned(),
1126            branch.to_owned(),
1127            "--repo".to_owned(),
1128            publication.display().to_string(),
1129        ],
1130    };
1131    let answering = latest_session(sessions, identity, branch);
1132    Ok((
1133        git::committed_at(repo.path(), branch),
1134        Recoverable {
1135            identity: identity.to_owned(),
1136            branch: PreservedBranch {
1137                branch: branch.to_owned(),
1138                base: base.to_owned(),
1139                provenance: kind,
1140                change_url: change_url
1141                    .clone()
1142                    .or_else(|| change_url_of(repo, compared, branch, trailers)),
1143                change_base,
1144            },
1145            checkout: repo.path().to_path_buf(),
1146            tip,
1147            landed: verdict.clone(),
1148            stopped_because: if decision { String::new() } else { stopped },
1149            recover_command,
1150            held_by,
1151            net_negative: if decision {
1152                None
1153            } else {
1154                net_negative(repo, compared, branch)?
1155            },
1156            session: answering.map(|record| SessionToken(record.token.to_string())),
1157            labels: answering
1158                .map(|record| record.labels.clone())
1159                .unwrap_or_default(),
1160            on_origin: on_origin.clone(),
1161            retirement: retirement.clone(),
1162        },
1163    ))
1164}
1165
1166/// The session that answers for a branch: the newest record naming it.
1167///
1168/// The end of its chain of retries — a record nothing superseded — and an open one
1169/// over a closed one where two chains end apart, which is the preference `status`
1170/// makes when it picks whose evidence is the branch's. Where every record of the
1171/// branch has been superseded, which is a chain this host cannot follow, the same
1172/// preference is applied to all of them rather than answering nobody: the row still
1173/// names a session somebody can look up, and its landing is already `unknown`.
1174/// Ties are broken by token, so two reads answer the same record.
1175pub(crate) fn latest_session<'a>(
1176    sessions: &'a [workspace::Record],
1177    identity: &str,
1178    branch: &str,
1179) -> Option<&'a workspace::Record> {
1180    let named: Vec<&workspace::Record> = sessions
1181        .iter()
1182        .filter(|record| record.identity == identity && *record.branch == *branch)
1183        .collect();
1184    let ends: Vec<&workspace::Record> = named
1185        .iter()
1186        .copied()
1187        .filter(|record| record.retried_by.is_none())
1188        .collect();
1189    let candidates = if ends.is_empty() { named } else { ends };
1190    candidates
1191        .into_iter()
1192        .max_by_key(|record| (record.state == Lifecycle::Open, record.token.to_string()))
1193}
1194
1195/// The branches of one repository this report answers about.
1196///
1197/// [`git::unpublished_branches_among`] is the question it has always asked — which local
1198/// branches hold commits no `origin` remote-tracking ref has — and the preserved names
1199/// are a union with it rather than a change to it. They have to be, because a
1200/// preserving push updates the pushing repository's own `origin/<branch>`: measured
1201/// against that, a branch `onevcs preserve` had just put somewhere safe would read as
1202/// published and vanish from this report, which is the opposite of what preserving it
1203/// promised. Being on the origin under its own name is not being published — the work
1204/// has not reached the base — so the row stays, with the same `recover_command` it
1205/// carried before and the origin named beside it.
1206///
1207/// Nothing else about which branches this report covers moves: a name no
1208/// `branch-preserved` record of this identity carries is listed exactly as it was, and
1209/// the other readers of `unpublished_branches` — the close, the reclaim, and the
1210/// sweep's retention rule — go on asking whether letting a clone go would lose work,
1211/// where a branch the origin carries genuinely loses none.
1212///
1213/// Each name comes with the commit it stands at, and the union costs no process of
1214/// its own: both halves are answered by the one listing of this checkout's refs, and
1215/// a name `keep` declines is in neither.
1216fn reported_branches(
1217    repo: &Path,
1218    preserved: &BTreeSet<String>,
1219    keep: impl Fn(&str) -> bool,
1220    based_on: impl Fn(&str) -> Option<String>,
1221) -> Result<Vec<(String, String)>> {
1222    // A branch's base is the one its session recorded, so a copy of that base the
1223    // checkout holds counts as published for that branch beside the origin's own refs:
1224    // a child a session cut at a stack base's head holds nothing beyond its base, exactly
1225    // as a branch at an origin ref does, and one with commits of its own is still listed.
1226    git::unpublished_branches_among(repo, keep, preserved, based_on)
1227}
1228
1229/// The base the session that cut `branch` recorded, read off the newest record naming
1230/// it ([`latest_session`]) by [`session_base`]. `None` where no session recorded the
1231/// branch, whose base is the root.
1232pub(crate) fn recorded_base(
1233    sessions: &[workspace::Record],
1234    identity: &str,
1235    branch: &str,
1236) -> Option<Ref> {
1237    latest_session(sessions, identity, branch).map(session_base)
1238}
1239
1240/// The base one session recorded for its branch, as its publication resolves it now.
1241///
1242/// Through [`publish::standing_target`] — the read-side resolution, which asks the
1243/// remote nothing — so a stacked session whose change below has landed answers the
1244/// root it publishes onto. Where that cannot be resolved from the clone, the name the
1245/// record wrote answers.
1246pub(crate) fn session_base(record: &workspace::Record) -> Ref {
1247    match publish::standing_target(record) {
1248        Ok(target) => target.base().clone(),
1249        Err(_) => record
1250            .change_base
1251            .clone()
1252            .unwrap_or_else(|| record.base.clone()),
1253    }
1254}
1255
1256/// Whether `repo` holds `base` under either name [`base_ref`] would compare against.
1257pub(crate) fn names_a_base(repo: &Path, base: &str) -> bool {
1258    git::ref_exists(repo, &format!("refs/remotes/origin/{base}"))
1259        || git::ref_exists(repo, &format!("refs/heads/{base}"))
1260}
1261
1262/// Whether this copy of a branch belongs to a session something superseded.
1263fn superseded_copy(
1264    sessions: &[workspace::Record],
1265    repo: &Path,
1266    identity: &str,
1267    branch: &str,
1268) -> bool {
1269    sessions.iter().any(|record| {
1270        record.retried_by.is_some()
1271            && record.clone == repo
1272            && record.identity == identity
1273            && *record.branch == *branch
1274    })
1275}
1276
1277/// Whether any session of this branch names a chain of retries this host cannot
1278/// follow to an end.
1279fn unfollowable_chain(sessions: &[workspace::Record], identity: &str, branch: &str) -> bool {
1280    sessions
1281        .iter()
1282        .filter(|record| record.identity == identity && *record.branch == *branch)
1283        .any(|record| workspace::newest(record).is_err())
1284}
1285
1286/// The token of an open session holding this branch, for reading its stream.
1287fn session_holding<'a>(
1288    sessions: &'a [workspace::Record],
1289    identity: &str,
1290    branch: &str,
1291) -> Option<&'a str> {
1292    sessions
1293        .iter()
1294        .find(|record| record.identity == identity && *record.branch == *branch)
1295        .map(|record| record.token.as_ref())
1296}
1297
1298/// What is still writing to a preserved branch, when anything is.
1299///
1300/// Three ways of being live, because they are true at different times and a report
1301/// that knew fewer would offer somebody a branch mid-flight. A consumer holding a
1302/// [`Session`] keeps the process that opened it, which is the question
1303/// [`Liveness`] already answers; the CLI takes an occupancy lease per command and
1304/// outlives none of them, so what says a command is in there *now* is the lease
1305/// itself. Either one means the same thing about the branch.
1306///
1307/// Only an open session is asked about: closing one hands its branch back and means
1308/// finished, and its run root going on being occupied afterwards says nothing about
1309/// work nobody is doing on that branch any more.
1310///
1311/// The third is a branch-keyed publication, which holds the branch under no session
1312/// at all and is found by the branch rather than by a record: [`publishing::running`]
1313/// reads its lease with the same [`lock::is_occupied`] the run root's is read with,
1314/// so a publisher that died is not running by the same rule. A session is asked
1315/// first, because a held session names a token an operator can act on.
1316fn held_by(sessions: &[workspace::Record], identity: &str, branch: &str) -> Result<Option<HeldBy>> {
1317    for record in sessions {
1318        if record.identity != identity
1319            || *record.branch != *branch
1320            || record.state != Lifecycle::Open
1321        {
1322            continue;
1323        }
1324        let holding = match record.liveness() {
1325            Liveness::Live => Some(Holding::OwnerRunning),
1326            Liveness::Stale => {
1327                lock::is_occupied(&record.lease())?.then_some(Holding::RunRootOccupied)
1328            }
1329        };
1330        if let Some(holding) = holding {
1331            return Ok(Some(HeldBy {
1332                token: Some(SessionToken(record.token.to_string())),
1333                worktree: record.worktree.clone(),
1334                holding,
1335            }));
1336        }
1337    }
1338    Ok(
1339        publishing::running(identity, branch)?.map(|worktree| HeldBy {
1340            token: None,
1341            worktree,
1342            holding: Holding::PublicationRunning,
1343        }),
1344    )
1345}
1346
1347/// What holds a branch, named the way a report's sentence names it: the session by
1348/// its token, and a running publication — which has none — as what it is.
1349pub(crate) fn holder(held: &HeldBy) -> String {
1350    match &held.token {
1351        Some(token) => format!("session {}", token.0),
1352        None => "a running publication".to_owned(),
1353    }
1354}
1355
1356/// What a branch would land, when it removes more lines than it adds.
1357///
1358/// Measured from the commit the branch forked from rather than from `compared`
1359/// itself: what the branch did is what it did to the tree it started on, and against
1360/// a base that has moved on every line that base gained would read as a line this
1361/// branch removed and never touched. A branch sharing no history with the base is
1362/// not measured — there is no point it forked from to measure against.
1363fn net_negative<'a>(
1364    repo: impl Into<git::Asked<'a>>,
1365    compared: &str,
1366    branch: &str,
1367) -> Result<Option<NetNegative>> {
1368    let repo = repo.into();
1369    let Some(fork) = git::merge_base(repo, compared, branch)? else {
1370        return Ok(None);
1371    };
1372    let counted = git::line_change(repo, &fork, branch)?;
1373    // Which counts are net-negative is `NetNegative`'s own rule, asked here rather
1374    // than restated: a second spelling of it is how a row comes to be marked by one
1375    // rule and read back under another.
1376    Ok(NetNegative::new(LineChange {
1377        added: counted.added,
1378        removed: counted.removed,
1379    }))
1380}
1381
1382/// The change request a preserved branch recorded, when one was opened for it.
1383pub fn change_url_of<'a>(
1384    repo: impl Into<git::Asked<'a>>,
1385    base: &str,
1386    branch: &str,
1387    trailers: &provenance::Trailers,
1388) -> Option<Url> {
1389    let commits = git::log_messages(repo, base, branch).ok()?;
1390    commits
1391        .iter()
1392        .rev()
1393        .flat_map(|commit| commit.message.lines())
1394        .filter_map(|line| line.trim().strip_prefix(trailers.change_url()))
1395        .find_map(|value| Url::parse(value.trim()).ok())
1396}