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    pub path: String,
157    /// The original path for a rename, populated by **both** backends (git's
158    /// `R old -> new` status; jj's `{old => new}` diff-summary form); `None`
159    /// for non-renames.
160    pub old_path: Option<String>,
161    /// How the file changed.
162    pub kind: ChangeKind,
163}
164
165impl FileChange {
166    /// A change to `path` of the given `kind`, with no original path. Chain the
167    /// `old_path` setter for a rename or copy. Lets an external `VcsRepo` impl or a
168    /// test build one despite the `#[non_exhaustive]`.
169    pub fn new(path: impl Into<String>, kind: ChangeKind) -> Self {
170        Self {
171            path: path.into(),
172            old_path: None,
173            kind,
174        }
175    }
176
177    /// Record the original path — a rename's or copy's source (sets the `old_path`
178    /// field, which both a rename and a copy populate).
179    pub fn old_path(mut self, old: impl Into<String>) -> Self {
180        self.old_path = Some(old.into());
181        self
182    }
183}
184
185/// Aggregate insertion/deletion counts for the working copy — the shared
186/// [`vcs_diff::DiffStat`], returned by the backends directly (no remapping).
187pub use vcs_diff::DiffStat;
188
189/// One attached worktree (git) / workspace (jj).
190#[derive(Debug, Clone, PartialEq, Eq)]
191#[cfg_attr(feature = "serde", derive(serde::Serialize))]
192#[non_exhaustive]
193pub struct WorktreeInfo {
194    /// Filesystem path of the worktree's working copy.
195    pub path: PathBuf,
196    /// The branch (git) or first bookmark (jj) on it; `None` when detached/none.
197    pub branch: Option<String>,
198    /// The checked-out commit; `None` when unavailable (e.g. a bare git entry).
199    pub commit: Option<String>,
200    /// A bare git worktree entry (always `false` for jj).
201    pub is_bare: bool,
202}
203
204impl WorktreeInfo {
205    /// A worktree at `path` with no branch/commit and not bare; chain the setters.
206    pub fn new(path: impl Into<PathBuf>) -> Self {
207        Self {
208            path: path.into(),
209            branch: None,
210            commit: None,
211            is_bare: false,
212        }
213    }
214
215    /// Set the branch (git) / first bookmark (jj) on the worktree.
216    pub fn branch(mut self, branch: impl Into<String>) -> Self {
217        self.branch = Some(branch.into());
218        self
219    }
220
221    /// Set the checked-out commit.
222    pub fn commit(mut self, commit: impl Into<String>) -> Self {
223        self.commit = Some(commit.into());
224        self
225    }
226
227    /// Mark it a bare git worktree entry.
228    pub fn bare(mut self) -> Self {
229        self.is_bare = true;
230        self
231    }
232}
233
234/// Whether the working copy is mid-operation, unified across the backends'
235/// different models: git exposes an in-progress merge or rebase as on-disk state
236/// (`MERGE_HEAD` / a `rebase-*` dir), while jj has no multi-step operations — it
237/// records a conflict directly on the working-copy change.
238#[derive(Debug, Clone, Copy, PartialEq, Eq)]
239#[cfg_attr(feature = "serde", derive(serde::Serialize))]
240#[non_exhaustive]
241pub enum OperationState {
242    /// No operation in progress and no conflict.
243    Clear,
244    /// A git merge is in progress (`MERGE_HEAD` present).
245    Merge,
246    /// A git rebase is in progress (a `rebase-merge` dir, or a `rebase-apply` dir
247    /// **not** left by `git am` — see [`ApplyMailbox`](OperationState::ApplyMailbox)).
248    Rebase,
249    /// A git `am` (mailbox patch apply) is in progress. Distinct from `Rebase`
250    /// because it aborts with `am --abort`, not `rebase --abort` (M20).
251    ApplyMailbox,
252    /// The working copy has an unresolved conflict (chiefly jj, which records
253    /// conflicts on the change rather than pausing an operation).
254    Conflict,
255}
256
257/// Upstream tracking for the current branch: the upstream ref and how far the
258/// branch is ahead/behind it. [`RepoSnapshot`] carries it as one
259/// `Option<UpstreamTracking>` — `None` when no upstream is configured at all.
260///
261/// The ahead/behind counts are themselves `Option`: git reports them only when the
262/// upstream ref actually **resolves**, so a branch whose upstream is *set but gone*
263/// (deleted on the remote, or not yet fetched) yields `Some(UpstreamTracking { branch,
264/// ahead: None, behind: None })` — "tracking configured but uncountable", distinct
265/// from the in-sync `Some(0)`/`Some(0)` that a `unwrap_or(0)` used to fabricate (M17).
266#[derive(Debug, Clone, PartialEq, Eq)]
267#[cfg_attr(feature = "serde", derive(serde::Serialize))]
268#[non_exhaustive]
269pub struct UpstreamTracking {
270    /// The upstream tracking branch, e.g. `"origin/main"`.
271    pub branch: String,
272    /// Commits the local branch is ahead of the upstream; `None` when the upstream is
273    /// set but git couldn't count against it (gone remote / not fetched).
274    pub ahead: Option<usize>,
275    /// Commits the local branch is behind the upstream; `None` when uncountable (see
276    /// [`ahead`](UpstreamTracking::ahead)).
277    pub behind: Option<usize>,
278}
279
280impl UpstreamTracking {
281    /// Tracking `branch` (e.g. `"origin/main"`) with **uncounted** ahead/behind
282    /// (both `None`); chain [`ahead`](UpstreamTracking::ahead) /
283    /// [`behind`](UpstreamTracking::behind) to set the counts.
284    pub fn new(branch: impl Into<String>) -> Self {
285        Self {
286            branch: branch.into(),
287            ahead: None,
288            behind: None,
289        }
290    }
291
292    /// Set the ahead count.
293    pub fn ahead(mut self, n: usize) -> Self {
294        self.ahead = Some(n);
295        self
296    }
297
298    /// Set the behind count.
299    pub fn behind(mut self, n: usize) -> Self {
300        self.behind = Some(n);
301        self
302    }
303}
304
305/// A one-shot snapshot of the common repository state — branch, upstream
306/// tracking, ahead/behind, dirtiness, and operation state — gathered in a
307/// **small fixed** number of process spawns instead of a call per field. The
308/// data a prompt, status line, or TUI refresh needs. See
309/// [`Repo::snapshot`](crate::Repo::snapshot).
310#[derive(Debug, Clone, PartialEq, Eq)]
311#[cfg_attr(feature = "serde", derive(serde::Serialize))]
312#[non_exhaustive]
313pub struct RepoSnapshot {
314    /// The working-copy commit's **full** object id (git `HEAD` oid / jj `@`
315    /// commit id) on both backends; `None` on an unborn git repo. Truncate for
316    /// display.
317    pub head: Option<String>,
318    /// Current branch (git) / bookmark (jj). On jj this is the nearest bookmark
319    /// reachable from `@` (`heads(::@ & bookmarks())`), so it stays set across a
320    /// `jj describe`/`jj new`/`jj commit`; `None` when detached / no bookmark on
321    /// or above `@`. Matches [`Repo::current_branch`](crate::Repo::current_branch)
322    /// by construction.
323    pub branch: Option<String>,
324    /// Upstream tracking and how far the branch is ahead/behind it, as one unit —
325    /// `Some` only when an upstream is configured, `None` otherwise (and **always
326    /// `None` on jj**, which has no git-style upstream tracking). Bundling the
327    /// three together makes the "all-or-nothing" relationship unrepresentable as a
328    /// half-populated state. See [`UpstreamTracking`].
329    pub tracking: Option<UpstreamTracking>,
330    /// Whether the working copy has any uncommitted change (tracked or untracked).
331    pub dirty: bool,
332    /// Number of changed paths (tracked + untracked on git; the `@` change's
333    /// files on jj).
334    pub change_count: usize,
335    /// Whether the working copy has an unresolved conflict.
336    pub conflicted: bool,
337    /// In-progress operation / conflict state (see [`OperationState`]).
338    pub operation: OperationState,
339}
340
341impl RepoSnapshot {
342    /// A clean snapshot: detached (no `head`/`branch`), no upstream tracking, not
343    /// dirty or conflicted, change count 0, [`OperationState::Clear`]. Chain the
344    /// setters to fill it — for a test double or a custom `VcsRepo` backend that must
345    /// return a `RepoSnapshot` (the struct is `#[non_exhaustive]`, so it can't be
346    /// built with a literal outside this crate).
347    pub fn new() -> Self {
348        Self {
349            head: None,
350            branch: None,
351            tracking: None,
352            dirty: false,
353            change_count: 0,
354            conflicted: false,
355            operation: OperationState::Clear,
356        }
357    }
358
359    /// Set the working-copy commit's object id.
360    pub fn head(mut self, head: impl Into<String>) -> Self {
361        self.head = Some(head.into());
362        self
363    }
364
365    /// Set the current branch (git) / bookmark (jj).
366    pub fn branch(mut self, branch: impl Into<String>) -> Self {
367        self.branch = Some(branch.into());
368        self
369    }
370
371    /// Set the upstream tracking (see [`UpstreamTracking`]).
372    pub fn tracking(mut self, tracking: UpstreamTracking) -> Self {
373        self.tracking = Some(tracking);
374        self
375    }
376
377    /// Mark the working copy dirty and record how many paths changed (a real snapshot
378    /// has `change_count >= 1` when dirty — the two fields move together, so this
379    /// setter couples them). A clean copy is the [`new`](RepoSnapshot::new) default.
380    pub fn dirty(mut self, change_count: usize) -> Self {
381        self.dirty = true;
382        self.change_count = change_count;
383        self
384    }
385
386    /// Mark the working copy as having an unresolved conflict.
387    pub fn conflicted(mut self) -> Self {
388        self.conflicted = true;
389        self
390    }
391
392    /// Set the in-progress operation / conflict state.
393    pub fn operation(mut self, operation: OperationState) -> Self {
394        self.operation = operation;
395        self
396    }
397}
398
399impl Default for RepoSnapshot {
400    fn default() -> Self {
401        Self::new()
402    }
403}
404
405/// The outcome of a [`try_merge`](crate::Repo::try_merge) probe. The probe
406/// itself is rolled back before it returns, whatever the outcome — this only
407/// *reports* what a real merge would do.
408#[derive(Debug, Clone, PartialEq, Eq)]
409#[cfg_attr(feature = "serde", derive(serde::Serialize))]
410// Adjacently tagged so the JSON is a *type-stable object* for both outcomes —
411// `{"outcome":"Clean"}` and `{"outcome":"Conflicts","files":[…]}` — rather than
412// serde's default externally-tagged shape, which would emit a bare string
413// `"Clean"` for one variant and an object for the other (a polymorphic result an
414// agent consumer can't branch on uniformly).
415#[cfg_attr(feature = "serde", serde(tag = "outcome", content = "files"))]
416#[non_exhaustive]
417pub enum MergeProbe {
418    /// The merge would apply without conflicts.
419    Clean,
420    /// The merge would conflict in these paths (repo-relative, `/` separators —
421    /// the same contract as [`conflicted_files`](crate::Repo::conflicted_files)).
422    Conflicts(Vec<String>),
423}
424
425impl MergeProbe {
426    /// Whether the probe found no conflicts.
427    pub fn is_clean(&self) -> bool {
428        matches!(self, MergeProbe::Clean)
429    }
430}
431
432/// How a worktree was materialised. The facade always reports
433/// [`Plain`](CreateOutcome::Plain); the [`CowCloned`](CreateOutcome::CowCloned)
434/// variant exists so a consumer that layers a copy-on-write strategy on top can
435/// reuse this type.
436#[derive(Debug, Clone, Copy, PartialEq, Eq)]
437#[cfg_attr(feature = "serde", derive(serde::Serialize))]
438#[non_exhaustive]
439pub enum CreateOutcome {
440    /// The tool materialised the working copy itself.
441    Plain,
442    /// A copy-on-write clone populated the working copy (consumer-supplied).
443    CowCloned,
444}
445
446// The optional `serde` feature derives `Serialize` on the facade DTOs.
447#[cfg(all(test, feature = "serde"))]
448mod serde_tests {
449    use super::*;
450
451    #[test]
452    fn snapshot_and_file_change_serialize_to_clean_json() {
453        let snap = RepoSnapshot {
454            head: Some("abc".into()),
455            branch: Some("main".into()),
456            tracking: Some(UpstreamTracking {
457                branch: "origin/main".into(),
458                ahead: Some(1),
459                behind: Some(0),
460            }),
461            dirty: true,
462            change_count: 2,
463            conflicted: false,
464            operation: OperationState::Merge,
465        };
466        let v = serde_json::to_value(&snap).unwrap();
467        assert_eq!(v["branch"], "main");
468        assert_eq!(v["operation"], "Merge"); // enum → variant name
469        assert_eq!(v["change_count"], 2);
470        // Tracking serialises as one nested object (or null), not three fields.
471        assert_eq!(v["tracking"]["branch"], "origin/main");
472        assert_eq!(v["tracking"]["ahead"], 1);
473
474        let fc = FileChange {
475            path: "a.rs".into(),
476            old_path: None,
477            kind: ChangeKind::Added, // re-exported vcs_diff type, Serialize via vcs-diff/serde
478        };
479        let v = serde_json::to_value(fc).unwrap();
480        assert_eq!(v["path"], "a.rs");
481        assert_eq!(v["kind"], "Added");
482    }
483
484    // `MergeProbe` is adjacently tagged: BOTH outcomes are objects with an
485    // `outcome` discriminant — a stable shape a tool consumer can branch on,
486    // never a bare string for one case and an object for the other.
487    #[test]
488    fn merge_probe_serializes_to_a_type_stable_object() {
489        let clean = serde_json::to_value(MergeProbe::Clean).unwrap();
490        assert_eq!(clean["outcome"], "Clean");
491        assert!(clean.get("files").is_none(), "{clean}");
492
493        let conflicts =
494            serde_json::to_value(MergeProbe::Conflicts(vec!["a.rs".into(), "b.rs".into()]))
495                .unwrap();
496        assert_eq!(conflicts["outcome"], "Conflicts");
497        assert_eq!(conflicts["files"][0], "a.rs");
498        assert_eq!(conflicts["files"][1], "b.rs");
499    }
500}
501
502#[cfg(test)]
503mod ctor_tests {
504    use super::*;
505
506    // A4: the public builder constructors let an external `VcsRepo` impl / test
507    // build the `#[non_exhaustive]` return DTOs, and land the fields where expected.
508    #[test]
509    fn dto_constructors_populate_fields() {
510        let fc = FileChange::new("new.rs", ChangeKind::Modified).old_path("old.rs");
511        assert_eq!(fc.path, "new.rs");
512        assert_eq!(fc.old_path.as_deref(), Some("old.rs"));
513        assert_eq!(fc.kind, ChangeKind::Modified);
514
515        let wt = WorktreeInfo::new("/wt")
516            .branch("feature")
517            .commit("abc123")
518            .bare();
519        assert_eq!(wt.path, PathBuf::from("/wt"));
520        assert_eq!(wt.branch.as_deref(), Some("feature"));
521        assert_eq!(wt.commit.as_deref(), Some("abc123"));
522        assert!(wt.is_bare);
523
524        let up = UpstreamTracking::new("origin/main").ahead(2).behind(3);
525        assert_eq!(up.branch, "origin/main");
526        assert_eq!(up.ahead, Some(2));
527        assert_eq!(up.behind, Some(3));
528        // Uncounted by default.
529        assert_eq!(UpstreamTracking::new("origin/x").ahead, None);
530
531        let snap = RepoSnapshot::new()
532            .head("deadbeef")
533            .branch("main")
534            .tracking(up)
535            .dirty(4)
536            .conflicted()
537            .operation(OperationState::Merge);
538        assert_eq!(snap.head.as_deref(), Some("deadbeef"));
539        assert_eq!(snap.branch.as_deref(), Some("main"));
540        assert_eq!(snap.tracking.as_ref().unwrap().branch, "origin/main");
541        assert_eq!(snap.tracking.as_ref().unwrap().ahead, Some(2));
542        assert!(snap.dirty);
543        assert_eq!(snap.change_count, 4);
544        assert!(snap.conflicted);
545        assert_eq!(snap.operation, OperationState::Merge);
546
547        // A default snapshot is clean.
548        let clean = RepoSnapshot::default();
549        assert!(!clean.dirty && !clean.conflicted && clean.head.is_none());
550        assert_eq!(clean.operation, OperationState::Clear);
551        assert_eq!(clean.change_count, 0);
552    }
553}