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}