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 file's full parsed diff (hunks and lines) — the shared
197/// [`vcs_diff::FileDiff`], returned by [`Repo::diff`](crate::Repo::diff) directly
198/// (no remapping); the same type `GitApi::diff`/`JjApi::diff` already return.
199pub use vcs_diff::FileDiff;
200
201/// One attached worktree (git) / workspace (jj).
202#[derive(Debug, Clone, PartialEq, Eq)]
203#[cfg_attr(feature = "serde", derive(serde::Serialize))]
204#[non_exhaustive]
205pub struct WorktreeInfo {
206    /// Filesystem path of the worktree's working copy.
207    pub path: PathBuf,
208    /// The branch (git) or first bookmark (jj) on it; `None` when detached/none.
209    pub branch: Option<String>,
210    /// The checked-out commit's **full** object id (git `HEAD` oid / jj `@` commit
211    /// id) on both backends — the same identity a [`RepoSnapshot::head`] carries, so
212    /// the two can be compared directly to tell whether a worktree sits on the
213    /// snapshotted commit. Not a display-truncated prefix (which could collide);
214    /// truncate for display. `None` when unavailable (e.g. a bare git entry).
215    pub commit: Option<String>,
216    /// A bare git worktree entry (always `false` for jj).
217    pub is_bare: bool,
218}
219
220impl WorktreeInfo {
221    /// A worktree at `path` with no branch/commit and not bare; chain the setters.
222    pub fn new(path: impl Into<PathBuf>) -> Self {
223        Self {
224            path: path.into(),
225            branch: None,
226            commit: None,
227            is_bare: false,
228        }
229    }
230
231    /// Set the branch (git) / first bookmark (jj) on the worktree.
232    pub fn branch(mut self, branch: impl Into<String>) -> Self {
233        self.branch = Some(branch.into());
234        self
235    }
236
237    /// Set the checked-out commit.
238    pub fn commit(mut self, commit: impl Into<String>) -> Self {
239        self.commit = Some(commit.into());
240        self
241    }
242
243    /// Mark it a bare git worktree entry.
244    pub fn bare(mut self) -> Self {
245        self.is_bare = true;
246        self
247    }
248}
249
250/// Whether the working copy is mid-operation, unified across the backends'
251/// different models: git exposes an in-progress merge, rebase, `am`, cherry-pick,
252/// revert, or bisect as on-disk state (`MERGE_HEAD` / a `rebase-*` dir /
253/// `CHERRY_PICK_HEAD` / `REVERT_HEAD` / `BISECT_LOG`), while jj has no multi-step
254/// operations — it records a conflict directly on the working-copy change.
255///
256/// The sequencer states are kept **distinct** because each aborts (and, where it
257/// makes sense, continues) with its *own* git command — dispatching the wrong one
258/// on a user's real repository is exactly what this type exists to prevent. See
259/// [`Repo::abort_in_progress`](crate::Repo::abort_in_progress) /
260/// [`continue_in_progress`](crate::Repo::continue_in_progress).
261#[derive(Debug, Clone, Copy, PartialEq, Eq)]
262#[cfg_attr(feature = "serde", derive(serde::Serialize))]
263#[non_exhaustive]
264pub enum OperationState {
265    /// No operation in progress and no conflict.
266    Clear,
267    /// A git merge is in progress (`MERGE_HEAD` present).
268    Merge,
269    /// A git rebase is in progress (a `rebase-merge` dir, or a `rebase-apply` dir
270    /// **not** left by `git am` — see [`ApplyMailbox`](OperationState::ApplyMailbox)).
271    Rebase,
272    /// A git `am` (mailbox patch apply) is in progress. Distinct from `Rebase`
273    /// because it aborts/continues with `am --abort` / `am --continue`, not the
274    /// `rebase --*` twins (M20). Like the other sequencer states it has a real
275    /// `--continue`, so [`continue_in_progress`](crate::Repo::continue_in_progress)
276    /// drives it forward (reporting `Conflict` if the next patch stops) rather than
277    /// treating it as nothing to do.
278    ApplyMailbox,
279    /// A git cherry-pick is in progress (`CHERRY_PICK_HEAD` present). Distinct
280    /// from `Merge`: it aborts/continues with `cherry-pick --abort` /
281    /// `cherry-pick --continue` (a cherry-pick conflict writes `CHERRY_PICK_HEAD`,
282    /// **not** `MERGE_HEAD`). git only.
283    CherryPick,
284    /// A git revert is in progress (`REVERT_HEAD` present). Aborts/continues with
285    /// `revert --abort` / `revert --continue`. git only.
286    Revert,
287    /// A git bisect session is in progress (`BISECT_LOG` present). Aborts with
288    /// `bisect reset`; it has **no** `--continue` step (bisect advances by marking
289    /// commits good/bad), so
290    /// [`continue_in_progress`](crate::Repo::continue_in_progress) reports it as
291    /// unsupported rather than silently doing nothing. git only.
292    Bisect,
293    /// The working copy has an unresolved conflict (chiefly jj, which records
294    /// conflicts on the change rather than pausing an operation).
295    Conflict,
296}
297
298/// Upstream tracking for the current branch: the upstream ref and how far the
299/// branch is ahead/behind it. [`RepoSnapshot`] carries it as one
300/// `Option<UpstreamTracking>` — `None` when no upstream is configured at all.
301///
302/// The ahead/behind counts are themselves `Option`: git reports them only when the
303/// upstream ref actually **resolves**, so a branch whose upstream is *set but gone*
304/// (deleted on the remote, or not yet fetched) yields `Some(UpstreamTracking { branch,
305/// ahead: None, behind: None })` — "tracking configured but uncountable", distinct
306/// from the in-sync `Some(0)`/`Some(0)` that a `unwrap_or(0)` used to fabricate (M17).
307#[derive(Debug, Clone, PartialEq, Eq)]
308#[cfg_attr(feature = "serde", derive(serde::Serialize))]
309#[non_exhaustive]
310pub struct UpstreamTracking {
311    /// The upstream tracking branch, e.g. `"origin/main"`.
312    pub branch: String,
313    /// Commits the local branch is ahead of the upstream; `None` when the upstream is
314    /// set but git couldn't count against it (gone remote / not fetched).
315    pub ahead: Option<usize>,
316    /// Commits the local branch is behind the upstream; `None` when uncountable (see
317    /// [`ahead`](UpstreamTracking::ahead)).
318    pub behind: Option<usize>,
319}
320
321impl UpstreamTracking {
322    /// Tracking `branch` (e.g. `"origin/main"`) with **uncounted** ahead/behind
323    /// (both `None`); chain [`ahead`](UpstreamTracking::ahead) /
324    /// [`behind`](UpstreamTracking::behind) to set the counts.
325    pub fn new(branch: impl Into<String>) -> Self {
326        Self {
327            branch: branch.into(),
328            ahead: None,
329            behind: None,
330        }
331    }
332
333    /// Set the ahead count.
334    pub fn ahead(mut self, n: usize) -> Self {
335        self.ahead = Some(n);
336        self
337    }
338
339    /// Set the behind count.
340    pub fn behind(mut self, n: usize) -> Self {
341        self.behind = Some(n);
342        self
343    }
344}
345
346/// A one-shot snapshot of the common repository state — branch, upstream
347/// tracking, ahead/behind, dirtiness, and operation state — gathered in a
348/// **small fixed** number of process spawns instead of a call per field. The
349/// data a prompt, status line, or TUI refresh needs. See
350/// [`Repo::snapshot`](crate::Repo::snapshot).
351#[derive(Debug, Clone, PartialEq, Eq)]
352#[cfg_attr(feature = "serde", derive(serde::Serialize))]
353#[non_exhaustive]
354pub struct RepoSnapshot {
355    /// The working-copy commit's **full** object id (git `HEAD` oid / jj `@`
356    /// commit id) on both backends; `None` on an unborn git repo. Truncate for
357    /// display. Carries the full id (not a short prefix) so it can be
358    /// cross-referenced against a [`WorktreeInfo::commit`] or a git oid without a
359    /// short-prefix collision.
360    pub head: Option<String>,
361    /// Current branch (git) / bookmark (jj). On jj this is the nearest bookmark
362    /// reachable from `@` (`heads(::@ & bookmarks())`), so it stays set across a
363    /// `jj describe`/`jj new`/`jj commit`; `None` when detached / no bookmark on
364    /// or above `@`. Matches [`Repo::current_branch`](crate::Repo::current_branch)
365    /// by construction.
366    pub branch: Option<String>,
367    /// Upstream tracking and how far the branch is ahead/behind it, as one unit —
368    /// `Some` only when an upstream is configured, `None` otherwise (and **always
369    /// `None` on jj**, which has no git-style upstream tracking). Bundling the
370    /// three together makes the "all-or-nothing" relationship unrepresentable as a
371    /// half-populated state. See [`UpstreamTracking`].
372    pub tracking: Option<UpstreamTracking>,
373    /// Whether the working copy has any uncommitted change (tracked or untracked).
374    pub dirty: bool,
375    /// Number of changed paths (tracked + untracked on git; the `@` change's
376    /// files on jj).
377    pub change_count: usize,
378    /// Whether the working copy has an unresolved conflict.
379    pub conflicted: bool,
380    /// In-progress operation / conflict state (see [`OperationState`]).
381    pub operation: OperationState,
382}
383
384impl RepoSnapshot {
385    /// A clean snapshot: detached (no `head`/`branch`), no upstream tracking, not
386    /// dirty or conflicted, change count 0, [`OperationState::Clear`]. Chain the
387    /// setters to fill it — for a test double or a custom `VcsRepo` backend that must
388    /// return a `RepoSnapshot` (the struct is `#[non_exhaustive]`, so it can't be
389    /// built with a literal outside this crate).
390    pub fn new() -> Self {
391        Self {
392            head: None,
393            branch: None,
394            tracking: None,
395            dirty: false,
396            change_count: 0,
397            conflicted: false,
398            operation: OperationState::Clear,
399        }
400    }
401
402    /// Set the working-copy commit's object id.
403    pub fn head(mut self, head: impl Into<String>) -> Self {
404        self.head = Some(head.into());
405        self
406    }
407
408    /// Set the current branch (git) / bookmark (jj).
409    pub fn branch(mut self, branch: impl Into<String>) -> Self {
410        self.branch = Some(branch.into());
411        self
412    }
413
414    /// Set the upstream tracking (see [`UpstreamTracking`]).
415    pub fn tracking(mut self, tracking: UpstreamTracking) -> Self {
416        self.tracking = Some(tracking);
417        self
418    }
419
420    /// Mark the working copy dirty and record how many paths changed (a real snapshot
421    /// has `change_count >= 1` when dirty — the two fields move together, so this
422    /// setter couples them). A clean copy is the [`new`](RepoSnapshot::new) default.
423    pub fn dirty(mut self, change_count: usize) -> Self {
424        self.dirty = true;
425        self.change_count = change_count;
426        self
427    }
428
429    /// Mark the working copy as having an unresolved conflict.
430    pub fn conflicted(mut self) -> Self {
431        self.conflicted = true;
432        self
433    }
434
435    /// Set the in-progress operation / conflict state.
436    pub fn operation(mut self, operation: OperationState) -> Self {
437        self.operation = operation;
438        self
439    }
440}
441
442impl Default for RepoSnapshot {
443    fn default() -> Self {
444        Self::new()
445    }
446}
447
448/// The outcome of a [`try_merge`](crate::Repo::try_merge) probe. The probe
449/// itself is rolled back before it returns, whatever the outcome — this only
450/// *reports* what a real merge would do.
451#[derive(Debug, Clone, PartialEq, Eq)]
452#[cfg_attr(feature = "serde", derive(serde::Serialize))]
453// Adjacently tagged so the JSON is a *type-stable object* for both outcomes —
454// `{"outcome":"Clean"}` and `{"outcome":"Conflicts","files":[…]}` — rather than
455// serde's default externally-tagged shape, which would emit a bare string
456// `"Clean"` for one variant and an object for the other (a polymorphic result an
457// agent consumer can't branch on uniformly).
458#[cfg_attr(feature = "serde", serde(tag = "outcome", content = "files"))]
459#[non_exhaustive]
460pub enum MergeProbe {
461    /// The merge would apply without conflicts.
462    Clean,
463    /// The merge would conflict in these paths (repo-relative, `/` separators —
464    /// the same contract and [`PathBuf`] type as
465    /// [`conflicted_files`](crate::Repo::conflicted_files), so a non-UTF-8 path is
466    /// carried losslessly).
467    Conflicts(Vec<PathBuf>),
468}
469
470impl MergeProbe {
471    /// Whether the probe found no conflicts.
472    pub fn is_clean(&self) -> bool {
473        matches!(self, MergeProbe::Clean)
474    }
475}
476
477/// One commit/change from the repository history — the honest least common
478/// denominator between git's typed `git log` (`vcs_git::parse::Commit`, which
479/// carries hash/short-hash/author/date/subject) and jj's typed `jj log`
480/// (`vcs_jj::parse::Change`, which carries change-id/commit-id/empty/description).
481/// See [`Repo::log`](crate::Repo::log).
482///
483/// `author`/`date` are `Some` only on git: jj's typed log doesn't currently
484/// surface authorship or a timestamp (its template renders only the id/empty/
485/// description columns), so this DTO leaves them `None` on jj rather than
486/// fabricating a value.
487#[derive(Debug, Clone, PartialEq, Eq)]
488#[cfg_attr(feature = "serde", derive(serde::Serialize))]
489#[non_exhaustive]
490pub struct Commit {
491    /// The commit's identifying hash: git's full object id (`%H`) / jj's
492    /// (already-short) commit id.
493    pub id: String,
494    /// Commit message: git's subject line (`%s`) / jj's first description line.
495    pub description: String,
496    /// Author name (git `%an`); `None` on jj (see the type docs).
497    pub author: Option<String>,
498    /// Author date, strict ISO-8601 on git (`%aI`); `None` on jj (see the type
499    /// docs).
500    pub date: Option<String>,
501}
502
503impl Commit {
504    /// A commit `id` with `description`, no author/date (jj's typed-log shape);
505    /// chain [`author`](Commit::author) / [`date`](Commit::date) to add them
506    /// (git's shape). Lets an external `VcsRepo` impl or a test build one despite
507    /// the `#[non_exhaustive]`.
508    pub fn new(id: impl Into<String>, description: impl Into<String>) -> Self {
509        Self {
510            id: id.into(),
511            description: description.into(),
512            author: None,
513            date: None,
514        }
515    }
516
517    /// Set the author name.
518    pub fn author(mut self, author: impl Into<String>) -> Self {
519        self.author = Some(author.into());
520        self
521    }
522
523    /// Set the author date.
524    pub fn date(mut self, date: impl Into<String>) -> Self {
525        self.date = Some(date.into());
526        self
527    }
528}
529
530/// One line of file attribution — the honest least common denominator between
531/// git's `blame` and jj's `file annotate`. See [`Repo::annotate`](crate::Repo::annotate).
532///
533/// `author`/`date` are `Some` only on git: jj's typed annotation reports the
534/// change that introduced a line, but not an author or timestamp. This DTO leaves
535/// both fields `None` on jj rather than fabricating provenance. `date`, when
536/// present, is git's author timestamp as Unix seconds.
537#[derive(Debug, Clone, PartialEq, Eq)]
538#[cfg_attr(feature = "serde", derive(serde::Serialize))]
539#[non_exhaustive]
540pub struct AnnotationLine {
541    /// Revision that last changed the line: git's full commit object id / jj's
542    /// short change id.
543    pub id: String,
544    /// Line number in the annotated file (1-based).
545    pub line: u32,
546    /// The line's content, without its row-separating newline.
547    pub content: String,
548    /// Author name on git; `None` on jj (see the type docs).
549    pub author: Option<String>,
550    /// Author timestamp as Unix seconds on git; `None` on jj (see the type docs).
551    pub date: Option<i64>,
552}
553
554impl AnnotationLine {
555    /// An attributed `id`/`line`/`content` with no author/date (jj's typed
556    /// annotation shape); chain [`author`](AnnotationLine::author) and
557    /// [`date`](AnnotationLine::date) for git's shape. Lets external `VcsRepo`
558    /// implementations and tests construct this `#[non_exhaustive]` DTO.
559    pub fn new(id: impl Into<String>, line: u32, content: impl Into<String>) -> Self {
560        Self {
561            id: id.into(),
562            line,
563            content: content.into(),
564            author: None,
565            date: None,
566        }
567    }
568
569    /// Set the author name.
570    pub fn author(mut self, author: impl Into<String>) -> Self {
571        self.author = Some(author.into());
572        self
573    }
574
575    /// Set the author timestamp in Unix seconds.
576    pub fn date(mut self, date: i64) -> Self {
577        self.date = Some(date);
578        self
579    }
580}
581/// How a worktree was materialised. The facade always reports
582/// [`Plain`](CreateOutcome::Plain); the [`CowCloned`](CreateOutcome::CowCloned)
583/// variant exists so a consumer that layers a copy-on-write strategy on top can
584/// reuse this type.
585#[derive(Debug, Clone, Copy, PartialEq, Eq)]
586#[cfg_attr(feature = "serde", derive(serde::Serialize))]
587#[non_exhaustive]
588pub enum CreateOutcome {
589    /// The tool materialised the working copy itself.
590    Plain,
591    /// A copy-on-write clone populated the working copy (consumer-supplied).
592    CowCloned,
593}
594
595// The optional `serde` feature derives `Serialize` on the facade DTOs.
596#[cfg(all(test, feature = "serde"))]
597mod serde_tests {
598    use super::*;
599
600    #[test]
601    fn snapshot_and_file_change_serialize_to_clean_json() {
602        let snap = RepoSnapshot {
603            head: Some("abc".into()),
604            branch: Some("main".into()),
605            tracking: Some(UpstreamTracking {
606                branch: "origin/main".into(),
607                ahead: Some(1),
608                behind: Some(0),
609            }),
610            dirty: true,
611            change_count: 2,
612            conflicted: false,
613            operation: OperationState::Merge,
614        };
615        let v = serde_json::to_value(&snap).unwrap();
616        assert_eq!(v["branch"], "main");
617        assert_eq!(v["operation"], "Merge"); // enum → variant name
618        assert_eq!(v["change_count"], 2);
619        // Tracking serialises as one nested object (or null), not three fields.
620        assert_eq!(v["tracking"]["branch"], "origin/main");
621        assert_eq!(v["tracking"]["ahead"], 1);
622
623        let fc = FileChange {
624            path: "a.rs".into(),
625            old_path: None,
626            kind: ChangeKind::Added, // re-exported vcs_diff type, Serialize via vcs-diff/serde
627        };
628        let v = serde_json::to_value(fc).unwrap();
629        // A `PathBuf` field serialises as a plain JSON string for a UTF-8 path.
630        assert_eq!(v["path"], "a.rs");
631        assert_eq!(v["kind"], "Added");
632    }
633
634    // Every `OperationState` variant, including the sequencer additions, serialises
635    // to its bare variant name (the JSON a `snapshot`/MCP consumer branches on).
636    #[test]
637    fn operation_state_variants_serialize_to_their_names() {
638        for (state, name) in [
639            (OperationState::Clear, "Clear"),
640            (OperationState::Merge, "Merge"),
641            (OperationState::Rebase, "Rebase"),
642            (OperationState::ApplyMailbox, "ApplyMailbox"),
643            (OperationState::CherryPick, "CherryPick"),
644            (OperationState::Revert, "Revert"),
645            (OperationState::Bisect, "Bisect"),
646            (OperationState::Conflict, "Conflict"),
647        ] {
648            assert_eq!(serde_json::to_value(state).unwrap(), name);
649        }
650    }
651
652    // `MergeProbe` is adjacently tagged: BOTH outcomes are objects with an
653    // `outcome` discriminant — a stable shape a tool consumer can branch on,
654    // never a bare string for one case and an object for the other.
655    #[test]
656    fn merge_probe_serializes_to_a_type_stable_object() {
657        let clean = serde_json::to_value(MergeProbe::Clean).unwrap();
658        assert_eq!(clean["outcome"], "Clean");
659        assert!(clean.get("files").is_none(), "{clean}");
660
661        let conflicts =
662            serde_json::to_value(MergeProbe::Conflicts(vec!["a.rs".into(), "b.rs".into()]))
663                .unwrap();
664        assert_eq!(conflicts["outcome"], "Conflicts");
665        assert_eq!(conflicts["files"][0], "a.rs");
666        assert_eq!(conflicts["files"][1], "b.rs");
667    }
668
669    #[test]
670    fn commit_serializes_with_null_author_date_on_the_jj_shape() {
671        let jj_shaped = Commit::new("abc123", "first line");
672        let v = serde_json::to_value(&jj_shaped).unwrap();
673        assert_eq!(v["id"], "abc123");
674        assert_eq!(v["description"], "first line");
675        assert!(v["author"].is_null());
676        assert!(v["date"].is_null());
677    }
678}
679
680#[cfg(test)]
681mod ctor_tests {
682    use super::*;
683
684    // A4: the public builder constructors let an external `VcsRepo` impl / test
685    // build the `#[non_exhaustive]` return DTOs, and land the fields where expected.
686    #[test]
687    fn dto_constructors_populate_fields() {
688        let jj_shaped = Commit::new("abc123", "first line");
689        assert_eq!(jj_shaped.id, "abc123");
690        assert_eq!(jj_shaped.description, "first line");
691        assert_eq!(jj_shaped.author, None);
692        assert_eq!(jj_shaped.date, None);
693
694        let git_shaped = Commit::new("deadbeef", "subject")
695            .author("Jane")
696            .date("2026-05-31");
697        assert_eq!(git_shaped.author.as_deref(), Some("Jane"));
698        assert_eq!(git_shaped.date.as_deref(), Some("2026-05-31"));
699
700        let fc = FileChange::new("new.rs", ChangeKind::Modified).old_path("old.rs");
701        assert_eq!(fc.path, PathBuf::from("new.rs"));
702        assert_eq!(fc.old_path.as_deref(), Some(std::path::Path::new("old.rs")));
703        assert_eq!(fc.kind, ChangeKind::Modified);
704
705        let wt = WorktreeInfo::new("/wt")
706            .branch("feature")
707            .commit("abc123")
708            .bare();
709        assert_eq!(wt.path, PathBuf::from("/wt"));
710        assert_eq!(wt.branch.as_deref(), Some("feature"));
711        assert_eq!(wt.commit.as_deref(), Some("abc123"));
712        assert!(wt.is_bare);
713
714        let up = UpstreamTracking::new("origin/main").ahead(2).behind(3);
715        assert_eq!(up.branch, "origin/main");
716        assert_eq!(up.ahead, Some(2));
717        assert_eq!(up.behind, Some(3));
718        // Uncounted by default.
719        assert_eq!(UpstreamTracking::new("origin/x").ahead, None);
720
721        let snap = RepoSnapshot::new()
722            .head("deadbeef")
723            .branch("main")
724            .tracking(up)
725            .dirty(4)
726            .conflicted()
727            .operation(OperationState::Merge);
728        assert_eq!(snap.head.as_deref(), Some("deadbeef"));
729        assert_eq!(snap.branch.as_deref(), Some("main"));
730        assert_eq!(snap.tracking.as_ref().unwrap().branch, "origin/main");
731        assert_eq!(snap.tracking.as_ref().unwrap().ahead, Some(2));
732        assert!(snap.dirty);
733        assert_eq!(snap.change_count, 4);
734        assert!(snap.conflicted);
735        assert_eq!(snap.operation, OperationState::Merge);
736
737        // A default snapshot is clean.
738        let clean = RepoSnapshot::default();
739        assert!(!clean.dirty && !clean.conflicted && clean.head.is_none());
740        assert_eq!(clean.operation, OperationState::Clear);
741        assert_eq!(clean.change_count, 0);
742    }
743}