onevcs 0.34.0

Version control and remote-host abstraction for agent workflows: host-neutral change requests, sessions, and a rules system.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
//! A session: the clone and worktree a change is made in — a pooled slot the host
//! keeps warm, or one cut for this run — and what is left behind when one does not
//! finish.

use std::collections::BTreeMap;
use std::path::PathBuf;

use serde::{Deserialize, Serialize};
use url::Url;

use crate::landed::Landed;
use crate::workspaces::Bound;

/// What to open a session over.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionRequest {
    /// The repository: an identity key, a registered alias, or a path.
    pub repo: String,
    /// The branch to work on.
    ///
    /// A name that already exists — in a checkout of this identity, or on origin —
    /// is **continued**: the session's worktree is opened at that branch's tip and
    /// carries the work already on it. A name nothing carries yet is cut fresh from
    /// [`base`](Self::base). Absent means one is derived, which is always a fresh
    /// cut.
    pub branch: Option<String>,
    /// A name the caller wants a branch **cut** at, which is a different question
    /// from [`branch`](Self::branch).
    ///
    /// `branch` names a branch to *continue* — anything already carrying that name
    /// is opened at its tip — so a rendered name cannot arrive there: a collision
    /// would silently continue somebody else's branch. This one is a *proposal*, and
    /// this crate does all of the work of turning it into a branch: it is sanitized
    /// into a name git accepts, the host's [branch prefix](crate::branches) is put
    /// in front of it, and the first free of `<name>`, `<name>-2`, `<name>-3`, … is
    /// taken — searched against this identity's local branches and its origin's. A
    /// proposal nothing usable is left of, and one no suffix can make free, are
    /// refused naming the proposal rather than silently replaced by a derived name.
    ///
    /// Absent means a name is derived from the session's own token, exactly as
    /// before. Naming this **and** `branch` is refused: they are two answers to one
    /// question.
    // llmlint: ignore[invalid_states_unrepresentable] a proposal is arbitrary text by
    // design, for the reason `SessionOpenArgs::branch_name` states: there is no
    // invalid value here to make unrepresentable, only one this crate sanitizes. The
    // amendment in docs/contract.md also fixes the declared shape as
    // `Option<String>`, and two consuming repositories build against that spelling.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub branch_name: Option<String>,
    /// The branch prefix this one open cuts against, over everything the host
    /// configures — the highest of the three layers [`branches`](crate::branches)
    /// resolves. Absent means the host's resolution; `Some("")` is this open saying
    /// it wants none.
    ///
    /// It reaches only branches this open **cuts**: a branch named by
    /// [`branch`](Self::branch) is continued or cut at exactly that name, prefixed
    /// by nothing.
    // llmlint: ignore[invalid_states_unrepresentable] a newtype here would have to
    // ask git whether a ref may start with the value, and the answer has to name the
    // layer that set it — which a `TryFrom` cannot, because the same value reaches
    // this field from a flag, from the environment and from the host's file.
    // `branches::resolve` is that one boundary, and `branch` beside this field is an
    // `Option<String>` for the same contract reason.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub branch_prefix: Option<String>,
    /// The branch this session's work is merged with and published into.
    ///
    /// For a branch cut fresh it is also the point the branch starts from. For a
    /// branch that already exists it is the integration target and nothing else:
    /// what is merged in when it has moved on, and what the publication is compared
    /// against. Absent means the identity's registered base. Naming the session's
    /// own branch here is refused — it would publish the branch into itself.
    pub base: Option<String>,
    /// Which registered checkout to clone from. Absent means the identity's
    /// default execution checkout.
    pub execution_checkout: Option<String>,
    /// The pool size this one open places against, over everything the host
    /// configures: `0` places this session fresh under `runs/` — and still spends the
    /// overflow, because the cap is about disk — and `N` may cut a slot while fewer
    /// than `N` exist. It removes no slot. Absent means the host's resolution.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pool: Option<u32>,
    /// The overflow bound this one open is admitted against, over everything the
    /// host configures; `Bound::Unlimited` is how one open opts out of the cap.
    /// Absent means the host's resolution.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub overflow: Option<Bound>,
    /// What the caller wants the session record to say about who opened it: the
    /// run, the node, the launching process — whatever it takes to find this
    /// session again without joining anything else. A key is `[A-Za-z0-9_-]+` and a
    /// value is one line; what a key *means* is the caller's to declare. Stored on
    /// the record, reported by `session holders` and on every `recoverable` row,
    /// and what `--label` filters both by. A resumed session takes each key the
    /// request names over the one it had and keeps the rest.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub labels: BTreeMap<String, String>,
}

