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/// Options for [`Repo::delete_branch`](crate::Repo::delete_branch).
41///
42/// `#[non_exhaustive]`, so build it through [`BranchDelete::new`] and the chained
43/// [`force`](BranchDelete::force) setter rather than a struct literal.
44#[derive(Debug, Clone, PartialEq, Eq)]
45#[non_exhaustive]
46pub struct BranchDelete {
47    /// The local branch (git) / bookmark (jj) name to delete.
48    pub name: String,
49    /// Delete even if not fully merged — git `branch -D` vs `-d`. **git only**: jj has
50    /// no force flag for `bookmark delete` and ignores it.
51    pub force: bool,
52}
53
54impl BranchDelete {
55    /// Delete branch/bookmark `name`; not forced (git refuses an unmerged branch).
56    pub fn new(name: impl Into<String>) -> Self {
57        Self {
58            name: name.into(),
59            force: false,
60        }
61    }
62
63    /// Delete even if not fully merged (git only).
64    pub fn force(mut self) -> Self {
65        self.force = true;
66        self
67    }
68}
69
70/// Which version-control tool backs a [`Repo`](crate::Repo).
71#[derive(Debug, Clone, Copy, PartialEq, Eq)]
72#[cfg_attr(feature = "serde", derive(serde::Serialize))]
73#[non_exhaustive]
74pub enum BackendKind {
75    /// A plain Git repository.
76    Git,
77    /// A Jujutsu repository (possibly colocated with Git).
78    Jj,
79}
80
81impl BackendKind {
82    /// The tool's short name (`"git"` / `"jj"`).
83    pub fn as_str(self) -> &'static str {
84        match self {
85            BackendKind::Git => "git",
86            BackendKind::Jj => "jj",
87        }
88    }
89}
90
91/// How a file changed in the working copy — the shared [`vcs_diff::ChangeKind`]
92/// (one type across the wrappers and the facade, no remapping). The status-code
93/// mappers in the backends turn git's `XY` codes / jj's letters into it.
94pub use vcs_diff::ChangeKind;
95
96/// One changed path in the working copy, unified across `git status` /
97/// `jj diff --summary`.
98#[derive(Debug, Clone, PartialEq, Eq)]
99#[cfg_attr(feature = "serde", derive(serde::Serialize))]
100#[non_exhaustive]
101pub struct FileChange {
102    /// The path (the *new* path for a rename).
103    pub path: String,
104    /// The original path for a rename, populated by **both** backends (git's
105    /// `R old -> new` status; jj's `{old => new}` diff-summary form); `None`
106    /// for non-renames.
107    pub old_path: Option<String>,
108    /// How the file changed.
109    pub kind: ChangeKind,
110}
111
112/// Aggregate insertion/deletion counts for the working copy — the shared
113/// [`vcs_diff::DiffStat`], returned by the backends directly (no remapping).
114pub use vcs_diff::DiffStat;
115
116/// One attached worktree (git) / workspace (jj).
117#[derive(Debug, Clone, PartialEq, Eq)]
118#[cfg_attr(feature = "serde", derive(serde::Serialize))]
119#[non_exhaustive]
120pub struct WorktreeInfo {
121    /// Filesystem path of the worktree's working copy.
122    pub path: PathBuf,
123    /// The branch (git) or first bookmark (jj) on it; `None` when detached/none.
124    pub branch: Option<String>,
125    /// The checked-out commit; `None` when unavailable (e.g. a bare git entry).
126    pub commit: Option<String>,
127    /// A bare git worktree entry (always `false` for jj).
128    pub is_bare: bool,
129}
130
131/// Whether the working copy is mid-operation, unified across the backends'
132/// different models: git exposes an in-progress merge or rebase as on-disk state
133/// (`MERGE_HEAD` / a `rebase-*` dir), while jj has no multi-step operations — it
134/// records a conflict directly on the working-copy change.
135#[derive(Debug, Clone, Copy, PartialEq, Eq)]
136#[cfg_attr(feature = "serde", derive(serde::Serialize))]
137#[non_exhaustive]
138pub enum OperationState {
139    /// No operation in progress and no conflict.
140    Clear,
141    /// A git merge is in progress (`MERGE_HEAD` present).
142    Merge,
143    /// A git rebase is in progress (a `rebase-merge` dir, or a `rebase-apply` dir
144    /// **not** left by `git am` — see [`ApplyMailbox`](OperationState::ApplyMailbox)).
145    Rebase,
146    /// A git `am` (mailbox patch apply) is in progress. Distinct from `Rebase`
147    /// because it aborts with `am --abort`, not `rebase --abort` (M20).
148    ApplyMailbox,
149    /// The working copy has an unresolved conflict (chiefly jj, which records
150    /// conflicts on the change rather than pausing an operation).
151    Conflict,
152}
153
154/// Upstream tracking for the current branch: the upstream ref and how far the
155/// branch is ahead/behind it. [`RepoSnapshot`] carries it as one
156/// `Option<UpstreamTracking>` — `None` when no upstream is configured at all.
157///
158/// The ahead/behind counts are themselves `Option`: git reports them only when the
159/// upstream ref actually **resolves**, so a branch whose upstream is *set but gone*
160/// (deleted on the remote, or not yet fetched) yields `Some(UpstreamTracking { branch,
161/// ahead: None, behind: None })` — "tracking configured but uncountable", distinct
162/// from the in-sync `Some(0)`/`Some(0)` that a `unwrap_or(0)` used to fabricate (M17).
163#[derive(Debug, Clone, PartialEq, Eq)]
164#[cfg_attr(feature = "serde", derive(serde::Serialize))]
165#[non_exhaustive]
166pub struct UpstreamTracking {
167    /// The upstream tracking branch, e.g. `"origin/main"`.
168    pub branch: String,
169    /// Commits the local branch is ahead of the upstream; `None` when the upstream is
170    /// set but git couldn't count against it (gone remote / not fetched).
171    pub ahead: Option<usize>,
172    /// Commits the local branch is behind the upstream; `None` when uncountable (see
173    /// [`ahead`](UpstreamTracking::ahead)).
174    pub behind: Option<usize>,
175}
176
177/// A one-shot snapshot of the common repository state — branch, upstream
178/// tracking, ahead/behind, dirtiness, and operation state — gathered in a
179/// **small fixed** number of process spawns instead of a call per field. The
180/// data a prompt, status line, or TUI refresh needs. See
181/// [`Repo::snapshot`](crate::Repo::snapshot).
182#[derive(Debug, Clone, PartialEq, Eq)]
183#[cfg_attr(feature = "serde", derive(serde::Serialize))]
184#[non_exhaustive]
185pub struct RepoSnapshot {
186    /// The working-copy commit's **full** object id (git `HEAD` oid / jj `@`
187    /// commit id) on both backends; `None` on an unborn git repo. Truncate for
188    /// display.
189    pub head: Option<String>,
190    /// Current branch (git) / bookmark (jj). On jj this is the nearest bookmark
191    /// reachable from `@` (`heads(::@ & bookmarks())`), so it stays set across a
192    /// `jj describe`/`jj new`/`jj commit`; `None` when detached / no bookmark on
193    /// or above `@`. Matches [`Repo::current_branch`](crate::Repo::current_branch)
194    /// by construction.
195    pub branch: Option<String>,
196    /// Upstream tracking and how far the branch is ahead/behind it, as one unit —
197    /// `Some` only when an upstream is configured, `None` otherwise (and **always
198    /// `None` on jj**, which has no git-style upstream tracking). Bundling the
199    /// three together makes the "all-or-nothing" relationship unrepresentable as a
200    /// half-populated state. See [`UpstreamTracking`].
201    pub tracking: Option<UpstreamTracking>,
202    /// Whether the working copy has any uncommitted change (tracked or untracked).
203    pub dirty: bool,
204    /// Number of changed paths (tracked + untracked on git; the `@` change's
205    /// files on jj).
206    pub change_count: usize,
207    /// Whether the working copy has an unresolved conflict.
208    pub conflicted: bool,
209    /// In-progress operation / conflict state (see [`OperationState`]).
210    pub operation: OperationState,
211}
212
213/// The outcome of a [`try_merge`](crate::Repo::try_merge) probe. The probe
214/// itself is rolled back before it returns, whatever the outcome — this only
215/// *reports* what a real merge would do.
216#[derive(Debug, Clone, PartialEq, Eq)]
217#[cfg_attr(feature = "serde", derive(serde::Serialize))]
218// Adjacently tagged so the JSON is a *type-stable object* for both outcomes —
219// `{"outcome":"Clean"}` and `{"outcome":"Conflicts","files":[…]}` — rather than
220// serde's default externally-tagged shape, which would emit a bare string
221// `"Clean"` for one variant and an object for the other (a polymorphic result an
222// agent consumer can't branch on uniformly).
223#[cfg_attr(feature = "serde", serde(tag = "outcome", content = "files"))]
224#[non_exhaustive]
225pub enum MergeProbe {
226    /// The merge would apply without conflicts.
227    Clean,
228    /// The merge would conflict in these paths (repo-relative, `/` separators —
229    /// the same contract as [`conflicted_files`](crate::Repo::conflicted_files)).
230    Conflicts(Vec<String>),
231}
232
233impl MergeProbe {
234    /// Whether the probe found no conflicts.
235    pub fn is_clean(&self) -> bool {
236        matches!(self, MergeProbe::Clean)
237    }
238}
239
240/// How a worktree was materialised. The facade always reports
241/// [`Plain`](CreateOutcome::Plain); the [`CowCloned`](CreateOutcome::CowCloned)
242/// variant exists so a consumer that layers a copy-on-write strategy on top can
243/// reuse this type.
244#[derive(Debug, Clone, Copy, PartialEq, Eq)]
245#[cfg_attr(feature = "serde", derive(serde::Serialize))]
246#[non_exhaustive]
247pub enum CreateOutcome {
248    /// The tool materialised the working copy itself.
249    Plain,
250    /// A copy-on-write clone populated the working copy (consumer-supplied).
251    CowCloned,
252}
253
254// The optional `serde` feature derives `Serialize` on the facade DTOs.
255#[cfg(all(test, feature = "serde"))]
256mod serde_tests {
257    use super::*;
258
259    #[test]
260    fn snapshot_and_file_change_serialize_to_clean_json() {
261        let snap = RepoSnapshot {
262            head: Some("abc".into()),
263            branch: Some("main".into()),
264            tracking: Some(UpstreamTracking {
265                branch: "origin/main".into(),
266                ahead: Some(1),
267                behind: Some(0),
268            }),
269            dirty: true,
270            change_count: 2,
271            conflicted: false,
272            operation: OperationState::Merge,
273        };
274        let v = serde_json::to_value(&snap).unwrap();
275        assert_eq!(v["branch"], "main");
276        assert_eq!(v["operation"], "Merge"); // enum → variant name
277        assert_eq!(v["change_count"], 2);
278        // Tracking serialises as one nested object (or null), not three fields.
279        assert_eq!(v["tracking"]["branch"], "origin/main");
280        assert_eq!(v["tracking"]["ahead"], 1);
281
282        let fc = FileChange {
283            path: "a.rs".into(),
284            old_path: None,
285            kind: ChangeKind::Added, // re-exported vcs_diff type, Serialize via vcs-diff/serde
286        };
287        let v = serde_json::to_value(fc).unwrap();
288        assert_eq!(v["path"], "a.rs");
289        assert_eq!(v["kind"], "Added");
290    }
291
292    // `MergeProbe` is adjacently tagged: BOTH outcomes are objects with an
293    // `outcome` discriminant — a stable shape a tool consumer can branch on,
294    // never a bare string for one case and an object for the other.
295    #[test]
296    fn merge_probe_serializes_to_a_type_stable_object() {
297        let clean = serde_json::to_value(MergeProbe::Clean).unwrap();
298        assert_eq!(clean["outcome"], "Clean");
299        assert!(clean.get("files").is_none(), "{clean}");
300
301        let conflicts =
302            serde_json::to_value(MergeProbe::Conflicts(vec!["a.rs".into(), "b.rs".into()]))
303                .unwrap();
304        assert_eq!(conflicts["outcome"], "Conflicts");
305        assert_eq!(conflicts["files"][0], "a.rs");
306        assert_eq!(conflicts["files"][1], "b.rs");
307    }
308}