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(®istry, 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(®istry, &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(®istry)?;
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(®istry, 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: ®istry,
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}