/// The handle a session is adopted, published, and closed by.
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(transparent)]
pub struct SessionToken(pub String);

/// An open session: a per-run shared clone plus a worktree, held under an
/// occupancy lease.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Session {
    /// The handle this session is addressed by.
    pub token: SessionToken,
    /// The worktree the change is made in.
    pub worktree: PathBuf,
    /// The branch the worktree has checked out.
    pub branch: String,
    /// The branch this session's work is merged with and published into, which for
    /// a branch cut fresh is also the one it was cut from.
    pub base: String,
}

/// Where a session is in its life.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Lifecycle {
    /// It has a worktree and its work has not been published or released.
    Open,
    /// Its worktree is gone and its branch has been handed back. The session is
    /// still addressable, because the branch it names is still the only record of
    /// the work.
    Closed,
}

/// Everything the implementation that opened a session records about it.
///
/// A [`Session`] is the handle a caller was given; this is what the repository side
/// knows about it afterwards, and it is why the record had to come through the
/// interface: which repository the session belongs to, whether it is still open,
/// and whether its branch carries an incomplete-step marker are all questions a
/// command asks between opening a session and publishing it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionRecord {
    /// The session itself.
    pub session: Session,
    /// The identity key the session belongs to.
    // llmlint: ignore[invalid_states_unrepresentable] an identity key is a `String`
    // everywhere this contract spells one — `Recoverable::identity`, `Identity::origin`,
    // and the registry document's own map key — so a newtype here would disagree with the
    // types it names and add a public item the contract does not. Every value written here
    // came out of a registry the implementation resolved against, which is where the key
    // is normalized and decided.
    pub identity: String,
    /// Where the session is in its life.
    pub lifecycle: Lifecycle,
    /// What the session's branch carries now — an adopted session that was left
    /// dirty carries [`Provenance::IncompleteStep`], and must pass the merge-path
    /// merge path through `onevcs recover` before it may be published.
    pub provenance: Provenance,
    /// The session that continued this one's branch, once one has.
    ///
    /// A branch a run left behind is picked up by the next session over the same
    /// name, and this is the link between the two — written onto the *older*
    /// record when the newer one opens. It is the answer to "which of these two
    /// copies of one branch is the work", which nothing else on either record
    /// gives, and everything that reports what became of a session or a branch
    /// follows it to the newest record before it answers.
    ///
    /// Absent on every session nothing has superseded, which is most of them.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retried_by: Option<SessionToken>,
}

/// One session that holds a repository's workspace, as an enumeration reports it.
///
/// A [`SessionRecord`] answers about a session somebody already has the token for;
/// this answers the question before that one — *who is in this repository* — for
/// every session the host has recorded that anybody is left to answer for: one
/// whose owner process is still there, and one a live process is still working
/// inside. A record with neither is forgotten by the enumeration rather than
/// reported above every launch. It is what `onevcs session holders` prints, so a
/// caller reading the command's JSON and a caller embedding
/// [`crate::session_holders`] read the same shape.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct SessionHolder {
    /// The handle this session is addressed by, and the one every command that
    /// takes a session takes.
    pub token: SessionToken,
    /// The identity key the session belongs to.
    pub identity: String,
    /// The branch its worktree has checked out.
    pub branch: String,
    /// The worktree the change is made in. A closed session's is gone; its branch
    /// is not.
    pub worktree: PathBuf,
    /// The process that opened it, so a diagnostic can name who to look for.
    pub owner_pid: u32,
    /// Where the session is in its life. Spelled `state` because that is the key
    /// `onevcs session holders --json` prints it under.
    pub state: Lifecycle,
    /// Whether the process that opened it is still there.
    pub liveness: Liveness,
    /// The labels the session was opened with. Always written — an empty object for
    /// a session opened without any, and for every record written before there was
    /// a field to write.
    #[serde(default)]
    pub labels: BTreeMap<String, String>,
}

