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}