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}