/// Whether a session's owner is still running, which is what makes its lease real.
///
/// A recorded pid alone cannot answer this — the OS reuses pids, so a later process
/// wearing a dead session's number would read as its owner. The answer is the pid
/// *and* that process's creation identity, which is why it is reported rather than
/// left for a caller to derive from [`SessionHolder::owner_pid`].
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Liveness {
    /// The session is open and the process that opened it is that same process,
    /// still running.
    Live,
    /// Anything else: the session is closed, its owner exited, or its pid now
    /// belongs to a different process.
    Stale,
}

impl Liveness {
    /// The word this crate reports it as, on the command line and in JSON.
    pub fn as_str(&self) -> &'static str {
        match self {
            Self::Live => "live",
            Self::Stale => "stale",
        }
    }
}

/// Why a branch was preserved, and therefore what recovering it must do.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Provenance {
    /// The step finished; the branch is ready to publish as it stands.
    Complete,
    /// The step did not finish. Its work was committed behind an incomplete-step
    /// marker and must pass its merge path before it may be published.
    IncompleteStep,
}

/// A branch that holds work outside its session.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PreservedBranch {
    /// The branch the work is on.
    pub branch: String,
    /// The base it was cut from.
    pub base: String,
    /// Why it was preserved.
    pub provenance: Provenance,
    /// The change request it belongs to, when one was opened. Host-neutral: a
    /// GitHub pull request and a GitLab merge request both land here.
    pub change_url: Option<Url>,
    /// The base that change request targets, which for a stacked branch is the
    /// branch below it rather than the root.
    pub change_base: Option<String>,
}

/// Which preserved work to look for.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Scope {
    /// Every registered identity.
    All,
    /// One repository: an identity key, a registered alias, or a path.
    Repo(String),
}

/// Which of the preserved work in scope a report is asked to answer with.
///
/// Both halves are `and`ed with each other and with the [`Scope`]: a row is answered
/// with when its branch is one the named sessions hold or held *and* the labels of
/// the session that answers for it carry every pair asked for. Empty asks nothing,
/// which is what every read before there was a selection asked — so [`Default`] is
/// the whole report.
///
/// A session token no record on this host names is refused by name rather than
/// answered with an empty report: "nothing of that session is left to publish" and
/// "there is no such session" are different answers to act on. A label pair nothing
/// carries is the first of those, and answers empty.
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Selection {
    /// The sessions whose branches are asked about, by token.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub sessions: Vec<SessionToken>,
    /// The pairs every answered row's labels must carry.
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub labels: BTreeMap<String, String>,
}

impl Selection {
    /// Whether this asks anything at all.
    pub fn is_empty(&self) -> bool {
        self.sessions.is_empty() && self.labels.is_empty()
    }
}

