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/// The detail a recovery read computes. Both modes answer the same branches and
320/// decisions, including the command that recovers them.
321#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
322#[serde(rename_all = "kebab-case")]
323pub enum Detail {
324    /// The complete recovery report.
325    #[default]
326    Full,
327    /// Omit costly presentation: stopped text, change metadata and line statistics.
328    /// Identity, branch/base, checkout/tip, landing evidence, holds, retirement,
329    /// session, labels and recovery command remain identical to `Full`.
330    Decision,
331}
332
333/// Which of the preserved work in scope a report is asked to answer with.
334///
335/// Both halves are `and`ed with each other and with the [`Scope`]: a row is answered
336/// with when its branch is one the named sessions hold or held *and* the labels of
337/// the session that answers for it carry every pair asked for. Empty asks nothing,
338/// which is what every read before there was a selection asked — so [`Default`] is
339/// the whole report.
340///
341/// A session token no record on this host names is refused by name rather than
342/// answered with an empty report: "nothing of that session is left to publish" and
343/// "there is no such session" are different answers to act on. A label pair nothing
344/// carries is the first of those, and answers empty.
345#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
346pub struct Selection {
347    /// Detail to compute; default is the complete report.
348    #[serde(default)]
349    pub detail: Detail,
350    /// The sessions whose branches are asked about, by token.
351    #[serde(default, skip_serializing_if = "Vec::is_empty")]
352    pub sessions: Vec<SessionToken>,
353    /// The pairs every answered row's labels must carry.
354    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
355    pub labels: BTreeMap<String, String>,
356}
357
358impl Selection {
359    /// Whether this asks anything at all.
360    pub fn is_empty(&self) -> bool {
361        self.sessions.is_empty() && self.labels.is_empty()
362    }
363}
364
365/// Preserved work, whether it reached its base, and what would land it.
366///
367/// Read through the conversion below, which is where the one thing this row could
368/// contradict itself about is settled: a row saying the work reached the base *and*
369/// carrying the argv that publishes it again does not deserialize at all. The row is
370/// read to be pasted, and the whole reason the answer is on it is that pasting one
371/// for finished work re-opens a change request for what the base has.
372// llmlint: ignore[invalid_states_unrepresentable] the two cannot be *one* field: this
373// row's field list is the recorded public surface in docs/inferred-surface.md, which a
374// consumer parses `--json` into, and folding the answer and the argv into one value would
375// change the document every consumer already reads. What is available is what this crate
376// does everywhere a rule spans two values it must keep: the one place that builds a row
377// derives both from the same verdict, and the boundary where a row is *read* refuses a
378// document whose two halves disagree.
379#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
380#[serde(try_from = "AnyRecoverable")]
381pub struct Recoverable {
382    /// The identity the work belongs to.
383    pub identity: String,
384    /// The branch and why it was preserved.
385    pub branch: PreservedBranch,
386    /// The checkout the branch can be reached from.
387    pub checkout: PathBuf,
388    /// Full commit object name of the branch in this checkout, or `None` only
389    /// when its ref cannot be read. Always serialized, including `null`; older
390    /// documents default to `None`.
391    // llmlint: ignore[invalid_states_unrepresentable] The approved recovery-detail
392    // amendment fixes this public field as Option<String>; a commit-id newtype
393    // would change the agreed surface. AnyRecoverable's conversion validates a
394    // present full object name before a serialized row reaches this field.
395    #[serde(default)]
396    pub tip: Option<String>,
397    /// Why the workstream stopped.
398    pub stopped_because: String,
399    /// Whether the work reached the base, and what says so.
400    ///
401    /// The one field on this row that decides whether the row is an instruction at
402    /// all: a row whose work reached the base carries no argv.
403    ///
404    /// Written always and defaulted on the way in, so a stored document that
405    /// predates the field reads back as the answer it gave: nothing said.
406    #[serde(default)]
407    pub landed: Landed,
408    /// The argv that lands it, ready to run.
409    ///
410    /// Ready to run is a claim about the branch as much as about the argv, so it
411    /// holds only where the two fields below say nothing: work a live session is
412    /// still writing to has not stopped, and running this on it publishes a branch
413    /// mid-flight.
414    ///
415    /// **Empty where nothing may be run**, which is every row whose work reached the
416    /// base: the argv would publish work that is already there. A row is read to be
417    /// pasted, so the answer is the absence of a command rather than a command with
418    /// a warning beside it.
419    pub recover_command: Vec<String>,
420    /// The live session still writing to this branch, or the publication of it
421    /// running right now, when either is.
422    ///
423    /// Present at all is the answer: this is not preserved work yet, and
424    /// [`recover_command`](Self::recover_command) must not be run until whatever holds
425    /// it is done with it. Absent — every row of a report about work that really did
426    /// stop — and the row is exactly what it has always been.
427    #[serde(default, skip_serializing_if = "Option::is_none")]
428    pub held_by: Option<HeldBy>,
429    /// What the branch would land, when it removes more lines than it adds.
430    ///
431    /// A branch that deletes far more than it adds may be perfectly correct, and it
432    /// is not something to publish unread — so it is marked rather than excluded,
433    /// and marked only then: a branch that adds at least as much as it removes
434    /// carries nothing here, and cannot, because [`NetNegative`] holds no such count.
435    #[serde(default, skip_serializing_if = "Option::is_none")]
436    pub net_negative: Option<NetNegative>,
437    /// The session that answers for this branch: the newest record naming it — the
438    /// end of its chain of retries, an open one preferred — or `None` where no record
439    /// on this host names the branch at all, which is what a `worktree-agent-*`
440    /// orphan left behind by nothing this crate opened reads as.
441    ///
442    /// Written always, `null` included, so a reader routing on it meets the key on
443    /// every row.
444    #[serde(default)]
445    pub session: Option<SessionToken>,
446    /// That session's labels: what the caller that opened it said about who opened
447    /// it. An empty object where the record carries none, and where
448    /// [`session`](Self::session) is `None`.
449    #[serde(default)]
450    pub labels: BTreeMap<String, String>,
451    /// Where `onevcs preserve` put this branch on its identity's origin, when it has.
452    ///
453    /// Additive, and absent for a branch nothing has preserved — which is every row
454    /// of a report a consumer that predates the field reads. It changes nothing about
455    /// [`recover_command`](Self::recover_command): being on the origin under its own
456    /// name is not being published, and the verb that lands the work is the verb that
457    /// landed it before. That is the whole point of the verb being called *preserve*.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub on_origin: Option<OnOrigin>,
460    /// What the branch is for the question of whether it may be deleted — the
461    /// classification `onevcs retire` and `onevcs reclaim` act on, made here from this
462    /// host's records and local refs alone, since this report never asks the host.
463    ///
464    /// Additive: every row `onevcs` answers carries it, and a row an implementation
465    /// that does not classify answered omits it, as a consumer that predates the field
466    /// never meets it. A row whose class is `retirable` is not in the default report,
467    /// as a row whose work landed is not.
468    #[serde(default, skip_serializing_if = "Option::is_none")]
469    pub retirement: Option<crate::retire::Retirement>,
470}
471
472/// A row as a document spells it, before the answer on it and the command beside it
473/// have been held to each other.
474///
475/// The same fields, and it exists only so that the check above has something to run
476/// on: serde hands a conversion the whole value or nothing, and what has to be
477/// checked here is one field against another.
478#[derive(Deserialize)]
479struct AnyRecoverable {
480    #[serde(default)]
481    tip: Option<String>,
482    identity: String,
483    branch: PreservedBranch,
484    checkout: PathBuf,
485    #[serde(default)]
486    landed: Landed,
487    stopped_because: String,
488    recover_command: Vec<String>,
489    #[serde(default)]
490    held_by: Option<HeldBy>,
491    #[serde(default)]
492    net_negative: Option<NetNegative>,
493    #[serde(default)]
494    session: Option<SessionToken>,
495    #[serde(default)]
496    labels: BTreeMap<String, String>,
497    on_origin: Option<OnOrigin>,
498    #[serde(default)]
499    retirement: Option<crate::retire::Retirement>,
500}
501
502impl TryFrom<AnyRecoverable> for Recoverable {
503    type Error = String;
504
505    fn try_from(value: AnyRecoverable) -> std::result::Result<Self, Self::Error> {
506        if value
507            .tip
508            .as_ref()
509            .is_some_and(|tip| crate::git::ObjectId::parse(tip).is_none())
510        {
511            return Err(format!(
512                "the row for branch {:?} carries tip {:?}, which is not a full commit object name",
513                value.branch.branch, value.tip
514            ));
515        }
516        if value.landed.is_landed() && !value.recover_command.is_empty() {
517            return Err(format!(
518                "the row for branch {branch:?} says its work reached {base} and carries \
519                 {command:?} to publish it again; a row that landed carries no command",
520                branch = value.branch.branch,
521                base = value.branch.base,
522                command = value.recover_command.join(" "),
523            ));
524        }
525        if let Some(held) = &value.held_by {
526            // A running publication is the one hold that names no session, so a token
527            // and a holding that say otherwise describe a hold nothing could be.
528            if held.token.is_none() != (held.holding == Holding::PublicationRunning) {
529                return Err(format!(
530                    "the row for branch {branch:?} carries a {holding:?} hold that {names} a \
531                     session; a running publication is the one hold that names none",
532                    branch = value.branch.branch,
533                    holding = held.holding,
534                    names = if held.token.is_some() {
535                        "names"
536                    } else {
537                        "names no"
538                    },
539                ));
540            }
541        }
542        Ok(Recoverable {
543            identity: value.identity,
544            branch: value.branch,
545            checkout: value.checkout,
546            tip: value.tip,
547            landed: value.landed,
548            stopped_because: value.stopped_because,
549            recover_command: value.recover_command,
550            held_by: value.held_by,
551            net_negative: value.net_negative,
552            session: value.session,
553            labels: value.labels,
554            on_origin: value.on_origin,
555            retirement: value.retirement,
556        })
557    }
558}
559
560/// Where a branch was last put on its identity's origin without being published.
561///
562/// The readback of what `onevcs preserve` recorded, and the answer to one question:
563/// if this host went away now, is the work somewhere that outlives it? Both halves
564/// are needed to answer it — the origin says *where*, and the commit says *how much*,
565/// since a branch preserved and then committed to again is on its origin at a commit
566/// that is no longer its tip.
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
568pub struct OnOrigin {
569    /// The origin URL the branch went to.
570    // llmlint: ignore[invalid_states_unrepresentable] a remote is a `String` everywhere
571    // this crate's public surface spells one, and this is the URL a repository's own
572    // `origin` was configured with rather than a form this crate composes — what makes it
573    // usable is checked where the record is read (`git::is_usable_remote`, before it can
574    // reach a line an operator reads), and the field itself is spelled by
575    // `docs/contract.md`.
576    pub remote: String,
577    /// The commit the origin carries it at.
578    // llmlint: ignore[invalid_states_unrepresentable] a commit is a `String` everywhere
579    // this crate's public surface spells one that came out of git — `Sha` wraps an
580    // unvalidated `String` and would make no state here unrepresentable — and this value
581    // is one `git push` reported or `git ls-remote` advertised, checked as an object id
582    // where it was read.
583    pub commit: String,
584}
585
586/// What still holds a preserved branch: a live session, or a publication of it
587/// running right now.
588// llmlint: ignore[invalid_states_unrepresentable] the shape is a shared contract fixed
589// for this change — `token` a session token or `null`, beside `holding` — which other
590// repositories build against as written; a tagged enum carrying the token only on a
591// session's hold would move the wire every reader of `held_by` parses. What is available
592// is what `Recoverable` does with its own two-field rule: `vcs::held_by` is the one place
593// that builds a hold and pairs `None` with `PublicationRunning` only, and the boundary
594// where a row is read refuses a hold whose token and holding disagree.
595#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
596pub struct HeldBy {
597    /// The session, so an operator can wait for it or close it by name.
598    ///
599    /// `None` exactly when [`holding`](Self::holding) is
600    /// [`Holding::PublicationRunning`]: a branch-keyed publication is made under no
601    /// session, so there is no token to name. Written as `null` rather than omitted,
602    /// so a reader meets the key on every row that carries this.
603    pub token: Option<SessionToken>,
604    /// The worktree its work is being made in — for a running publication, that
605    /// publication's own workspace. Absolute.
606    pub worktree: PathBuf,
607    /// How this host can tell it is still live.
608    pub holding: Holding,
609}
610
611/// How a host can tell that something still holds a branch.
612///
613/// Three answers rather than one, because they are true at different times: a
614/// consumer holding a [`Session`] keeps the process that opened it alive, while the
615/// CLI takes an occupancy lease per command and outlives none of them — either is a
616/// session that has not finished with its branch — and a branch-keyed publication
617/// (`publish-branch`, `recover`) holds the branch under no session at all, for as
618/// long as it runs.
619#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
620#[serde(rename_all = "kebab-case")]
621pub enum Holding {
622    /// The session is open and the process that opened it is still running, which is
623    /// the question [`Liveness::Live`] answers.
624    OwnerRunning,
625    /// Something holds the occupancy lease on its run root right now, so a command
626    /// is working in there whatever became of the process that opened the session.
627    RunRootOccupied,
628    /// A publication of the branch is running right now, under no session. Its
629    /// [`HeldBy::token`] is `None`.
630    PublicationRunning,
631}
632
633impl Holding {
634    /// The clause a report gives as the reason, for the command line: `--json`
635    /// carries the value itself.
636    pub fn because(&self) -> &'static str {
637        match self {
638            Self::OwnerRunning => "the process that opened it is still running",
639            Self::RunRootOccupied => "a command is working in its run root right now",
640            Self::PublicationRunning => "a publication is running on this branch right now",
641        }
642    }
643}
644
645/// The lines a branch would land, as git counts them against the commit it forked
646/// from.
647///
648/// The fork point rather than the base's tip, because what the branch did is what it
649/// did to the tree it started on: measured against a base that has moved on, every
650/// line that base gained reads as a line this branch removed and never touched.
651#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
652pub struct LineChange {
653    /// Lines it adds.
654    pub added: u64,
655    /// Lines it removes.
656    pub removed: u64,
657}
658
659/// A [`LineChange`] that removes more than it adds, and the only thing that can be
660/// one.
661///
662/// The mark and its evidence are one value, so a row cannot carry a count that says
663/// the opposite of the field holding it — and the rule that decides "net-negative"
664/// lives here rather than at the site that measures a branch and at every consumer
665/// that reads one back. It serializes and reads as the [`LineChange`] it holds, so
666/// the two counts are what `--json` carries either way; a document naming a count
667/// that is not net-negative is refused where it is read.
668#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
669#[serde(try_from = "LineChange", into = "LineChange")]
670pub struct NetNegative(LineChange);
671
672impl NetNegative {
673    /// The count, when it is one a net-negative branch could have.
674    pub fn new(lines: LineChange) -> Option<Self> {
675        (lines.removed > lines.added).then_some(Self(lines))
676    }
677
678    /// Lines the branch adds.
679    pub fn added(&self) -> u64 {
680        self.0.added
681    }
682
683    /// Lines it removes, which is more than it adds.
684    pub fn removed(&self) -> u64 {
685        self.0.removed
686    }
687}
688
689impl TryFrom<LineChange> for NetNegative {
690    type Error = String;
691
692    fn try_from(lines: LineChange) -> std::result::Result<Self, Self::Error> {
693        Self::new(lines).ok_or_else(|| {
694            format!(
695                "{} line(s) added and {} removed is not a net-negative change",
696                lines.added, lines.removed
697            )
698        })
699    }
700}
701
702impl From<NetNegative> for LineChange {
703    fn from(value: NetNegative) -> Self {
704        value.0
705    }
706}