Skip to main content

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(&registry, 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(&registry, &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(&registry)?;
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(&registry, 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(&registry, 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(&registry, &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}