/// Preserved work, whether it reached its base, and what would land it.
///
/// Read through the conversion below, which is where the one thing this row could
/// contradict itself about is settled: a row saying the work reached the base *and*
/// carrying the argv that publishes it again does not deserialize at all. The row is
/// read to be pasted, and the whole reason the answer is on it is that pasting one
/// for finished work re-opens a change request for what the base has.
// llmlint: ignore[invalid_states_unrepresentable] the two cannot be *one* field: this
// row's field list is the recorded public surface in docs/inferred-surface.md, which a
// consumer parses `--json` into, and folding the answer and the argv into one value would
// change the document every consumer already reads. What is available is what this crate
// does everywhere a rule spans two values it must keep: the one place that builds a row
// derives both from the same verdict, and the boundary where a row is *read* refuses a
// document whose two halves disagree.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "AnyRecoverable")]
pub struct Recoverable {
    /// The identity the work belongs to.
    pub identity: String,
    /// The branch and why it was preserved.
    pub branch: PreservedBranch,
    /// The checkout the branch can be reached from.
    pub checkout: PathBuf,
    /// Why the workstream stopped.
    pub stopped_because: String,
    /// Whether the work reached the base, and what says so.
    ///
    /// The one field on this row that decides whether the row is an instruction at
    /// all: a row whose work reached the base carries no argv.
    ///
    /// Written always and defaulted on the way in, so a stored document that
    /// predates the field reads back as the answer it gave: nothing said.
    #[serde(default)]
    pub landed: Landed,
    /// The argv that lands it, ready to run.
    ///
    /// Ready to run is a claim about the branch as much as about the argv, so it
    /// holds only where the two fields below say nothing: work a live session is
    /// still writing to has not stopped, and running this on it publishes a branch
    /// mid-flight.
    ///
    /// **Empty where nothing may be run**, which is every row whose work reached the
    /// base: the argv would publish work that is already there. A row is read to be
    /// pasted, so the answer is the absence of a command rather than a command with
    /// a warning beside it.
    pub recover_command: Vec<String>,
    /// The live session still writing to this branch, when one is.
    ///
    /// Present at all is the answer: this is not preserved work yet, and
    /// [`recover_command`](Self::recover_command) must not be run until that session
    /// is done with it. Absent — every row of a report about work that really did
    /// stop — and the row is exactly what it has always been.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub held_by: Option<HeldBy>,
    /// What the branch would land, when it removes more lines than it adds.
    ///
    /// A branch that deletes far more than it adds may be perfectly correct, and it
    /// is not something to publish unread — so it is marked rather than excluded,
    /// and marked only then: a branch that adds at least as much as it removes
    /// carries nothing here, and cannot, because [`NetNegative`] holds no such count.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub net_negative: Option<NetNegative>,
    /// The session that answers for this branch: the newest record naming it — the
    /// end of its chain of retries, an open one preferred — or `None` where no record
    /// on this host names the branch at all, which is what a `worktree-agent-*`
    /// orphan left behind by nothing this crate opened reads as.
    ///
    /// Written always, `null` included, so a reader routing on it meets the key on
    /// every row.
    #[serde(default)]
    pub session: Option<SessionToken>,
    /// That session's labels: what the caller that opened it said about who opened
    /// it. An empty object where the record carries none, and where
    /// [`session`](Self::session) is `None`.
    #[serde(default)]
    pub labels: BTreeMap<String, String>,
    /// Where `onevcs preserve` put this branch on its identity's origin, when it has.
    ///
    /// Additive, and absent for a branch nothing has preserved — which is every row
    /// of a report a consumer that predates the field reads. It changes nothing about
    /// [`recover_command`](Self::recover_command): being on the origin under its own
    /// name is not being published, and the verb that lands the work is the verb that
    /// landed it before. That is the whole point of the verb being called *preserve*.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub on_origin: Option<OnOrigin>,
    /// What the branch is for the question of whether it may be deleted — the
    /// classification `onevcs retire` and `onevcs reclaim` act on, made here from this
    /// host's records and local refs alone, since this report never asks the host.
    ///
    /// Additive: every row `onevcs` answers carries it, and a row an implementation
    /// that does not classify answered omits it, as a consumer that predates the field
    /// never meets it. A row whose class is `retirable` is not in the default report,
    /// as a row whose work landed is not.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub retirement: Option<crate::retire::Retirement>,
}

/// A row as a document spells it, before the answer on it and the command beside it
/// have been held to each other.
///
/// The same fields, and it exists only so that the check above has something to run
/// on: serde hands a conversion the whole value or nothing, and what has to be
/// checked here is one field against another.
#[derive(Deserialize)]
struct AnyRecoverable {
    identity: String,
    branch: PreservedBranch,
    checkout: PathBuf,
    #[serde(default)]
    landed: Landed,
    stopped_because: String,
    recover_command: Vec<String>,
    #[serde(default)]
    held_by: Option<HeldBy>,
    #[serde(default)]
    net_negative: Option<NetNegative>,
    #[serde(default)]
    session: Option<SessionToken>,
    #[serde(default)]
    labels: BTreeMap<String, String>,
    on_origin: Option<OnOrigin>,
    #[serde(default)]
    retirement: Option<crate::retire::Retirement>,
}

impl TryFrom<AnyRecoverable> for Recoverable {
    type Error = String;

    fn try_from(value: AnyRecoverable) -> std::result::Result<Self, Self::Error> {
        if value.landed.is_landed() && !value.recover_command.is_empty() {
            return Err(format!(
                "the row for branch {branch:?} says its work reached {base} and carries \
                 {command:?} to publish it again; a row that landed carries no command",
                branch = value.branch.branch,
                base = value.branch.base,
                command = value.recover_command.join(" "),
            ));
        }
        Ok(Recoverable {
            identity: value.identity,
            branch: value.branch,
            checkout: value.checkout,
            landed: value.landed,
            stopped_because: value.stopped_because,
            recover_command: value.recover_command,
            held_by: value.held_by,
            net_negative: value.net_negative,
            session: value.session,
            labels: value.labels,
            on_origin: value.on_origin,
            retirement: value.retirement,
        })
    }
}

