onevcs/session.rs
1//! A session: the per-run clone and worktree a change is made in, and what is
2//! left behind when one does not finish.
3
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7use url::Url;
8
9use crate::landed::Landed;
10
11/// What to open a session over.
12#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
13pub struct SessionRequest {
14 /// The repository: an identity key, a registered alias, or a path.
15 pub repo: String,
16 /// The branch to work on.
17 ///
18 /// A name that already exists — in a checkout of this identity, or on origin —
19 /// is **continued**: the session's worktree is opened at that branch's tip and
20 /// carries the work already on it. A name nothing carries yet is cut fresh from
21 /// [`base`](Self::base). Absent means one is derived, which is always a fresh
22 /// cut.
23 pub branch: Option<String>,
24 /// The branch this session's work is merged with and published into.
25 ///
26 /// For a branch cut fresh it is also the point the branch starts from. For a
27 /// branch that already exists it is the integration target and nothing else:
28 /// what is merged in when it has moved on, and what the publication is compared
29 /// against. Absent means the identity's registered base. Naming the session's
30 /// own branch here is refused — it would publish the branch into itself.
31 pub base: Option<String>,
32 /// Which registered checkout to clone from. Absent means the identity's
33 /// default execution checkout.
34 pub execution_checkout: Option<String>,
35}
36
37/// The handle a session is adopted, published, and closed by.
38#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
39#[serde(transparent)]
40pub struct SessionToken(pub String);
41
42/// An open session: a per-run shared clone plus a worktree, held under an
43/// occupancy lease.
44#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Session {
46 /// The handle this session is addressed by.
47 pub token: SessionToken,
48 /// The worktree the change is made in.
49 pub worktree: PathBuf,
50 /// The branch the worktree has checked out.
51 pub branch: String,
52 /// The branch this session's work is merged with and published into, which for
53 /// a branch cut fresh is also the one it was cut from.
54 pub base: String,
55}
56
57/// Where a session is in its life.
58#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
59#[serde(rename_all = "kebab-case")]
60pub enum Lifecycle {
61 /// It has a worktree and its work has not been published or released.
62 Open,
63 /// Its worktree is gone and its branch has been handed back. The session is
64 /// still addressable, because the branch it names is still the only record of
65 /// the work.
66 Closed,
67}
68
69/// Everything the implementation that opened a session records about it.
70///
71/// A [`Session`] is the handle a caller was given; this is what the repository side
72/// knows about it afterwards, and it is why the record had to come through the
73/// interface: which repository the session belongs to, whether it is still open,
74/// and whether its branch carries an incomplete-step marker are all questions a
75/// command asks between opening a session and publishing it.
76#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct SessionRecord {
78 /// The session itself.
79 pub session: Session,
80 /// The identity key the session belongs to.
81 // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
82 // everywhere this contract spells one — `Recoverable::identity`, `Identity::origin`,
83 // and the registry document's own map key — so a newtype here would disagree with the
84 // types it names and add a public item the contract does not. Every value written here
85 // came out of a registry the implementation resolved against, which is where the key
86 // is normalized and decided.
87 pub identity: String,
88 /// Where the session is in its life.
89 pub lifecycle: Lifecycle,
90 /// What the session's branch carries now — an adopted session that was left
91 /// dirty carries [`Provenance::IncompleteStep`], and must pass the merge-path
92 /// merge path through `onevcs recover` before it may be published.
93 pub provenance: Provenance,
94 /// The session that continued this one's branch, once one has.
95 ///
96 /// A branch a run left behind is picked up by the next session over the same
97 /// name, and this is the link between the two — written onto the *older*
98 /// record when the newer one opens. It is the answer to "which of these two
99 /// copies of one branch is the work", which nothing else on either record
100 /// gives, and everything that reports what became of a session or a branch
101 /// follows it to the newest record before it answers.
102 ///
103 /// Absent on every session nothing has superseded, which is most of them.
104 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub retried_by: Option<SessionToken>,
106}
107
108/// One session that holds a repository's workspace, as an enumeration reports it.
109///
110/// A [`SessionRecord`] answers about a session somebody already has the token for;
111/// this answers the question before that one — *who is in this repository* — for
112/// every session the host has recorded that anybody is left to answer for: one
113/// whose owner process is still there, and one a live process is still working
114/// inside. A record with neither is forgotten by the enumeration rather than
115/// reported above every launch. It is what `onevcs session holders` prints, so a
116/// caller reading the command's JSON and a caller embedding
117/// [`crate::session_holders`] read the same shape.
118#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119pub struct SessionHolder {
120 /// The handle this session is addressed by, and the one every command that
121 /// takes a session takes.
122 pub token: SessionToken,
123 /// The identity key the session belongs to.
124 pub identity: String,
125 /// The branch its worktree has checked out.
126 pub branch: String,
127 /// The worktree the change is made in. A closed session's is gone; its branch
128 /// is not.
129 pub worktree: PathBuf,
130 /// The process that opened it, so a diagnostic can name who to look for.
131 pub owner_pid: u32,
132 /// Where the session is in its life. Spelled `state` because that is the key
133 /// `onevcs session holders --json` prints it under.
134 pub state: Lifecycle,
135 /// Whether the process that opened it is still there.
136 pub liveness: Liveness,
137}
138
139/// Whether a session's owner is still running, which is what makes its lease real.
140///
141/// A recorded pid alone cannot answer this — the OS reuses pids, so a later process
142/// wearing a dead session's number would read as its owner. The answer is the pid
143/// *and* that process's creation identity, which is why it is reported rather than
144/// left for a caller to derive from [`SessionHolder::owner_pid`].
145#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
146#[serde(rename_all = "lowercase")]
147pub enum Liveness {
148 /// The session is open and the process that opened it is that same process,
149 /// still running.
150 Live,
151 /// Anything else: the session is closed, its owner exited, or its pid now
152 /// belongs to a different process.
153 Stale,
154}
155
156impl Liveness {
157 /// The word this crate reports it as, on the command line and in JSON.
158 pub fn as_str(&self) -> &'static str {
159 match self {
160 Self::Live => "live",
161 Self::Stale => "stale",
162 }
163 }
164}
165
166/// Why a branch was preserved, and therefore what recovering it must do.
167#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
168#[serde(rename_all = "kebab-case")]
169pub enum Provenance {
170 /// The step finished; the branch is ready to publish as it stands.
171 Complete,
172 /// The step did not finish. Its work was committed behind an incomplete-step
173 /// marker and must pass its merge path before it may be published.
174 IncompleteStep,
175}
176
177/// A branch that holds work outside its session.
178#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
179pub struct PreservedBranch {
180 /// The branch the work is on.
181 pub branch: String,
182 /// The base it was cut from.
183 pub base: String,
184 /// Why it was preserved.
185 pub provenance: Provenance,
186 /// The change request it belongs to, when one was opened. Host-neutral: a
187 /// GitHub pull request and a GitLab merge request both land here.
188 pub change_url: Option<Url>,
189 /// The base that change request targets, which for a stacked branch is the
190 /// branch below it rather than the root.
191 pub change_base: Option<String>,
192}
193
194/// Which preserved work to look for.
195#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
196#[serde(rename_all = "kebab-case")]
197pub enum Scope {
198 /// Every registered identity.
199 All,
200 /// One repository: an identity key, a registered alias, or a path.
201 Repo(String),
202}
203
204/// Preserved work, whether it reached its base, and what would land it.
205///
206/// Read through the conversion below, which is where the one thing this row could
207/// contradict itself about is settled: a row saying the work reached the base *and*
208/// carrying the argv that publishes it again does not deserialize at all. The row is
209/// read to be pasted, and the whole reason the answer is on it is that pasting one
210/// for finished work re-opens a change request for what the base has.
211// llmlint: ignore[invalid_states_unrepresentable] the two cannot be *one* field: this
212// row's field list is the recorded public surface in docs/inferred-surface.md, which a
213// consumer parses `--json` into, and folding the answer and the argv into one value would
214// change the document every consumer already reads. What is available is what this crate
215// does everywhere a rule spans two values it must keep: the one place that builds a row
216// derives both from the same verdict, and the boundary where a row is *read* refuses a
217// document whose two halves disagree.
218#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
219#[serde(try_from = "AnyRecoverable")]
220pub struct Recoverable {
221 /// The identity the work belongs to.
222 pub identity: String,
223 /// The branch and why it was preserved.
224 pub branch: PreservedBranch,
225 /// The checkout the branch can be reached from.
226 pub checkout: PathBuf,
227 /// Why the workstream stopped.
228 pub stopped_because: String,
229 /// Whether the work reached the base, and what says so.
230 ///
231 /// The one field on this row that decides whether the row is an instruction at
232 /// all: a row whose work reached the base carries no argv.
233 ///
234 /// Written always and defaulted on the way in, so a stored document that
235 /// predates the field reads back as the answer it gave: nothing said.
236 #[serde(default)]
237 pub landed: Landed,
238 /// The argv that lands it, ready to run.
239 ///
240 /// Ready to run is a claim about the branch as much as about the argv, so it
241 /// holds only where the two fields below say nothing: work a live session is
242 /// still writing to has not stopped, and running this on it publishes a branch
243 /// mid-flight.
244 ///
245 /// **Empty where nothing may be run**, which is every row whose work reached the
246 /// base: the argv would publish work that is already there. A row is read to be
247 /// pasted, so the answer is the absence of a command rather than a command with
248 /// a warning beside it.
249 pub recover_command: Vec<String>,
250 /// The live session still writing to this branch, when one is.
251 ///
252 /// Present at all is the answer: this is not preserved work yet, and
253 /// [`recover_command`](Self::recover_command) must not be run until that session
254 /// is done with it. Absent — every row of a report about work that really did
255 /// stop — and the row is exactly what it has always been.
256 #[serde(default, skip_serializing_if = "Option::is_none")]
257 pub held_by: Option<HeldBy>,
258 /// What the branch would land, when it removes more lines than it adds.
259 ///
260 /// A branch that deletes far more than it adds may be perfectly correct, and it
261 /// is not something to publish unread — so it is marked rather than excluded,
262 /// and marked only then: a branch that adds at least as much as it removes
263 /// carries nothing here, and cannot, because [`NetNegative`] holds no such count.
264 #[serde(default, skip_serializing_if = "Option::is_none")]
265 pub net_negative: Option<NetNegative>,
266}
267
268/// A row as a document spells it, before the answer on it and the command beside it
269/// have been held to each other.
270///
271/// The same fields, and it exists only so that the check above has something to run
272/// on: serde hands a conversion the whole value or nothing, and what has to be
273/// checked here is one field against another.
274#[derive(Deserialize)]
275struct AnyRecoverable {
276 identity: String,
277 branch: PreservedBranch,
278 checkout: PathBuf,
279 #[serde(default)]
280 landed: Landed,
281 stopped_because: String,
282 recover_command: Vec<String>,
283 #[serde(default)]
284 held_by: Option<HeldBy>,
285 #[serde(default)]
286 net_negative: Option<NetNegative>,
287}
288
289impl TryFrom<AnyRecoverable> for Recoverable {
290 type Error = String;
291
292 fn try_from(value: AnyRecoverable) -> std::result::Result<Self, Self::Error> {
293 if value.landed.is_landed() && !value.recover_command.is_empty() {
294 return Err(format!(
295 "the row for branch {branch:?} says its work reached {base} and carries \
296 {command:?} to publish it again; a row that landed carries no command",
297 branch = value.branch.branch,
298 base = value.branch.base,
299 command = value.recover_command.join(" "),
300 ));
301 }
302 Ok(Recoverable {
303 identity: value.identity,
304 branch: value.branch,
305 checkout: value.checkout,
306 landed: value.landed,
307 stopped_because: value.stopped_because,
308 recover_command: value.recover_command,
309 held_by: value.held_by,
310 net_negative: value.net_negative,
311 })
312 }
313}
314
315/// The live session that still holds a preserved branch.
316#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
317pub struct HeldBy {
318 /// The session, so an operator can wait for it or close it by name.
319 pub token: SessionToken,
320 /// The worktree its work is being made in.
321 pub worktree: PathBuf,
322 /// How this host can tell it is still live.
323 pub holding: Holding,
324}
325
326/// How a host can tell that a session still holds its branch.
327///
328/// Two answers rather than one, because the two are true at different times: a
329/// consumer holding a [`Session`] keeps the process that opened it alive, while the
330/// CLI takes an occupancy lease per command and outlives none of them. Either one is
331/// a session that has not finished with its branch.
332#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
333#[serde(rename_all = "kebab-case")]
334pub enum Holding {
335 /// The session is open and the process that opened it is still running, which is
336 /// the question [`Liveness::Live`] answers.
337 OwnerRunning,
338 /// Something holds the occupancy lease on its run root right now, so a command
339 /// is working in there whatever became of the process that opened the session.
340 RunRootOccupied,
341}
342
343impl Holding {
344 /// The clause a report gives as the reason, for the command line: `--json`
345 /// carries the value itself.
346 pub fn because(&self) -> &'static str {
347 match self {
348 Self::OwnerRunning => "the process that opened it is still running",
349 Self::RunRootOccupied => "a command is working in its run root right now",
350 }
351 }
352}
353
354/// The lines a branch would land, as git counts them against the commit it forked
355/// from.
356///
357/// The fork point rather than the base's tip, because what the branch did is what it
358/// did to the tree it started on: measured against a base that has moved on, every
359/// line that base gained reads as a line this branch removed and never touched.
360#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
361pub struct LineChange {
362 /// Lines it adds.
363 pub added: u64,
364 /// Lines it removes.
365 pub removed: u64,
366}
367
368/// A [`LineChange`] that removes more than it adds, and the only thing that can be
369/// one.
370///
371/// The mark and its evidence are one value, so a row cannot carry a count that says
372/// the opposite of the field holding it — and the rule that decides "net-negative"
373/// lives here rather than at the site that measures a branch and at every consumer
374/// that reads one back. It serializes and reads as the [`LineChange`] it holds, so
375/// the two counts are what `--json` carries either way; a document naming a count
376/// that is not net-negative is refused where it is read.
377#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
378#[serde(try_from = "LineChange", into = "LineChange")]
379pub struct NetNegative(LineChange);
380
381impl NetNegative {
382 /// The count, when it is one a net-negative branch could have.
383 pub fn new(lines: LineChange) -> Option<Self> {
384 (lines.removed > lines.added).then_some(Self(lines))
385 }
386
387 /// Lines the branch adds.
388 pub fn added(&self) -> u64 {
389 self.0.added
390 }
391
392 /// Lines it removes, which is more than it adds.
393 pub fn removed(&self) -> u64 {
394 self.0.removed
395 }
396}
397
398impl TryFrom<LineChange> for NetNegative {
399 type Error = String;
400
401 fn try_from(lines: LineChange) -> std::result::Result<Self, Self::Error> {
402 Self::new(lines).ok_or_else(|| {
403 format!(
404 "{} line(s) added and {} removed is not a net-negative change",
405 lines.added, lines.removed
406 )
407 })
408 }
409}
410
411impl From<NetNegative> for LineChange {
412 fn from(value: NetNegative) -> Self {
413 value.0
414 }
415}