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