/// Where a branch was last put on its identity's origin without being published.
///
/// The readback of what `onevcs preserve` recorded, and the answer to one question:
/// if this host went away now, is the work somewhere that outlives it? Both halves
/// are needed to answer it — the origin says *where*, and the commit says *how much*,
/// since a branch preserved and then committed to again is on its origin at a commit
/// that is no longer its tip.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct OnOrigin {
    /// The origin URL the branch went to.
    // llmlint: ignore[invalid_states_unrepresentable] a remote is a `String` everywhere
    // this crate's public surface spells one, and this is the URL a repository's own
    // `origin` was configured with rather than a form this crate composes — what makes it
    // usable is checked where the record is read (`git::is_usable_remote`, before it can
    // reach a line an operator reads), and the field itself is spelled by
    // `docs/contract.md`.
    pub remote: String,
    /// The commit the origin carries it at.
    // llmlint: ignore[invalid_states_unrepresentable] a commit is a `String` everywhere
    // this crate's public surface spells one that came out of git — `Sha` wraps an
    // unvalidated `String` and would make no state here unrepresentable — and this value
    // is one `git push` reported or `git ls-remote` advertised, checked as an object id
    // where it was read.
    pub commit: String,
}

/// The live session that still holds a preserved branch.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct HeldBy {
    /// The session, so an operator can wait for it or close it by name.
    pub token: SessionToken,
    /// The worktree its work is being made in.
    pub worktree: PathBuf,
    /// How this host can tell it is still live.
    pub holding: Holding,
}

/// How a host can tell that a session still holds its branch.
///
/// Two answers rather than one, because the two are true at different times: a
/// consumer holding a [`Session`] keeps the process that opened it alive, while the
/// CLI takes an occupancy lease per command and outlives none of them. Either one is
/// a session that has not finished with its branch.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Holding {
    /// The session is open and the process that opened it is still running, which is
    /// the question [`Liveness::Live`] answers.
    OwnerRunning,
    /// Something holds the occupancy lease on its run root right now, so a command
    /// is working in there whatever became of the process that opened the session.
    RunRootOccupied,
}

impl Holding {
    /// The clause a report gives as the reason, for the command line: `--json`
    /// carries the value itself.
    pub fn because(&self) -> &'static str {
        match self {
            Self::OwnerRunning => "the process that opened it is still running",
            Self::RunRootOccupied => "a command is working in its run root right now",
        }
    }
}

/// The lines a branch would land, as git counts them against the commit it forked
/// from.
///
/// The fork point rather than the base's tip, because what the branch did is what it
/// did to the tree it started on: measured against a base that has moved on, every
/// line that base gained reads as a line this branch removed and never touched.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub struct LineChange {
    /// Lines it adds.
    pub added: u64,
    /// Lines it removes.
    pub removed: u64,
}

/// A [`LineChange`] that removes more than it adds, and the only thing that can be
/// one.
///
/// The mark and its evidence are one value, so a row cannot carry a count that says
/// the opposite of the field holding it — and the rule that decides "net-negative"
/// lives here rather than at the site that measures a branch and at every consumer
/// that reads one back. It serializes and reads as the [`LineChange`] it holds, so
/// the two counts are what `--json` carries either way; a document naming a count
/// that is not net-negative is refused where it is read.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(try_from = "LineChange", into = "LineChange")]
pub struct NetNegative(LineChange);

impl NetNegative {
    /// The count, when it is one a net-negative branch could have.
    pub fn new(lines: LineChange) -> Option<Self> {
        (lines.removed > lines.added).then_some(Self(lines))
    }

    /// Lines the branch adds.
    pub fn added(&self) -> u64 {
        self.0.added
    }

    /// Lines it removes, which is more than it adds.
    pub fn removed(&self) -> u64 {
        self.0.removed
    }
}

impl TryFrom<LineChange> for NetNegative {
    type Error = String;

    fn try_from(lines: LineChange) -> std::result::Result<Self, Self::Error> {
        Self::new(lines).ok_or_else(|| {
            format!(
                "{} line(s) added and {} removed is not a net-negative change",
                lines.added, lines.removed
            )
        })
    }
}

impl From<NetNegative> for LineChange {
    fn from(value: NetNegative) -> Self {
        value.0
    }
}