Skip to main content

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}