Skip to main content

onevcs/
session.rs

1//! A session: the clone and worktree a change is made in — a pooled slot the host
2//! keeps warm, or one cut for this run — and what is left behind when one does not
3//! finish.
4
5use std::collections::BTreeMap;
6use std::path::PathBuf;
7
8use serde::{Deserialize, Serialize};
9use url::Url;
10
11use crate::landed::Landed;
12use crate::workspaces::Bound;
13
14/// What to open a session over.
15#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
16pub struct SessionRequest {
17    /// The repository: an identity key, a registered alias, or a path.
18    pub repo: String,
19    /// The branch to work on.
20    ///
21    /// A name that already exists — in a checkout of this identity, or on origin —
22    /// is **continued**: the session's worktree is opened at that branch's tip and
23    /// carries the work already on it. A name nothing carries yet is cut fresh from
24    /// [`base`](Self::base). Absent means one is derived, which is always a fresh
25    /// cut.
26    pub branch: Option<String>,
27    /// A name the caller wants a branch **cut** at, which is a different question
28    /// from [`branch`](Self::branch).
29    ///
30    /// `branch` names a branch to *continue* — anything already carrying that name
31    /// is opened at its tip — so a rendered name cannot arrive there: a collision
32    /// would silently continue somebody else's branch. This one is a *proposal*, and
33    /// this crate does all of the work of turning it into a branch: it is sanitized
34    /// into a name git accepts, the host's [branch prefix](crate::branches) is put
35    /// in front of it, and the first free of `<name>`, `<name>-2`, `<name>-3`, … is
36    /// taken — searched against this identity's local branches and its origin's. A
37    /// proposal nothing usable is left of, and one no suffix can make free, are
38    /// refused naming the proposal rather than silently replaced by a derived name.
39    ///
40    /// Absent means a name is derived from the session's own token, exactly as
41    /// before. Naming this **and** `branch` is refused: they are two answers to one
42    /// question.
43    // llmlint: ignore[invalid_states_unrepresentable] a proposal is arbitrary text by
44    // design, for the reason `SessionOpenArgs::branch_name` states: there is no
45    // invalid value here to make unrepresentable, only one this crate sanitizes. The
46    // amendment in docs/contract.md also fixes the declared shape as
47    // `Option<String>`, and two consuming repositories build against that spelling.
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub branch_name: Option<String>,
50    /// The branch prefix this one open cuts against, over everything the host
51    /// configures — the highest of the three layers [`branches`](crate::branches)
52    /// resolves. Absent means the host's resolution; `Some("")` is this open saying
53    /// it wants none.
54    ///
55    /// It reaches only branches this open **cuts**: a branch named by
56    /// [`branch`](Self::branch) is continued or cut at exactly that name, prefixed
57    /// by nothing.
58    // llmlint: ignore[invalid_states_unrepresentable] a newtype here would have to
59    // ask git whether a ref may start with the value, and the answer has to name the
60    // layer that set it — which a `TryFrom` cannot, because the same value reaches
61    // this field from a flag, from the environment and from the host's file.
62    // `branches::resolve` is that one boundary, and `branch` beside this field is an
63    // `Option<String>` for the same contract reason.
64    #[serde(default, skip_serializing_if = "Option::is_none")]
65    pub branch_prefix: Option<String>,
66    /// The branch this session's work is merged with and published into.
67    ///
68    /// For a branch cut fresh it is also the point the branch starts from. For a
69    /// branch that already exists it is the integration target and nothing else:
70    /// what is merged in when it has moved on, and what the publication is compared
71    /// against. Absent means the identity's registered base. Naming the session's
72    /// own branch here is refused — it would publish the branch into itself.
73    pub base: Option<String>,
74    /// Which registered checkout to clone from. Absent means the identity's
75    /// default execution checkout.
76    pub execution_checkout: Option<String>,
77    /// The pool size this one open places against, over everything the host
78    /// configures: `0` places this session fresh under `runs/` — and still spends the
79    /// overflow, because the cap is about disk — and `N` may cut a slot while fewer
80    /// than `N` exist. It removes no slot. Absent means the host's resolution.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub pool: Option<u32>,
83    /// The overflow bound this one open is admitted against, over everything the
84    /// host configures; `Bound::Unlimited` is how one open opts out of the cap.
85    /// Absent means the host's resolution.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub overflow: Option<Bound>,
88    /// What the caller wants the session record to say about who opened it: the
89    /// run, the node, the launching process — whatever it takes to find this
90    /// session again without joining anything else. A key is `[A-Za-z0-9_-]+` and a
91    /// value is one line; what a key *means* is the caller's to declare. Stored on
92    /// the record, reported by `session holders` and on every `recoverable` row,
93    /// and what `--label` filters both by. A resumed session takes each key the
94    /// request names over the one it had and keeps the rest.
95    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
96    pub labels: BTreeMap<String, String>,
97    /// Refuse a continued branch whose base conflicts with it, rather than open the
98    /// session with the merge left in progress.
99    ///
100    /// `false` — the default, and what a request that never names it reads as — opens
101    /// the session over the conflicted merge: git's markers in the worktree, the
102    /// unmerged paths in its index and `MERGE_HEAD` naming the base, with
103    /// [`Session::conflict`] saying so. `true` aborts that merge where it was
104    /// attempted and refuses the open as [`Error::SyncConflict`](crate::Error), with
105    /// the branch untouched — which is what every open did before this field existed.
106    /// Omitted when `false`.
107    // llmlint: ignore[invalid_states_unrepresentable] the contract amendment fixes this
108    // field as `refuse_conflicts: bool`, omitted when false, and onepipeline's next node
109    // is written against exactly that name and type; both values are valid requests, and
110    // the crate turns it into its own two-variant choice where the open acts on it.
111    #[serde(default, skip_serializing_if = "std::ops::Not::not")]
112    pub refuse_conflicts: bool,
113}
114
115/// The handle a session is adopted, published, and closed by.
116#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
117#[serde(transparent)]
118pub struct SessionToken(pub String);
119
120/// An open session: a per-run shared clone plus a worktree, held under an
121/// occupancy lease.
122#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
123pub struct Session {
124    /// The handle this session is addressed by.
125    pub token: SessionToken,
126    /// The worktree the change is made in.
127    pub worktree: PathBuf,
128    /// The branch the worktree has checked out.
129    pub branch: String,
130    /// The branch this session's work is merged with and published into, which for
131    /// a branch cut fresh is also the one it was cut from.
132    pub base: String,
133    /// The merge this session opened with still in progress, where the branch it
134    /// continued conflicted with its base.
135    ///
136    /// `None` for every session that opened on a clean tree — a fresh cut, a
137    /// continuation that merged cleanly, and every handle a call other than an open
138    /// returns. Omitted when `None`, so a session without one serializes exactly as
139    /// it did before the field existed.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub conflict: Option<OpenConflict>,
142}
143
144/// A merge left in progress in a session's worktree, for the session to conclude.
145///
146/// What a continued branch whose base conflicts with it opens with unless the
147/// request [refuses conflicts](SessionRequest::refuse_conflicts). Publication refuses
148/// the session as a sync conflict until the merge is concluded — resolved, staged and
149/// committed in the worktree — and every teardown aborts it rather than committing
150/// it.
151// llmlint: ignore-block[invalid_states_unrepresentable] the contract amendment fixes
152// these three fields as `Vec<String>` and `String`, the way `Recoverable` and
153// `PreservedBranch` spell a sha and a path list, and onepipeline builds against exactly
154// that spelling; every value is git's own answer to a command the open ran, and a path
155// git prints is lossily decoded text, as `git::Conflict::paths` already carries it.
156#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
157pub struct OpenConflict {
158    /// The paths git left unmerged, in the order it listed them. Never empty.
159    pub paths: Vec<String>,
160    /// The full sha of the integration target that was merged into the branch.
161    pub base_commit: String,
162    /// The full sha the branch stood at before the merge.
163    pub branch_tip: String,
164}
165// llmlint: ignore-end[invalid_states_unrepresentable]
166
167/// Where a session is in its life.
168#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
169#[serde(rename_all = "kebab-case")]
170pub enum Lifecycle {
171    /// It has a worktree and its work has not been published or released.
172    Open,
173    /// Its worktree is gone and its branch has been handed back. The session is
174    /// still addressable, because the branch it names is still the only record of
175    /// the work.
176    Closed,
177}
178
179/// Everything the implementation that opened a session records about it.
180///
181/// A [`Session`] is the handle a caller was given; this is what the repository side
182/// knows about it afterwards, and it is why the record had to come through the
183/// interface: which repository the session belongs to, whether it is still open,
184/// and whether its branch carries an incomplete-step marker are all questions a
185/// command asks between opening a session and publishing it.
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct SessionRecord {
188    /// The session itself.
189    pub session: Session,
190    /// The identity key the session belongs to.
191    // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
192    // everywhere this contract spells one — `Recoverable::identity`, `Identity::origin`,
193    // and the registry document's own map key — so a newtype here would disagree with the
194    // types it names and add a public item the contract does not. Every value written here
195    // came out of a registry the implementation resolved against, which is where the key
196    // is normalized and decided.
197    pub identity: String,
198    /// Where the session is in its life.
199    pub lifecycle: Lifecycle,
200    /// What the session's branch carries now — an adopted session that was left
201    /// dirty carries [`Provenance::IncompleteStep`], and must pass the merge-path
202    /// merge path through `onevcs recover` before it may be published.
203    pub provenance: Provenance,
204    /// The session that continued this one's branch, once one has.
205    ///
206    /// A branch a run left behind is picked up by the next session over the same
207    /// name, and this is the link between the two — written onto the *older*
208    /// record when the newer one opens. It is the answer to "which of these two
209    /// copies of one branch is the work", which nothing else on either record
210    /// gives, and everything that reports what became of a session or a branch
211    /// follows it to the newest record before it answers.
212    ///
213    /// Absent on every session nothing has superseded, which is most of them.
214    #[serde(default, skip_serializing_if = "Option::is_none")]
215    pub retried_by: Option<SessionToken>,
216}
217
218/// One session that holds a repository's workspace, as an enumeration reports it.
219///
220/// A [`SessionRecord`] answers about a session somebody already has the token for;
221/// this answers the question before that one — *who is in this repository* — for
222/// every session the host has recorded that anybody is left to answer for: one
223/// whose owner process is still there, and one a live process is still working
224/// inside. A record with neither is forgotten by the enumeration rather than
225/// reported above every launch. It is what `onevcs session holders` prints, so a
226/// caller reading the command's JSON and a caller embedding
227/// [`crate::session_holders`] read the same shape.
228#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
229pub struct SessionHolder {
230    /// The handle this session is addressed by, and the one every command that
231    /// takes a session takes.
232    pub token: SessionToken,
233    /// The identity key the session belongs to.
234    pub identity: String,
235    /// The branch its worktree has checked out.
236    pub branch: String,
237    /// The worktree the change is made in. A closed session's is gone; its branch
238    /// is not.
239    pub worktree: PathBuf,
240    /// The process that opened it, so a diagnostic can name who to look for.
241    pub owner_pid: u32,
242    /// Where the session is in its life. Spelled `state` because that is the key
243    /// `onevcs session holders --json` prints it under.
244    pub state: Lifecycle,
245    /// Whether the process that opened it is still there.
246    pub liveness: Liveness,
247    /// The labels the session was opened with. Always written — an empty object for
248    /// a session opened without any, and for every record written before there was
249    /// a field to write.
250    #[serde(default)]
251    pub labels: BTreeMap<String, String>,
252}
253
254/// Whether a session's owner is still running, which is what makes its lease real.
255///
256/// A recorded pid alone cannot answer this — the OS reuses pids, so a later process
257/// wearing a dead session's number would read as its owner. The answer is the pid
258/// *and* that process's creation identity, which is why it is reported rather than
259/// left for a caller to derive from [`SessionHolder::owner_pid`].
260#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
261#[serde(rename_all = "lowercase")]
262pub enum Liveness {
263    /// The session is open and the process that opened it is that same process,
264    /// still running.
265    Live,
266    /// Anything else: the session is closed, its owner exited, or its pid now
267    /// belongs to a different process.
268    Stale,
269}
270
271impl Liveness {
272    /// The word this crate reports it as, on the command line and in JSON.
273    pub fn as_str(&self) -> &'static str {
274        match self {
275            Self::Live => "live",
276            Self::Stale => "stale",
277        }
278    }
279}
280
281/// Why a branch was preserved, and therefore what recovering it must do.
282#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
283#[serde(rename_all = "kebab-case")]
284pub enum Provenance {
285    /// The step finished; the branch is ready to publish as it stands.
286    Complete,
287    /// The step did not finish. Its work was committed behind an incomplete-step
288    /// marker and must pass its merge path before it may be published.
289    IncompleteStep,
290}
291
292/// A branch that holds work outside its session.
293#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
294pub struct PreservedBranch {
295    /// The branch the work is on.
296    pub branch: String,
297    /// The base it was cut from.
298    pub base: String,
299    /// Why it was preserved.
300    pub provenance: Provenance,
301    /// The change request it belongs to, when one was opened. Host-neutral: a
302    /// GitHub pull request and a GitLab merge request both land here.
303    pub change_url: Option<Url>,
304    /// The base that change request targets, which for a stacked branch is the
305    /// branch below it rather than the root.
306    pub change_base: Option<String>,
307}
308
309/// Which preserved work to look for.
310#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
311#[serde(rename_all = "kebab-case")]
312pub enum Scope {
313    /// Every registered identity.
314    All,
315    /// One repository: an identity key, a registered alias, or a path.
316    Repo(String),
317}
318
319/// Which of the preserved work in scope a report is asked to answer with.
320///
321/// Both halves are `and`ed with each other and with the [`Scope`]: a row is answered
322/// with when its branch is one the named sessions hold or held *and* the labels of
323/// the session that answers for it carry every pair asked for. Empty asks nothing,
324/// which is what every read before there was a selection asked — so [`Default`] is
325/// the whole report.
326///
327/// A session token no record on this host names is refused by name rather than
328/// answered with an empty report: "nothing of that session is left to publish" and
329/// "there is no such session" are different answers to act on. A label pair nothing
330/// carries is the first of those, and answers empty.
331#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
332pub struct Selection {
333    /// The sessions whose branches are asked about, by token.
334    #[serde(default, skip_serializing_if = "Vec::is_empty")]
335    pub sessions: Vec<SessionToken>,
336    /// The pairs every answered row's labels must carry.
337    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
338    pub labels: BTreeMap<String, String>,
339}
340
341impl Selection {
342    /// Whether this asks anything at all.
343    pub fn is_empty(&self) -> bool {
344        self.sessions.is_empty() && self.labels.is_empty()
345    }
346}
347
348/// Preserved work, whether it reached its base, and what would land it.
349///
350/// Read through the conversion below, which is where the one thing this row could
351/// contradict itself about is settled: a row saying the work reached the base *and*
352/// carrying the argv that publishes it again does not deserialize at all. The row is
353/// read to be pasted, and the whole reason the answer is on it is that pasting one
354/// for finished work re-opens a change request for what the base has.
355// llmlint: ignore[invalid_states_unrepresentable] the two cannot be *one* field: this
356// row's field list is the recorded public surface in docs/inferred-surface.md, which a
357// consumer parses `--json` into, and folding the answer and the argv into one value would
358// change the document every consumer already reads. What is available is what this crate
359// does everywhere a rule spans two values it must keep: the one place that builds a row
360// derives both from the same verdict, and the boundary where a row is *read* refuses a
361// document whose two halves disagree.
362#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
363#[serde(try_from = "AnyRecoverable")]
364pub struct Recoverable {
365    /// The identity the work belongs to.
366    pub identity: String,
367    /// The branch and why it was preserved.
368    pub branch: PreservedBranch,
369    /// The checkout the branch can be reached from.
370    pub checkout: PathBuf,
371    /// Why the workstream stopped.
372    pub stopped_because: String,
373    /// Whether the work reached the base, and what says so.
374    ///
375    /// The one field on this row that decides whether the row is an instruction at
376    /// all: a row whose work reached the base carries no argv.
377    ///
378    /// Written always and defaulted on the way in, so a stored document that
379    /// predates the field reads back as the answer it gave: nothing said.
380    #[serde(default)]
381    pub landed: Landed,
382    /// The argv that lands it, ready to run.
383    ///
384    /// Ready to run is a claim about the branch as much as about the argv, so it
385    /// holds only where the two fields below say nothing: work a live session is
386    /// still writing to has not stopped, and running this on it publishes a branch
387    /// mid-flight.
388    ///
389    /// **Empty where nothing may be run**, which is every row whose work reached the
390    /// base: the argv would publish work that is already there. A row is read to be
391    /// pasted, so the answer is the absence of a command rather than a command with
392    /// a warning beside it.
393    pub recover_command: Vec<String>,
394    /// The live session still writing to this branch, or the publication of it
395    /// running right now, when either is.
396    ///
397    /// Present at all is the answer: this is not preserved work yet, and
398    /// [`recover_command`](Self::recover_command) must not be run until whatever holds
399    /// it is done with it. Absent — every row of a report about work that really did
400    /// stop — and the row is exactly what it has always been.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub held_by: Option<HeldBy>,
403    /// What the branch would land, when it removes more lines than it adds.
404    ///
405    /// A branch that deletes far more than it adds may be perfectly correct, and it
406    /// is not something to publish unread — so it is marked rather than excluded,
407    /// and marked only then: a branch that adds at least as much as it removes
408    /// carries nothing here, and cannot, because [`NetNegative`] holds no such count.
409    #[serde(default, skip_serializing_if = "Option::is_none")]
410    pub net_negative: Option<NetNegative>,
411    /// The session that answers for this branch: the newest record naming it — the
412    /// end of its chain of retries, an open one preferred — or `None` where no record
413    /// on this host names the branch at all, which is what a `worktree-agent-*`
414    /// orphan left behind by nothing this crate opened reads as.
415    ///
416    /// Written always, `null` included, so a reader routing on it meets the key on
417    /// every row.
418    #[serde(default)]
419    pub session: Option<SessionToken>,
420    /// That session's labels: what the caller that opened it said about who opened
421    /// it. An empty object where the record carries none, and where
422    /// [`session`](Self::session) is `None`.
423    #[serde(default)]
424    pub labels: BTreeMap<String, String>,
425    /// Where `onevcs preserve` put this branch on its identity's origin, when it has.
426    ///
427    /// Additive, and absent for a branch nothing has preserved — which is every row
428    /// of a report a consumer that predates the field reads. It changes nothing about
429    /// [`recover_command`](Self::recover_command): being on the origin under its own
430    /// name is not being published, and the verb that lands the work is the verb that
431    /// landed it before. That is the whole point of the verb being called *preserve*.
432    #[serde(default, skip_serializing_if = "Option::is_none")]
433    pub on_origin: Option<OnOrigin>,
434    /// What the branch is for the question of whether it may be deleted — the
435    /// classification `onevcs retire` and `onevcs reclaim` act on, made here from this
436    /// host's records and local refs alone, since this report never asks the host.
437    ///
438    /// Additive: every row `onevcs` answers carries it, and a row an implementation
439    /// that does not classify answered omits it, as a consumer that predates the field
440    /// never meets it. A row whose class is `retirable` is not in the default report,
441    /// as a row whose work landed is not.
442    #[serde(default, skip_serializing_if = "Option::is_none")]
443    pub retirement: Option<crate::retire::Retirement>,
444}
445
446/// A row as a document spells it, before the answer on it and the command beside it
447/// have been held to each other.
448///
449/// The same fields, and it exists only so that the check above has something to run
450/// on: serde hands a conversion the whole value or nothing, and what has to be
451/// checked here is one field against another.
452#[derive(Deserialize)]
453struct AnyRecoverable {
454    identity: String,
455    branch: PreservedBranch,
456    checkout: PathBuf,
457    #[serde(default)]
458    landed: Landed,
459    stopped_because: String,
460    recover_command: Vec<String>,
461    #[serde(default)]
462    held_by: Option<HeldBy>,
463    #[serde(default)]
464    net_negative: Option<NetNegative>,
465    #[serde(default)]
466    session: Option<SessionToken>,
467    #[serde(default)]
468    labels: BTreeMap<String, String>,
469    on_origin: Option<OnOrigin>,
470    #[serde(default)]
471    retirement: Option<crate::retire::Retirement>,
472}
473
474impl TryFrom<AnyRecoverable> for Recoverable {
475    type Error = String;
476
477    fn try_from(value: AnyRecoverable) -> std::result::Result<Self, Self::Error> {
478        if value.landed.is_landed() && !value.recover_command.is_empty() {
479            return Err(format!(
480                "the row for branch {branch:?} says its work reached {base} and carries \
481                 {command:?} to publish it again; a row that landed carries no command",
482                branch = value.branch.branch,
483                base = value.branch.base,
484                command = value.recover_command.join(" "),
485            ));
486        }
487        if let Some(held) = &value.held_by {
488            // A running publication is the one hold that names no session, so a token
489            // and a holding that say otherwise describe a hold nothing could be.
490            if held.token.is_none() != (held.holding == Holding::PublicationRunning) {
491                return Err(format!(
492                    "the row for branch {branch:?} carries a {holding:?} hold that {names} a \
493                     session; a running publication is the one hold that names none",
494                    branch = value.branch.branch,
495                    holding = held.holding,
496                    names = if held.token.is_some() {
497                        "names"
498                    } else {
499                        "names no"
500                    },
501                ));
502            }
503        }
504        Ok(Recoverable {
505            identity: value.identity,
506            branch: value.branch,
507            checkout: value.checkout,
508            landed: value.landed,
509            stopped_because: value.stopped_because,
510            recover_command: value.recover_command,
511            held_by: value.held_by,
512            net_negative: value.net_negative,
513            session: value.session,
514            labels: value.labels,
515            on_origin: value.on_origin,
516            retirement: value.retirement,
517        })
518    }
519}
520
521/// Where a branch was last put on its identity's origin without being published.
522///
523/// The readback of what `onevcs preserve` recorded, and the answer to one question:
524/// if this host went away now, is the work somewhere that outlives it? Both halves
525/// are needed to answer it — the origin says *where*, and the commit says *how much*,
526/// since a branch preserved and then committed to again is on its origin at a commit
527/// that is no longer its tip.
528#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
529pub struct OnOrigin {
530    /// The origin URL the branch went to.
531    // llmlint: ignore[invalid_states_unrepresentable] a remote is a `String` everywhere
532    // this crate's public surface spells one, and this is the URL a repository's own
533    // `origin` was configured with rather than a form this crate composes — what makes it
534    // usable is checked where the record is read (`git::is_usable_remote`, before it can
535    // reach a line an operator reads), and the field itself is spelled by
536    // `docs/contract.md`.
537    pub remote: String,
538    /// The commit the origin carries it at.
539    // llmlint: ignore[invalid_states_unrepresentable] a commit is a `String` everywhere
540    // this crate's public surface spells one that came out of git — `Sha` wraps an
541    // unvalidated `String` and would make no state here unrepresentable — and this value
542    // is one `git push` reported or `git ls-remote` advertised, checked as an object id
543    // where it was read.
544    pub commit: String,
545}
546
547/// What still holds a preserved branch: a live session, or a publication of it
548/// running right now.
549// llmlint: ignore[invalid_states_unrepresentable] the shape is a shared contract fixed
550// for this change — `token` a session token or `null`, beside `holding` — which other
551// repositories build against as written; a tagged enum carrying the token only on a
552// session's hold would move the wire every reader of `held_by` parses. What is available
553// is what `Recoverable` does with its own two-field rule: `vcs::held_by` is the one place
554// that builds a hold and pairs `None` with `PublicationRunning` only, and the boundary
555// where a row is read refuses a hold whose token and holding disagree.
556#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
557pub struct HeldBy {
558    /// The session, so an operator can wait for it or close it by name.
559    ///
560    /// `None` exactly when [`holding`](Self::holding) is
561    /// [`Holding::PublicationRunning`]: a branch-keyed publication is made under no
562    /// session, so there is no token to name. Written as `null` rather than omitted,
563    /// so a reader meets the key on every row that carries this.
564    pub token: Option<SessionToken>,
565    /// The worktree its work is being made in — for a running publication, that
566    /// publication's own workspace. Absolute.
567    pub worktree: PathBuf,
568    /// How this host can tell it is still live.
569    pub holding: Holding,
570}
571
572/// How a host can tell that something still holds a branch.
573///
574/// Three answers rather than one, because they are true at different times: a
575/// consumer holding a [`Session`] keeps the process that opened it alive, while the
576/// CLI takes an occupancy lease per command and outlives none of them — either is a
577/// session that has not finished with its branch — and a branch-keyed publication
578/// (`publish-branch`, `recover`) holds the branch under no session at all, for as
579/// long as it runs.
580#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
581#[serde(rename_all = "kebab-case")]
582pub enum Holding {
583    /// The session is open and the process that opened it is still running, which is
584    /// the question [`Liveness::Live`] answers.
585    OwnerRunning,
586    /// Something holds the occupancy lease on its run root right now, so a command
587    /// is working in there whatever became of the process that opened the session.
588    RunRootOccupied,
589    /// A publication of the branch is running right now, under no session. Its
590    /// [`HeldBy::token`] is `None`.
591    PublicationRunning,
592}
593
594impl Holding {
595    /// The clause a report gives as the reason, for the command line: `--json`
596    /// carries the value itself.
597    pub fn because(&self) -> &'static str {
598        match self {
599            Self::OwnerRunning => "the process that opened it is still running",
600            Self::RunRootOccupied => "a command is working in its run root right now",
601            Self::PublicationRunning => "a publication is running on this branch right now",
602        }
603    }
604}
605
606/// The lines a branch would land, as git counts them against the commit it forked
607/// from.
608///
609/// The fork point rather than the base's tip, because what the branch did is what it
610/// did to the tree it started on: measured against a base that has moved on, every
611/// line that base gained reads as a line this branch removed and never touched.
612#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
613pub struct LineChange {
614    /// Lines it adds.
615    pub added: u64,
616    /// Lines it removes.
617    pub removed: u64,
618}
619
620/// A [`LineChange`] that removes more than it adds, and the only thing that can be
621/// one.
622///
623/// The mark and its evidence are one value, so a row cannot carry a count that says
624/// the opposite of the field holding it — and the rule that decides "net-negative"
625/// lives here rather than at the site that measures a branch and at every consumer
626/// that reads one back. It serializes and reads as the [`LineChange`] it holds, so
627/// the two counts are what `--json` carries either way; a document naming a count
628/// that is not net-negative is refused where it is read.
629#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
630#[serde(try_from = "LineChange", into = "LineChange")]
631pub struct NetNegative(LineChange);
632
633impl NetNegative {
634    /// The count, when it is one a net-negative branch could have.
635    pub fn new(lines: LineChange) -> Option<Self> {
636        (lines.removed > lines.added).then_some(Self(lines))
637    }
638
639    /// Lines the branch adds.
640    pub fn added(&self) -> u64 {
641        self.0.added
642    }
643
644    /// Lines it removes, which is more than it adds.
645    pub fn removed(&self) -> u64 {
646        self.0.removed
647    }
648}
649
650impl TryFrom<LineChange> for NetNegative {
651    type Error = String;
652
653    fn try_from(lines: LineChange) -> std::result::Result<Self, Self::Error> {
654        Self::new(lines).ok_or_else(|| {
655            format!(
656                "{} line(s) added and {} removed is not a net-negative change",
657                lines.added, lines.removed
658            )
659        })
660    }
661}
662
663impl From<NetNegative> for LineChange {
664    fn from(value: NetNegative) -> Self {
665        value.0
666    }
667}