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