Skip to main content

vcs_core/
dto.rs

1//! Backend-agnostic data types the facade returns — plus the option **specs** it
2//! accepts — generalising the per-tool shapes of `vcs-git` and `vcs-jj` into one set
3//! a consumer can use without knowing which backend is in play.
4
5use std::path::PathBuf;
6
7/// Options for [`Repo::remove_worktree`](crate::Repo::remove_worktree).
8///
9/// `#[non_exhaustive]`, so build it through [`WorktreeRemove::new`] and the chained
10/// [`force`](WorktreeRemove::force) setter rather than a struct literal — a bare
11/// `bool` at the call site (`remove_worktree(path, true)`) doesn't say what `true`
12/// means, and this leaves room to add options without a breaking signature change.
13#[derive(Debug, Clone, PartialEq, Eq)]
14#[non_exhaustive]
15pub struct WorktreeRemove {
16    /// The attached worktree (git) / secondary workspace (jj) path to remove.
17    pub path: PathBuf,
18    /// Remove even when the worktree has uncommitted changes — git `worktree remove
19    /// --force`; on jj, the snapshot-and-refuse-if-dirty guard is bypassed. The
20    /// repository's **main** worktree/workspace is refused regardless of this flag.
21    pub force: bool,
22}
23
24impl WorktreeRemove {
25    /// Remove the worktree/workspace at `path`; not forced (refuses a dirty one).
26    pub fn new(path: impl Into<PathBuf>) -> Self {
27        Self {
28            path: path.into(),
29            force: false,
30        }
31    }
32
33    /// Remove even when the worktree has uncommitted changes.
34    pub fn force(mut self) -> Self {
35        self.force = true;
36        self
37    }
38}
39
40/// Partial [`WorktreeCreate`] — carries the path and new-branch name; chain
41/// [`base`](WorktreeCreatePartial::base) to name the ref it forks from.
42#[derive(Debug, Clone)]
43pub struct WorktreeCreatePartial {
44    path: PathBuf,
45    branch: String,
46}
47
48impl WorktreeCreatePartial {
49    /// The ref the new worktree/workspace forks from — a branch, tag, or commit
50    /// (git `HEAD`; jj `@` / a change id). Required and explicit: it has no default
51    /// because the sentinel for "current" differs by backend.
52    pub fn base(self, base: impl Into<String>) -> WorktreeCreate {
53        WorktreeCreate {
54            path: self.path,
55            branch: self.branch,
56            base: base.into(),
57        }
58    }
59}
60
61/// Options for [`Repo::create_worktree`](crate::Repo::create_worktree).
62///
63/// Built as `WorktreeCreate::new(path, "feature").base("main")` — the new-branch name
64/// and the fork-point `base` (both plain strings that a swap would silently accept,
65/// creating a branch *named* like the base) are named across **two** builder steps, so
66/// they can't be transposed. `#[non_exhaustive]`.
67#[derive(Debug, Clone, PartialEq, Eq)]
68#[non_exhaustive]
69pub struct WorktreeCreate {
70    /// Where the new attached worktree (git) / secondary workspace (jj) is created.
71    pub path: PathBuf,
72    /// The new branch (git) / bookmark (jj) to create at the worktree.
73    pub branch: String,
74    /// The ref the new branch forks from (git `HEAD`, jj `@`, a branch/tag/commit).
75    pub base: String,
76}
77
78impl WorktreeCreate {
79    /// Name the worktree `path` and the new `branch` to create there; chain
80    /// [`base`](WorktreeCreatePartial::base) to name the fork point.
81    ///
82    // A type-state builder entry: `new` returns the partial (not `Self`) so `base`
83    // is mandatory — the recognised builder exception to `new_ret_no_self`.
84    #[allow(clippy::new_ret_no_self)]
85    pub fn new(path: impl Into<PathBuf>, branch: impl Into<String>) -> WorktreeCreatePartial {
86        WorktreeCreatePartial {
87            path: path.into(),
88            branch: branch.into(),
89        }
90    }
91}
92
93/// Options for [`Repo::delete_branch`](crate::Repo::delete_branch).
94///
95/// `#[non_exhaustive]`, so build it through [`BranchDelete::new`] and the chained
96/// [`force`](BranchDelete::force) setter rather than a struct literal.
97#[derive(Debug, Clone, PartialEq, Eq)]
98#[non_exhaustive]
99pub struct BranchDelete {
100    /// The local branch (git) / bookmark (jj) name to delete.
101    pub name: String,
102    /// Delete even if not fully merged — git `branch -D` vs `-d`. **git only**: jj has
103    /// no force flag for `bookmark delete` and ignores it.
104    pub force: bool,
105}
106
107impl BranchDelete {
108    /// Delete branch/bookmark `name`; not forced (git refuses an unmerged branch).
109    pub fn new(name: impl Into<String>) -> Self {
110        Self {
111            name: name.into(),
112            force: false,
113        }
114    }
115
116    /// Delete even if not fully merged (git only).
117    pub fn force(mut self) -> Self {
118        self.force = true;
119        self
120    }
121}
122
123/// Which version-control tool backs a [`Repo`](crate::Repo).
124#[derive(Debug, Clone, Copy, PartialEq, Eq)]
125#[cfg_attr(feature = "serde", derive(serde::Serialize))]
126#[non_exhaustive]
127pub enum BackendKind {
128    /// A plain Git repository.
129    Git,
130    /// A Jujutsu repository (possibly colocated with Git).
131    Jj,
132}
133
134impl BackendKind {
135    /// The tool's short name (`"git"` / `"jj"`).
136    pub fn as_str(self) -> &'static str {
137        match self {
138            BackendKind::Git => "git",
139            BackendKind::Jj => "jj",
140        }
141    }
142}
143
144/// How a file changed in the working copy — the shared [`vcs_diff::ChangeKind`]
145/// (one type across the wrappers and the facade, no remapping). The status-code
146/// mappers in the backends turn git's `XY` codes / jj's letters into it.
147pub use vcs_diff::ChangeKind;
148
149/// One changed path in the working copy, unified across `git status` /
150/// `jj diff --summary`.
151#[derive(Debug, Clone, PartialEq, Eq)]
152#[cfg_attr(feature = "serde", derive(serde::Serialize))]
153#[non_exhaustive]
154pub struct FileChange {
155    /// The path (the *new* path for a rename).
156    ///
157    /// A [`PathBuf`] (not a `String`) so a filename whose bytes are not valid UTF-8
158    /// — legal on Unix — is carried **losslessly** from `status`/`diff` and can be
159    /// fed straight back into [`Repo::commit_paths`](crate::Repo::commit_paths) /
160    /// the backend `add`. A `String` filled via `String::from_utf8_lossy` would
161    /// substitute `U+FFFD` and address a different file. See the crate's
162    /// serde-policy note for how a non-UTF-8 path is emitted as JSON.
163    pub path: PathBuf,
164    /// The original path for a rename, populated by **both** backends (git's
165    /// `R old -> new` status; jj's `{old => new}` diff-summary form); `None`
166    /// for non-renames.
167    pub old_path: Option<PathBuf>,
168    /// How the file changed.
169    pub kind: ChangeKind,
170}
171
172impl FileChange {
173    /// A change to `path` of the given `kind`, with no original path. Chain the
174    /// `old_path` setter for a rename or copy. Lets an external `VcsRepo` impl or a
175    /// test build one despite the `#[non_exhaustive]`.
176    pub fn new(path: impl Into<PathBuf>, kind: ChangeKind) -> Self {
177        Self {
178            path: path.into(),
179            old_path: None,
180            kind,
181        }
182    }
183
184    /// Record the original path — a rename's or copy's source (sets the `old_path`
185    /// field, which both a rename and a copy populate).
186    pub fn old_path(mut self, old: impl Into<PathBuf>) -> Self {
187        self.old_path = Some(old.into());
188        self
189    }
190}
191
192/// Aggregate insertion/deletion counts for the working copy — the shared
193/// [`vcs_diff::DiffStat`], returned by the backends directly (no remapping).
194pub use vcs_diff::DiffStat;
195
196/// One attached worktree (git) / workspace (jj).
197#[derive(Debug, Clone, PartialEq, Eq)]
198#[cfg_attr(feature = "serde", derive(serde::Serialize))]
199#[non_exhaustive]
200pub struct WorktreeInfo {
201    /// Filesystem path of the worktree's working copy.
202    pub path: PathBuf,
203    /// The branch (git) or first bookmark (jj) on it; `None` when detached/none.
204    pub branch: Option<String>,
205    /// The checked-out commit's **full** object id (git `HEAD` oid / jj `@` commit
206    /// id) on both backends — the same identity a [`RepoSnapshot::head`] carries, so
207    /// the two can be compared directly to tell whether a worktree sits on the
208    /// snapshotted commit. Not a display-truncated prefix (which could collide);
209    /// truncate for display. `None` when unavailable (e.g. a bare git entry).
210    pub commit: Option<String>,
211    /// A bare git worktree entry (always `false` for jj).
212    pub is_bare: bool,
213}
214
215impl WorktreeInfo {
216    /// A worktree at `path` with no branch/commit and not bare; chain the setters.
217    pub fn new(path: impl Into<PathBuf>) -> Self {
218        Self {
219            path: path.into(),
220            branch: None,
221            commit: None,
222            is_bare: false,
223        }
224    }
225
226    /// Set the branch (git) / first bookmark (jj) on the worktree.
227    pub fn branch(mut self, branch: impl Into<String>) -> Self {
228        self.branch = Some(branch.into());
229        self
230    }
231
232    /// Set the checked-out commit.
233    pub fn commit(mut self, commit: impl Into<String>) -> Self {
234        self.commit = Some(commit.into());
235        self
236    }
237
238    /// Mark it a bare git worktree entry.
239    pub fn bare(mut self) -> Self {
240        self.is_bare = true;
241        self
242    }
243}
244
245/// Whether the working copy is mid-operation, unified across the backends'
246/// different models: git exposes an in-progress merge, rebase, `am`, cherry-pick,
247/// revert, or bisect as on-disk state (`MERGE_HEAD` / a `rebase-*` dir /
248/// `CHERRY_PICK_HEAD` / `REVERT_HEAD` / `BISECT_LOG`), while jj has no multi-step
249/// operations — it records a conflict directly on the working-copy change.
250///
251/// The sequencer states are kept **distinct** because each aborts (and, where it
252/// makes sense, continues) with its *own* git command — dispatching the wrong one
253/// on a user's real repository is exactly what this type exists to prevent. See
254/// [`Repo::abort_in_progress`](crate::Repo::abort_in_progress) /
255/// [`continue_in_progress`](crate::Repo::continue_in_progress).
256#[derive(Debug, Clone, Copy, PartialEq, Eq)]
257#[cfg_attr(feature = "serde", derive(serde::Serialize))]
258#[non_exhaustive]
259pub enum OperationState {
260    /// No operation in progress and no conflict.
261    Clear,
262    /// A git merge is in progress (`MERGE_HEAD` present).
263    Merge,
264    /// A git rebase is in progress (a `rebase-merge` dir, or a `rebase-apply` dir
265    /// **not** left by `git am` — see [`ApplyMailbox`](OperationState::ApplyMailbox)).
266    Rebase,
267    /// A git `am` (mailbox patch apply) is in progress. Distinct from `Rebase`
268    /// because it aborts with `am --abort`, not `rebase --abort` (M20).
269    ApplyMailbox,
270    /// A git cherry-pick is in progress (`CHERRY_PICK_HEAD` present). Distinct
271    /// from `Merge`: it aborts/continues with `cherry-pick --abort` /
272    /// `cherry-pick --continue` (a cherry-pick conflict writes `CHERRY_PICK_HEAD`,
273    /// **not** `MERGE_HEAD`). git only.
274    CherryPick,
275    /// A git revert is in progress (`REVERT_HEAD` present). Aborts/continues with
276    /// `revert --abort` / `revert --continue`. git only.
277    Revert,
278    /// A git bisect session is in progress (`BISECT_LOG` present). Aborts with
279    /// `bisect reset`; it has **no** `--continue` step (bisect advances by marking
280    /// commits good/bad), so
281    /// [`continue_in_progress`](crate::Repo::continue_in_progress) reports it as
282    /// unsupported rather than silently doing nothing. git only.
283    Bisect,
284    /// The working copy has an unresolved conflict (chiefly jj, which records
285    /// conflicts on the change rather than pausing an operation).
286    Conflict,
287}
288
289/// Upstream tracking for the current branch: the upstream ref and how far the
290/// branch is ahead/behind it. [`RepoSnapshot`] carries it as one
291/// `Option<UpstreamTracking>` — `None` when no upstream is configured at all.
292///
293/// The ahead/behind counts are themselves `Option`: git reports them only when the
294/// upstream ref actually **resolves**, so a branch whose upstream is *set but gone*
295/// (deleted on the remote, or not yet fetched) yields `Some(UpstreamTracking { branch,
296/// ahead: None, behind: None })` — "tracking configured but uncountable", distinct
297/// from the in-sync `Some(0)`/`Some(0)` that a `unwrap_or(0)` used to fabricate (M17).
298#[derive(Debug, Clone, PartialEq, Eq)]
299#[cfg_attr(feature = "serde", derive(serde::Serialize))]
300#[non_exhaustive]
301pub struct UpstreamTracking {
302    /// The upstream tracking branch, e.g. `"origin/main"`.
303    pub branch: String,
304    /// Commits the local branch is ahead of the upstream; `None` when the upstream is
305    /// set but git couldn't count against it (gone remote / not fetched).
306    pub ahead: Option<usize>,
307    /// Commits the local branch is behind the upstream; `None` when uncountable (see
308    /// [`ahead`](UpstreamTracking::ahead)).
309    pub behind: Option<usize>,
310}
311
312impl UpstreamTracking {
313    /// Tracking `branch` (e.g. `"origin/main"`) with **uncounted** ahead/behind
314    /// (both `None`); chain [`ahead`](UpstreamTracking::ahead) /
315    /// [`behind`](UpstreamTracking::behind) to set the counts.
316    pub fn new(branch: impl Into<String>) -> Self {
317        Self {
318            branch: branch.into(),
319            ahead: None,
320            behind: None,
321        }
322    }
323
324    /// Set the ahead count.
325    pub fn ahead(mut self, n: usize) -> Self {
326        self.ahead = Some(n);
327        self
328    }
329
330    /// Set the behind count.
331    pub fn behind(mut self, n: usize) -> Self {
332        self.behind = Some(n);
333        self
334    }
335}
336
337/// A one-shot snapshot of the common repository state — branch, upstream
338/// tracking, ahead/behind, dirtiness, and operation state — gathered in a
339/// **small fixed** number of process spawns instead of a call per field. The
340/// data a prompt, status line, or TUI refresh needs. See
341/// [`Repo::snapshot`](crate::Repo::snapshot).
342#[derive(Debug, Clone, PartialEq, Eq)]
343#[cfg_attr(feature = "serde", derive(serde::Serialize))]
344#[non_exhaustive]
345pub struct RepoSnapshot {
346    /// The working-copy commit's **full** object id (git `HEAD` oid / jj `@`
347    /// commit id) on both backends; `None` on an unborn git repo. Truncate for
348    /// display. Carries the full id (not a short prefix) so it can be
349    /// cross-referenced against a [`WorktreeInfo::commit`] or a git oid without a
350    /// short-prefix collision.
351    pub head: Option<String>,
352    /// Current branch (git) / bookmark (jj). On jj this is the nearest bookmark
353    /// reachable from `@` (`heads(::@ & bookmarks())`), so it stays set across a
354    /// `jj describe`/`jj new`/`jj commit`; `None` when detached / no bookmark on
355    /// or above `@`. Matches [`Repo::current_branch`](crate::Repo::current_branch)
356    /// by construction.
357    pub branch: Option<String>,
358    /// Upstream tracking and how far the branch is ahead/behind it, as one unit —
359    /// `Some` only when an upstream is configured, `None` otherwise (and **always
360    /// `None` on jj**, which has no git-style upstream tracking). Bundling the
361    /// three together makes the "all-or-nothing" relationship unrepresentable as a
362    /// half-populated state. See [`UpstreamTracking`].
363    pub tracking: Option<UpstreamTracking>,
364    /// Whether the working copy has any uncommitted change (tracked or untracked).
365    pub dirty: bool,
366    /// Number of changed paths (tracked + untracked on git; the `@` change's
367    /// files on jj).
368    pub change_count: usize,
369    /// Whether the working copy has an unresolved conflict.
370    pub conflicted: bool,
371    /// In-progress operation / conflict state (see [`OperationState`]).
372    pub operation: OperationState,
373}
374
375impl RepoSnapshot {
376    /// A clean snapshot: detached (no `head`/`branch`), no upstream tracking, not
377    /// dirty or conflicted, change count 0, [`OperationState::Clear`]. Chain the
378    /// setters to fill it — for a test double or a custom `VcsRepo` backend that must
379    /// return a `RepoSnapshot` (the struct is `#[non_exhaustive]`, so it can't be
380    /// built with a literal outside this crate).
381    pub fn new() -> Self {
382        Self {
383            head: None,
384            branch: None,
385            tracking: None,
386            dirty: false,
387            change_count: 0,
388            conflicted: false,
389            operation: OperationState::Clear,
390        }
391    }
392
393    /// Set the working-copy commit's object id.
394    pub fn head(mut self, head: impl Into<String>) -> Self {
395        self.head = Some(head.into());
396        self
397    }
398
399    /// Set the current branch (git) / bookmark (jj).
400    pub fn branch(mut self, branch: impl Into<String>) -> Self {
401        self.branch = Some(branch.into());
402        self
403    }
404
405    /// Set the upstream tracking (see [`UpstreamTracking`]).
406    pub fn tracking(mut self, tracking: UpstreamTracking) -> Self {
407        self.tracking = Some(tracking);
408        self
409    }
410
411    /// Mark the working copy dirty and record how many paths changed (a real snapshot
412    /// has `change_count >= 1` when dirty — the two fields move together, so this
413    /// setter couples them). A clean copy is the [`new`](RepoSnapshot::new) default.
414    pub fn dirty(mut self, change_count: usize) -> Self {
415        self.dirty = true;
416        self.change_count = change_count;
417        self
418    }
419
420    /// Mark the working copy as having an unresolved conflict.
421    pub fn conflicted(mut self) -> Self {
422        self.conflicted = true;
423        self
424    }
425
426    /// Set the in-progress operation / conflict state.
427    pub fn operation(mut self, operation: OperationState) -> Self {
428        self.operation = operation;
429        self
430    }
431}
432
433impl Default for RepoSnapshot {
434    fn default() -> Self {
435        Self::new()
436    }
437}
438
439/// The outcome of a [`try_merge`](crate::Repo::try_merge) probe. The probe
440/// itself is rolled back before it returns, whatever the outcome — this only
441/// *reports* what a real merge would do.
442#[derive(Debug, Clone, PartialEq, Eq)]
443#[cfg_attr(feature = "serde", derive(serde::Serialize))]
444// Adjacently tagged so the JSON is a *type-stable object* for both outcomes —
445// `{"outcome":"Clean"}` and `{"outcome":"Conflicts","files":[…]}` — rather than
446// serde's default externally-tagged shape, which would emit a bare string
447// `"Clean"` for one variant and an object for the other (a polymorphic result an
448// agent consumer can't branch on uniformly).
449#[cfg_attr(feature = "serde", serde(tag = "outcome", content = "files"))]
450#[non_exhaustive]
451pub enum MergeProbe {
452    /// The merge would apply without conflicts.
453    Clean,
454    /// The merge would conflict in these paths (repo-relative, `/` separators —
455    /// the same contract and [`PathBuf`] type as
456    /// [`conflicted_files`](crate::Repo::conflicted_files), so a non-UTF-8 path is
457    /// carried losslessly).
458    Conflicts(Vec<PathBuf>),
459}
460
461impl MergeProbe {
462    /// Whether the probe found no conflicts.
463    pub fn is_clean(&self) -> bool {
464        matches!(self, MergeProbe::Clean)
465    }
466}
467
468/// One commit/change from the repository history — the honest least common
469/// denominator between git's typed `git log` (`vcs_git::parse::Commit`, which
470/// carries hash/short-hash/author/date/subject) and jj's typed `jj log`
471/// (`vcs_jj::parse::Change`, which carries change-id/commit-id/empty/description).
472/// See [`Repo::log`](crate::Repo::log).
473///
474/// `author`/`date` are `Some` only on git: jj's typed log doesn't currently
475/// surface authorship or a timestamp (its template renders only the id/empty/
476/// description columns), so this DTO leaves them `None` on jj rather than
477/// fabricating a value.
478#[derive(Debug, Clone, PartialEq, Eq)]
479#[cfg_attr(feature = "serde", derive(serde::Serialize))]
480#[non_exhaustive]
481pub struct Commit {
482    /// The commit's identifying hash: git's full object id (`%H`) / jj's
483    /// (already-short) commit id.
484    pub id: String,
485    /// Commit message: git's subject line (`%s`) / jj's first description line.
486    pub description: String,
487    /// Author name (git `%an`); `None` on jj (see the type docs).
488    pub author: Option<String>,
489    /// Author date, strict ISO-8601 on git (`%aI`); `None` on jj (see the type
490    /// docs).
491    pub date: Option<String>,
492}
493
494impl Commit {
495    /// A commit `id` with `description`, no author/date (jj's typed-log shape);
496    /// chain [`author`](Commit::author) / [`date`](Commit::date) to add them
497    /// (git's shape). Lets an external `VcsRepo` impl or a test build one despite
498    /// the `#[non_exhaustive]`.
499    pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
500        Self {
501            id: id.into(),
502            description: description.into(),
503            author: None,
504            date: None,
505        }
506    }
507
508    /// Set the author name.
509    pub fn author(mut self, author: impl Into<String>) -> Self {
510        self.author = Some(author.into());
511        self
512    }
513
514    /// Set the author date.
515    pub fn date(mut self, date: impl Into<String>) -> Self {
516        self.date = Some(date.into());
517        self
518    }
519}
520
521/// How a worktree was materialised. The facade always reports
522/// [`Plain`](CreateOutcome::Plain); the [`CowCloned`](CreateOutcome::CowCloned)
523/// variant exists so a consumer that layers a copy-on-write strategy on top can
524/// reuse this type.
525#[derive(Debug, Clone, Copy, PartialEq, Eq)]
526#[cfg_attr(feature = "serde", derive(serde::Serialize))]
527#[non_exhaustive]
528pub enum CreateOutcome {
529    /// The tool materialised the working copy itself.
530    Plain,
531    /// A copy-on-write clone populated the working copy (consumer-supplied).
532    CowCloned,
533}
534
535// The optional `serde` feature derives `Serialize` on the facade DTOs.
536#[cfg(all(test, feature = "serde"))]
537mod serde_tests {
538    use super::*;
539
540    #[test]
541    fn snapshot_and_file_change_serialize_to_clean_json() {
542        let snap = RepoSnapshot {
543            head: Some("abc".into()),
544            branch: Some("main".into()),
545            tracking: Some(UpstreamTracking {
546                branch: "origin/main".into(),
547                ahead: Some(1),
548                behind: Some(0),
549            }),
550            dirty: true,
551            change_count: 2,
552            conflicted: false,
553            operation: OperationState::Merge,
554        };
555        let v = serde_json::to_value(&snap).unwrap();
556        assert_eq!(v["branch"], "main");
557        assert_eq!(v["operation"], "Merge"); // enum → variant name
558        assert_eq!(v["change_count"], 2);
559        // Tracking serialises as one nested object (or null), not three fields.
560        assert_eq!(v["tracking"]["branch"], "origin/main");
561        assert_eq!(v["tracking"]["ahead"], 1);
562
563        let fc = FileChange {
564            path: "a.rs".into(),
565            old_path: None,
566            kind: ChangeKind::Added, // re-exported vcs_diff type, Serialize via vcs-diff/serde
567        };
568        let v = serde_json::to_value(fc).unwrap();
569        // A `PathBuf` field serialises as a plain JSON string for a UTF-8 path.
570        assert_eq!(v["path"], "a.rs");
571        assert_eq!(v["kind"], "Added");
572    }
573
574    // Every `OperationState` variant, including the sequencer additions, serialises
575    // to its bare variant name (the JSON a `snapshot`/MCP consumer branches on).
576    #[test]
577    fn operation_state_variants_serialize_to_their_names() {
578        for (state, name) in [
579            (OperationState::Clear, "Clear"),
580            (OperationState::Merge, "Merge"),
581            (OperationState::Rebase, "Rebase"),
582            (OperationState::ApplyMailbox, "ApplyMailbox"),
583            (OperationState::CherryPick, "CherryPick"),
584            (OperationState::Revert, "Revert"),
585            (OperationState::Bisect, "Bisect"),
586            (OperationState::Conflict, "Conflict"),
587        ] {
588            assert_eq!(serde_json::to_value(state).unwrap(), name);
589        }
590    }
591
592    // `MergeProbe` is adjacently tagged: BOTH outcomes are objects with an
593    // `outcome` discriminant — a stable shape a tool consumer can branch on,
594    // never a bare string for one case and an object for the other.
595    #[test]
596    fn merge_probe_serializes_to_a_type_stable_object() {
597        let clean = serde_json::to_value(MergeProbe::Clean).unwrap();
598        assert_eq!(clean["outcome"], "Clean");
599        assert!(clean.get("files").is_none(), "{clean}");
600
601        let conflicts =
602            serde_json::to_value(MergeProbe::Conflicts(vec!["a.rs".into(), "b.rs".into()]))
603                .unwrap();
604        assert_eq!(conflicts["outcome"], "Conflicts");
605        assert_eq!(conflicts["files"][0], "a.rs");
606        assert_eq!(conflicts["files"][1], "b.rs");
607    }
608
609    #[test]
610    fn commit_serializes_with_null_author_date_on_the_jj_shape() {
611        let jj_shaped = Commit::new("abc123", "first line");
612        let v = serde_json::to_value(&jj_shaped).unwrap();
613        assert_eq!(v["id"], "abc123");
614        assert_eq!(v["description"], "first line");
615        assert!(v["author"].is_null());
616        assert!(v["date"].is_null());
617    }
618}
619
620#[cfg(test)]
621mod ctor_tests {
622    use super::*;
623
624    // A4: the public builder constructors let an external `VcsRepo` impl / test
625    // build the `#[non_exhaustive]` return DTOs, and land the fields where expected.
626    #[test]
627    fn dto_constructors_populate_fields() {
628        let jj_shaped = Commit::new("abc123", "first line");
629        assert_eq!(jj_shaped.id, "abc123");
630        assert_eq!(jj_shaped.description, "first line");
631        assert_eq!(jj_shaped.author, None);
632        assert_eq!(jj_shaped.date, None);
633
634        let git_shaped = Commit::new("deadbeef", "subject")
635            .author("Jane")
636            .date("2026-05-31");
637        assert_eq!(git_shaped.author.as_deref(), Some("Jane"));
638        assert_eq!(git_shaped.date.as_deref(), Some("2026-05-31"));
639
640        let fc = FileChange::new("new.rs", ChangeKind::Modified).old_path("old.rs");
641        assert_eq!(fc.path, PathBuf::from("new.rs"));
642        assert_eq!(fc.old_path.as_deref(), Some(std::path::Path::new("old.rs")));
643        assert_eq!(fc.kind, ChangeKind::Modified);
644
645        let wt = WorktreeInfo::new("/wt")
646            .branch("feature")
647            .commit("abc123")
648            .bare();
649        assert_eq!(wt.path, PathBuf::from("/wt"));
650        assert_eq!(wt.branch.as_deref(), Some("feature"));
651        assert_eq!(wt.commit.as_deref(), Some("abc123"));
652        assert!(wt.is_bare);
653
654        let up = UpstreamTracking::new("origin/main").ahead(2).behind(3);
655        assert_eq!(up.branch, "origin/main");
656        assert_eq!(up.ahead, Some(2));
657        assert_eq!(up.behind, Some(3));
658        // Uncounted by default.
659        assert_eq!(UpstreamTracking::new("origin/x").ahead, None);
660
661        let snap = RepoSnapshot::new()
662            .head("deadbeef")
663            .branch("main")
664            .tracking(up)
665            .dirty(4)
666            .conflicted()
667            .operation(OperationState::Merge);
668        assert_eq!(snap.head.as_deref(), Some("deadbeef"));
669        assert_eq!(snap.branch.as_deref(), Some("main"));
670        assert_eq!(snap.tracking.as_ref().unwrap().branch, "origin/main");
671        assert_eq!(snap.tracking.as_ref().unwrap().ahead, Some(2));
672        assert!(snap.dirty);
673        assert_eq!(snap.change_count, 4);
674        assert!(snap.conflicted);
675        assert_eq!(snap.operation, OperationState::Merge);
676
677        // A default snapshot is clean.
678        let clean = RepoSnapshot::default();
679        assert!(!clean.dirty && !clean.conflicted && clean.head.is_none());
680        assert_eq!(clean.operation, OperationState::Clear);
681        assert_eq!(clean.change_count, 0);
682    }
683}