onevcs/vcs.rs
1//! The repository side of the seam.
2
3use std::path::Path;
4
5use crate::error::{Error, Result};
6use crate::event::EventKind;
7use crate::host::{Hosting, Sha};
8use crate::landed::{self, Landed};
9use crate::publish::{Publication, PublishRequest};
10use crate::registry::Identity;
11use crate::session::{
12 HeldBy, Holding, Lifecycle, LineChange, Liveness, NetNegative, PreservedBranch, Provenance,
13 Recoverable, Scope, Session, SessionRecord, SessionRequest, SessionToken,
14};
15use crate::stream::Stream;
16use crate::workspace::{self, object};
17use crate::{git, lock, provenance, publish, store};
18
19use serde_json::json;
20use url::Url;
21
22/// Everything `onevcs` does to a repository, independent of which version
23/// control system is underneath.
24///
25/// The session record belongs here rather than beside it. It was written by [`Git`]
26/// directly for three releases, and the cost was exact: a session a supplied
27/// implementation had just opened was refused by `publish` and by `session close`
28/// as a session nobody opened, so the seam was declared and those commands went
29/// around it.
30pub trait Vcs {
31 /// Resolve an origin URL or a checkout path to the repository identity it
32 /// belongs to.
33 fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity>;
34
35 /// Open a session: a per-run clone of an execution checkout and an isolated
36 /// worktree cut from it, held under an occupancy lease.
37 fn open_session(&self, req: SessionRequest) -> Result<Session>;
38
39 /// Re-attach to a session that already exists, claiming its free occupancy
40 /// lease.
41 fn adopt_session(&self, token: SessionToken) -> Result<Session>;
42
43 /// What this implementation recorded about a session it opened.
44 fn session(&self, token: &SessionToken) -> Result<SessionRecord>;
45
46 /// Release a session's worktree and its lease, keeping its branch.
47 fn close_session(&self, token: &SessionToken) -> Result<Session>;
48
49 /// Commit the session's work onto a branch that outlives it, recording why.
50 fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch>;
51
52 /// Verify a session's branch and land it under its repository's policy.
53 ///
54 /// The one operation that reaches both interfaces — the repository side lands
55 /// the change and the host side opens and merges the change request — so the
56 /// host factory travels with the request rather than being reached for.
57 fn publish(
58 &self,
59 token: &SessionToken,
60 request: &PublishRequest,
61 hosting: &dyn Hosting,
62 ) -> Result<Publication>;
63
64 /// Every preserved-but-unpublished branch in scope, and what would land each.
65 ///
66 /// Unpublished is decided from history rather than inferred from content, and a
67 /// branch whose work reached the base is not one of these — including one the
68 /// base has moved a long way past since. The row that offered such a branch a
69 /// paste-ready `publish-branch` is what this excludes: following it re-opens a
70 /// change request for work the base already carries. A branch nothing can decide
71 /// about is still here, because it may be work nobody published; what it does not
72 /// carry is a command anybody should paste without looking.
73 fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>>;
74
75 /// Every preserved branch in scope, whatever became of its work, each saying
76 /// what did become of it and what says so.
77 ///
78 /// [`recoverable`](Self::recoverable) is this without the ones whose work is on
79 /// the base, and is what somebody asking "what is left to publish" wants. This is
80 /// what somebody asking "what became of all of it" wants, and it is the one place
81 /// a withheld branch is reported rather than silently dropped — an exclusion
82 /// nobody can see is how preserved work goes missing.
83 ///
84 /// Required rather than defaulted to [`recoverable`](Self::recoverable): the
85 /// default would answer the *narrower* question under this one's name, and an
86 /// implementation whose wider answer is the same one has only to say so.
87 fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>>;
88}
89
90/// The git implementation of [`Vcs`].
91///
92/// Stateless by design: everything it needs is the registry and the workspaces
93/// under the one state root, so two processes driving it see the same host.
94#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
95pub struct Git;
96
97impl Vcs for Git {
98 fn resolve_identity(&self, origin_or_path: &str) -> Result<Identity> {
99 let registry = store::load()?;
100 Ok(store::resolve(®istry, origin_or_path)?.identity)
101 }
102
103 fn open_session(&self, req: SessionRequest) -> Result<Session> {
104 let registry = store::load()?;
105 let (record, _stream) = workspace::open(®istry, &req)?;
106 Ok(record.session())
107 }
108
109 fn adopt_session(&self, token: SessionToken) -> Result<Session> {
110 let (record, _stream, _preserved) = workspace::adopt(&token.0)?;
111 Ok(record.session())
112 }
113
114 fn session(&self, token: &SessionToken) -> Result<SessionRecord> {
115 let record = workspace::load(&token.0)?;
116 let base = judging_base(&record)?;
117 let trailers = provenance::configured()?;
118 Ok(SessionRecord {
119 session: record.session(),
120 identity: record.identity.clone(),
121 lifecycle: record.state,
122 // Read off the branch rather than remembered: an adoption that met a
123 // dirty tree writes the marker, and a recovery clears it, so what the
124 // branch carries now is the only answer that stays true.
125 provenance: provenance::provenance_of(&record.clone, &base, &record.branch, &trailers)?,
126 retried_by: record
127 .retried_by
128 .as_ref()
129 .map(|token| SessionToken(token.to_string())),
130 })
131 }
132
133 fn close_session(&self, token: &SessionToken) -> Result<Session> {
134 let record = workspace::close(&token.0)?;
135 Ok(record.session())
136 }
137
138 fn preserve(&self, s: &Session, provenance: Provenance) -> Result<PreservedBranch> {
139 let record = workspace::load(&s.token.0)?;
140 let mut stream = Stream::open(&s.token.0)?;
141 preserve_into(&record, &mut stream, provenance)
142 }
143
144 fn publish(
145 &self,
146 token: &SessionToken,
147 request: &PublishRequest,
148 hosting: &dyn Hosting,
149 ) -> Result<Publication> {
150 publish::run_for_session(token, request, hosting)
151 }
152
153 fn recoverable(&self, scope: Scope) -> Result<Vec<Recoverable>> {
154 collect(&scope, Reporting::UnpublishedOnly)
155 }
156
157 fn preserved(&self, scope: Scope) -> Result<Vec<Recoverable>> {
158 collect(&scope, Reporting::Everything)
159 }
160}
161
162/// Commit whatever the worktree holds onto the branch, and hand the branch back to
163/// the execution checkout.
164///
165/// The session's clone is disposable, so a branch that is not copied out is lost
166/// with it. The copy is fast-forward only, to protect a concurrent session holding
167/// the same branch name, and a refusal is reported rather than swallowed: the
168/// branch a caller is then told about would name something nothing outside this
169/// session carries.
170pub fn preserve_into(
171 record: &workspace::Record,
172 stream: &mut Stream,
173 kind: Provenance,
174) -> Result<PreservedBranch> {
175 let trailers = provenance::configured()?;
176 if git::is_dirty(&record.worktree)? {
177 git::add_all(&record.worktree)?;
178 let message = match kind {
179 Provenance::Complete => format!("chore: preserve work on {}", record.branch),
180 Provenance::IncompleteStep => provenance::incomplete_message(
181 &format!("work on {}", record.branch),
182 record.change_base.as_deref(),
183 &trailers,
184 ),
185 };
186 let sha = git::commit(&record.worktree, &message)?;
187 stream.emit(
188 EventKind::CommitPreserved,
189 object(json!({
190 "branch": record.branch,
191 "sha": sha,
192 "provenance": spell_provenance(kind),
193 })),
194 );
195 }
196
197 let copied = git::copy_branch(&record.clone, &record.execution_checkout, &record.branch)?;
198 if !copied {
199 return Err(Error::Invalid {
200 reason: format!(
201 "the execution checkout {} refused branch {:?}; it holds work this session's \
202 clone does not, so nothing outside the session carries this branch",
203 record.execution_checkout.display(),
204 record.branch
205 ),
206 });
207 }
208
209 let base = judging_base(record)?;
210 Ok(PreservedBranch {
211 branch: record.branch.to_string(),
212 base: record.base.to_string(),
213 provenance: provenance::provenance_of(&record.clone, &base, &record.branch, &trailers)?,
214 change_url: None,
215 change_base: record.change_base.as_ref().map(ToString::to_string),
216 })
217}
218
219/// How a provenance kind is spelled in an event payload.
220pub fn spell_provenance(kind: Provenance) -> &'static str {
221 match kind {
222 Provenance::Complete => "complete",
223 Provenance::IncompleteStep => "incomplete-step",
224 }
225}
226
227/// The ref a session's branch is judged against: the base its publication resolves
228/// from what the clone holds now — the branch below for a stacked session, until the
229/// clone has seen the root carry it — as [`base_ref`] spells it.
230fn judging_base(record: &workspace::Record) -> Result<String> {
231 let target = publish::standing_target(record)?;
232 Ok(base_ref(&record.clone, target.base()))
233}
234
235/// The ref a branch's commits are counted against: the remote-tracking base when
236/// the repository has one, and the local base otherwise.
237pub fn base_ref(repo: &Path, base: &str) -> String {
238 let remote = format!("origin/{base}");
239 if git::ref_exists(repo, &format!("refs/remotes/{remote}")) {
240 remote
241 } else {
242 base.to_owned()
243 }
244}
245
246/// What a branch held in `repo` is judged against: the identity's base *now*,
247/// named by a commit `repo` can reach.
248///
249/// A checkout's remote-tracking refs are frozen at its last fetch, so its own
250/// `origin/main` can be many merges behind. Judged against that, a branch whose work
251/// landed weeks ago still looks like work nobody published — and a name whose meaning
252/// is spent still looks like a name that means something. Which is why the repository
253/// is asked *through* the object store of the checkout every publication
254/// fast-forwards: the commit the base stands at is one that checkout has by
255/// construction, so a copy that never fetched it can still read it. What is left over
256/// — a repository that cannot reach the base even so — is judged against its own view
257/// and reported as behind, which is what keeps the tiers below a record from closing
258/// the question from a history that stops short of the evidence.
259// The two states this answers with are deliberately one type, as `base_ref` beside it
260// and `Landing::compared_change_base` already are: what comes back is a *comparison
261// target*, and git resolves a ref name and a commit id identically at every call that
262// takes one. Distinguishing them in the type would only oblige each of those call
263// sites to collapse the distinction again, and the thing that must not be confused
264// with either — a branch name this crate writes — is `Ref`, which neither of these is.
265// llmlint: ignore[invalid_states_unrepresentable] a comparison target git resolves either way
266pub fn judged_against<'a>(
267 repo: impl Into<git::Asked<'a>>,
268 base: &str,
269 current: Option<&Sha>,
270) -> String {
271 let repo = repo.into();
272 match current {
273 Some(sha) if git::has_commit(repo, sha) => sha.0.clone(),
274 _ => base_ref(repo.path(), base),
275 }
276}
277
278/// The commit a checkout's base ref stands at.
279///
280/// Asked of the publication checkout, which is the one every publication
281/// fast-forwards and therefore the freshest view of the base this host keeps — not
282/// a guarantee of the newest there is, which only the remote can answer for.
283pub fn base_commit(checkout: &Path, base: &str) -> Option<Sha> {
284 git::tip(checkout, &base_ref(checkout, base)).map(Sha)
285}
286
287/// Which branches a report is asked to carry.
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289pub enum Reporting {
290 /// Every branch whose work is not known to have reached the base, which is what
291 /// [`Vcs::recoverable`] answers.
292 UnpublishedOnly,
293 /// Every preserved branch, whatever became of its work — the ones that reached
294 /// the base included, which is what [`Vcs::preserved`] answers.
295 Everything,
296}
297
298/// Every preserved branch in scope, newest first, and whether its work landed.
299///
300/// Read-only in the strongest sense: it opens repositories to ask questions, writes
301/// nothing, and takes no lease, so it is safe to run beside live work — which is
302/// exactly when somebody reaches for it.
303pub fn collect(scope: &Scope, reporting: Reporting) -> Result<Vec<Recoverable>> {
304 let registry = store::load()?;
305 let (rules, _source) = crate::policy::load(®istry)?;
306 let trailers = provenance::from_rules(&rules);
307 let sessions = workspace::all()?;
308 // What this host's own runs recorded, read once: the change request each branch
309 // opened and any landing seen for it. A gap in the streams is not reported here —
310 // this report has nowhere to say one — and costs only certainty: a branch whose
311 // record could not be read falls to a lower tier and is judged from the base's own
312 // history instead.
313 let streams = crate::status::recorded_streams(&mut Vec::new())?;
314 let wanted = match scope {
315 Scope::All => None,
316 Scope::Repo(repo) => Some(store::resolve(®istry, repo)?.key),
317 };
318
319 let mut rows: Vec<(Option<u64>, Recoverable)> = Vec::new();
320 // The branches whose work reached the base, kept aside rather than dropped: they
321 // are what `preserved` adds, and a copy of a name whose work the base already
322 // carries must not answer for a copy of it elsewhere that still holds work, so
323 // they join the answer only where no such copy did.
324 let mut withheld_rows: Vec<(Option<u64>, Recoverable)> = Vec::new();
325 let mut seen: Vec<(String, String)> = Vec::new();
326 // Once per identity rather than once per checkout of one, because the places a
327 // branch of it can be are a property of the identity — and they are read from
328 // the one list the verbs that go on to *land* a branch read, so this report
329 // cannot come to offer branches nothing can reach, or miss ones something can.
330 let mut identities: Vec<&str> = registry
331 .checkouts
332 .values()
333 .map(|checkout| checkout.identity.as_str())
334 .collect();
335 identities.sort_unstable();
336 identities.dedup();
337 for identity in identities {
338 if wanted.as_ref().is_some_and(|key| key != identity) {
339 continue;
340 }
341 let resolution = store::resolve(®istry, identity)?;
342 let publication = resolution.publication.clone();
343 let current = git::default_branch(&publication, "origin")
344 .ok()
345 .and_then(|base| base_commit(&publication, &base));
346 // Every publication fast-forwards this checkout, so it is where a landing's
347 // evidence is — and lending its objects is what lets a checkout that has not
348 // fetched since read the commit that carries them.
349 let lent = git::objects_dir(&publication).ok();
350 for repo in workspace::checkouts_of(®istry, &resolution)? {
351 if !git::is_repo(&repo) {
352 continue;
353 }
354 let base = match git::default_branch(&repo, "origin") {
355 Ok(base) => base,
356 Err(_) => continue,
357 };
358 let asked = git::Asked::borrowing(&repo, lent.as_deref());
359 let compared = judged_against(asked, &base, current.as_ref());
360 for branch in git::unpublished_branches(&repo)? {
361 let key = (identity.to_owned(), branch.clone());
362 if seen.contains(&key) {
363 continue;
364 }
365 // The clone of a session something superseded holds the work that was
366 // taken over rather than the work that went on, so it answers for this
367 // name no more here than it does in `onevcs status` — and a row from it
368 // is a paste-ready publication of commits a later session already
369 // replaced.
370 if superseded_copy(&sessions, &repo, identity, &branch) {
371 continue;
372 }
373 // Unpublished by ref is not the same as unfinished: publication
374 // squashes, so a branch that landed is never an ancestor of the base
375 // afterwards. What answers the question is what the base's own history
376 // records about this branch — and, only where it records nothing, what
377 // the base carries of what the branch changed.
378 let recorded = crate::status::recorded_for(
379 &streams,
380 identity,
381 &branch,
382 session_holding(&sessions, identity, &branch),
383 );
384 let recorded = landed::Recorded {
385 change: recorded
386 .change
387 .or_else(|| change_url_of(asked, &compared, &branch, &trailers)),
388 ..recorded
389 };
390 let change_url = recorded.change.clone();
391 let mut verdict = landed::decide(
392 asked,
393 &compared,
394 current.as_ref(),
395 &branch,
396 &recorded,
397 &trailers,
398 )?;
399 // A chain of retries this host cannot follow leaves nothing decided
400 // about the branch — the same answer `onevcs status` gives, through
401 // the same reading of the same records, because a row that said `no`
402 // here and `unknown` there would be the disagreement this report
403 // exists to end.
404 if unfollowable_chain(&sessions, identity, &branch) {
405 verdict = Landed::Unknown;
406 }
407 // Withheld unless every branch was asked for, and only where the
408 // work *reached the base*: that is the row whose command must not be
409 // pasted. A row nothing can decide about is the opposite case — it
410 // may be work nobody published, so withholding it is how preserved
411 // work goes missing — and it is listed, saying so, with no line that
412 // reads as "paste this".
413 let withheld = verdict.is_landed();
414 if withheld && reporting == Reporting::UnpublishedOnly {
415 continue;
416 }
417 // Marked seen only once it is a row this report is answering with, so
418 // that one repository's spent copy of a name cannot answer for
419 // another's: a branch published out of the checkout and re-cut in a
420 // later run has both, and the first has nothing left in it.
421 if !withheld {
422 seen.push(key);
423 }
424 let row = preserved_row(
425 &Preserved {
426 identity,
427 repo: asked,
428 publication: &publication,
429 base: &base,
430 compared: &compared,
431 branch: &branch,
432 change_url,
433 verdict,
434 },
435 &sessions,
436 &trailers,
437 )?;
438 if withheld {
439 withheld_rows.push(row);
440 } else {
441 rows.push(row);
442 }
443 }
444 }
445 }
446 // A landed copy answers only where nothing holding work under that name did.
447 let mut kept: Vec<(String, String)> = Vec::new();
448 for (at, row) in withheld_rows {
449 let key = (row.identity.clone(), row.branch.branch.clone());
450 if seen.contains(&key) || kept.contains(&key) {
451 continue;
452 }
453 kept.push(key);
454 rows.push((at, row));
455 }
456 rows.sort_by_key(|(at, row)| {
457 (
458 std::cmp::Reverse(at.unwrap_or(0)),
459 row.branch.branch.clone(),
460 )
461 });
462 Ok(rows.into_iter().map(|(_, row)| row).collect())
463}
464
465/// One preserved branch, and everything a row about it is read out of.
466///
467/// One value rather than seven arguments, for the reason `status`'s own decision
468/// takes one: every field is read by the one function below, and two of them
469/// transposed would produce a row that reads perfectly and names another branch's
470/// repository.
471struct Preserved<'a> {
472 identity: &'a str,
473 /// The repository holding the branch, asked through whatever store lends it the
474 /// base — so every read below judges against the base the row names.
475 repo: git::Asked<'a>,
476 publication: &'a Path,
477 base: &'a str,
478 compared: &'a str,
479 branch: &'a str,
480 change_url: Option<Url>,
481 verdict: Landed,
482}
483
484/// The row one preserved branch answers with.
485fn preserved_row(
486 preserved: &Preserved<'_>,
487 sessions: &[workspace::Record],
488 trailers: &provenance::Trailers,
489) -> Result<(Option<u64>, Recoverable)> {
490 let Preserved {
491 identity,
492 repo,
493 publication,
494 base,
495 compared,
496 branch,
497 ref change_url,
498 ref verdict,
499 } = *preserved;
500 // A marker under a prefix this host does not read is still a marker:
501 // reporting the branch as complete is what would let somebody hand
502 // interrupted work to the verb that publishes a finished one.
503 let unrecognized = provenance::unrecognized(repo, compared, branch, trailers)?;
504 let kind = match unrecognized.first() {
505 Some(_) => Provenance::IncompleteStep,
506 None => provenance::provenance_of(repo, compared, branch, trailers)?,
507 };
508 let incomplete = kind == Provenance::IncompleteStep;
509 let change_base = provenance::recorded_change_base(repo, compared, branch, trailers)?;
510 // Asked before the row says anything about why the work stopped,
511 // because a branch a live session is still writing to has not stopped.
512 let held_by = held_by(sessions, identity, branch)?;
513 let mut stopped = match verdict {
514 // Nothing stopped it: it finished. Which tier says so travels with the
515 // sentence, because "it landed" is exactly the claim that used to be an
516 // inference and was wrong whenever the base had moved.
517 Landed::Yes { evidence } => format!(
518 "nothing stopped: this branch's work reached {base}, and {tier} ({commit}) says so",
519 tier = verdict.tier(),
520 commit = evidence.commit(),
521 ),
522 _ => match &held_by {
523 Some(held) => format!(
524 "nothing has stopped: session {token} still holds this branch and \
525 {because}. Its work is being made in {worktree}",
526 token = held.token.0,
527 because = held.holding.because(),
528 worktree = held.worktree.display(),
529 ),
530 None => sessions
531 .iter()
532 .find(|record| *record.branch == *branch)
533 .map(|record| {
534 if record.state == Lifecycle::Open {
535 format!("session {} was left open", record.token)
536 } else {
537 format!("session {} closed without publishing", record.token)
538 }
539 })
540 .unwrap_or_else(|| {
541 "no session record names this branch; it stopped before recording one"
542 .to_owned()
543 }),
544 },
545 };
546 if let Landed::InPart { evidence, unlanded } = verdict {
547 stopped.push_str(&format!(
548 ". Part of this branch's work reached {base}, and {tier} ({commit}) says so — the \
549 {unlanded} commit(s) it has gained since are what is left to publish",
550 tier = verdict.tier(),
551 commit = evidence.commit(),
552 ));
553 }
554 if *verdict == Landed::Unknown {
555 stopped.push_str(&format!(
556 ". Whether it landed cannot be decided from history: nothing records that it \
557 reached {base} — no landing, no change request's number in the base's history, \
558 and no landing trailer — and comparing content settles nothing here, so {base} \
559 may already carry this work"
560 ));
561 }
562 if let Some(prefix) = unrecognized.first() {
563 stopped.push_str(&format!(
564 ". Its provenance is written under the trailer prefix {prefix:?}, which \
565 this host is not configured to read: set trailer_prefix in the rules \
566 file to {prefix:?} before publishing it"
567 ));
568 }
569 // The verb its provenance earns, taking the repository by path so
570 // that the command runs wherever the row is read. A branch whose work
571 // reached the base earns none: the row is read to be pasted, and pasting
572 // one for finished work re-opens a change request for what the base has.
573 let verb = if incomplete {
574 "recover"
575 } else {
576 "publish-branch"
577 };
578 // llmlint: ignore[names_match_behavior] the name is the public
579 // `Recoverable::recover_command` field this fills, which the recorded
580 // surface in docs/inferred-surface.md fixes; renaming that field is a
581 // break of the published surface, and a local that disagreed with it
582 // would be the drift. Which verb the argv holds is the two lines above.
583 let recover_command = match verdict.is_landed() {
584 true => Vec::new(),
585 false => vec![
586 "onevcs".to_owned(),
587 verb.to_owned(),
588 branch.to_owned(),
589 "--repo".to_owned(),
590 publication.display().to_string(),
591 ],
592 };
593 Ok((
594 git::committed_at(repo.path(), branch),
595 Recoverable {
596 identity: identity.to_owned(),
597 branch: PreservedBranch {
598 branch: branch.to_owned(),
599 base: base.to_owned(),
600 provenance: kind,
601 change_url: change_url
602 .clone()
603 .or_else(|| change_url_of(repo, compared, branch, trailers)),
604 change_base,
605 },
606 checkout: repo.path().to_path_buf(),
607 landed: verdict.clone(),
608 stopped_because: stopped,
609 recover_command,
610 held_by,
611 net_negative: net_negative(repo, compared, branch)?,
612 },
613 ))
614}
615
616/// Whether this copy of a branch belongs to a session something superseded.
617fn superseded_copy(
618 sessions: &[workspace::Record],
619 repo: &Path,
620 identity: &str,
621 branch: &str,
622) -> bool {
623 sessions.iter().any(|record| {
624 record.retried_by.is_some()
625 && record.clone == repo
626 && record.identity == identity
627 && *record.branch == *branch
628 })
629}
630
631/// Whether any session of this branch names a chain of retries this host cannot
632/// follow to an end.
633fn unfollowable_chain(sessions: &[workspace::Record], identity: &str, branch: &str) -> bool {
634 sessions
635 .iter()
636 .filter(|record| record.identity == identity && *record.branch == *branch)
637 .any(|record| workspace::newest(record).is_err())
638}
639
640/// The token of an open session holding this branch, for reading its stream.
641fn session_holding<'a>(
642 sessions: &'a [workspace::Record],
643 identity: &str,
644 branch: &str,
645) -> Option<&'a str> {
646 sessions
647 .iter()
648 .find(|record| record.identity == identity && *record.branch == *branch)
649 .map(|record| record.token.as_ref())
650}
651
652/// The live session still writing to a preserved branch, when one is.
653///
654/// Two ways of being live, because the two are true at different times and a report
655/// that knew only one would offer somebody a branch mid-flight. A consumer holding a
656/// [`Session`] keeps the process that opened it, which is the question
657/// [`Liveness`] already answers; the CLI takes an occupancy lease per command and
658/// outlives none of them, so what says a command is in there *now* is the lease
659/// itself. Either one means the same thing about the branch.
660///
661/// Only an open session is asked about: closing one hands its branch back and means
662/// finished, and its run root going on being occupied afterwards says nothing about
663/// work nobody is doing on that branch any more.
664fn held_by(sessions: &[workspace::Record], identity: &str, branch: &str) -> Result<Option<HeldBy>> {
665 for record in sessions {
666 if record.identity != identity
667 || *record.branch != *branch
668 || record.state != Lifecycle::Open
669 {
670 continue;
671 }
672 let holding = match record.liveness() {
673 Liveness::Live => Some(Holding::OwnerRunning),
674 Liveness::Stale => {
675 lock::is_occupied(&record.lease())?.then_some(Holding::RunRootOccupied)
676 }
677 };
678 if let Some(holding) = holding {
679 return Ok(Some(HeldBy {
680 token: SessionToken(record.token.to_string()),
681 worktree: record.worktree.clone(),
682 holding,
683 }));
684 }
685 }
686 Ok(None)
687}
688
689/// What a branch would land, when it removes more lines than it adds.
690///
691/// Measured from the commit the branch forked from rather than from `compared`
692/// itself: what the branch did is what it did to the tree it started on, and against
693/// a base that has moved on every line that base gained would read as a line this
694/// branch removed and never touched. A branch sharing no history with the base is
695/// not measured — there is no point it forked from to measure against.
696fn net_negative<'a>(
697 repo: impl Into<git::Asked<'a>>,
698 compared: &str,
699 branch: &str,
700) -> Result<Option<NetNegative>> {
701 let repo = repo.into();
702 let Some(fork) = git::merge_base(repo, compared, branch)? else {
703 return Ok(None);
704 };
705 let counted = git::line_change(repo, &fork, branch)?;
706 // Which counts are net-negative is `NetNegative`'s own rule, asked here rather
707 // than restated: a second spelling of it is how a row comes to be marked by one
708 // rule and read back under another.
709 Ok(NetNegative::new(LineChange {
710 added: counted.added,
711 removed: counted.removed,
712 }))
713}
714
715/// The change request a preserved branch recorded, when one was opened for it.
716pub fn change_url_of<'a>(
717 repo: impl Into<git::Asked<'a>>,
718 base: &str,
719 branch: &str,
720 trailers: &provenance::Trailers,
721) -> Option<Url> {
722 let commits = git::log_messages(repo, base, branch).ok()?;
723 commits
724 .iter()
725 .rev()
726 .flat_map(|commit| commit.message.lines())
727 .filter_map(|line| line.trim().strip_prefix(trailers.change_url()))
728 .find_map(|value| Url::parse(value.trim()).ok())
729}