Skip to main content

vcs_core/
dto.rs

1//! Backend-agnostic data types the facade returns, generalising the per-tool
2//! shapes of `vcs-git` and `vcs-jj` into one set a consumer can use without
3//! knowing which backend is in play.
4
5use std::path::PathBuf;
6
7/// Which version-control tool backs a [`Repo`](crate::Repo).
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9#[cfg_attr(feature = "serde", derive(serde::Serialize))]
10#[non_exhaustive]
11pub enum BackendKind {
12    /// A plain Git repository.
13    Git,
14    /// A Jujutsu repository (possibly colocated with Git).
15    Jj,
16}
17
18impl BackendKind {
19    /// The tool's short name (`"git"` / `"jj"`).
20    pub fn as_str(self) -> &'static str {
21        match self {
22            BackendKind::Git => "git",
23            BackendKind::Jj => "jj",
24        }
25    }
26}
27
28/// How a file changed in the working copy — the shared [`vcs_diff::ChangeKind`]
29/// (one type across the wrappers and the facade, no remapping). The status-code
30/// mappers in the backends turn git's `XY` codes / jj's letters into it.
31pub use vcs_diff::ChangeKind;
32
33/// One changed path in the working copy, unified across `git status` /
34/// `jj diff --summary`.
35#[derive(Debug, Clone, PartialEq, Eq)]
36#[cfg_attr(feature = "serde", derive(serde::Serialize))]
37#[non_exhaustive]
38pub struct FileChange {
39    /// The path (the *new* path for a rename).
40    pub path: String,
41    /// The original path for a rename, populated by **both** backends (git's
42    /// `R old -> new` status; jj's `{old => new}` diff-summary form); `None`
43    /// for non-renames.
44    pub old_path: Option<String>,
45    /// How the file changed.
46    pub kind: ChangeKind,
47}
48
49/// Aggregate insertion/deletion counts for the working copy — the shared
50/// [`vcs_diff::DiffStat`], returned by the backends directly (no remapping).
51pub use vcs_diff::DiffStat;
52
53/// One attached worktree (git) / workspace (jj).
54#[derive(Debug, Clone, PartialEq, Eq)]
55#[cfg_attr(feature = "serde", derive(serde::Serialize))]
56#[non_exhaustive]
57pub struct WorktreeInfo {
58    /// Filesystem path of the worktree's working copy.
59    pub path: PathBuf,
60    /// The branch (git) or first bookmark (jj) on it; `None` when detached/none.
61    pub branch: Option<String>,
62    /// The checked-out commit; `None` when unavailable (e.g. a bare git entry).
63    pub commit: Option<String>,
64    /// A bare git worktree entry (always `false` for jj).
65    pub is_bare: bool,
66}
67
68/// Whether the working copy is mid-operation, unified across the backends'
69/// different models: git exposes an in-progress merge or rebase as on-disk state
70/// (`MERGE_HEAD` / a `rebase-*` dir), while jj has no multi-step operations — it
71/// records a conflict directly on the working-copy change.
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize))]
74#[non_exhaustive]
75pub enum OperationState {
76    /// No operation in progress and no conflict.
77    Clear,
78    /// A git merge is in progress (`MERGE_HEAD` present).
79    Merge,
80    /// A git rebase is in progress (a `rebase-merge`/`rebase-apply` dir present).
81    Rebase,
82    /// The working copy has an unresolved conflict (chiefly jj, which records
83    /// conflicts on the change rather than pausing an operation).
84    Conflict,
85}
86
87/// Upstream tracking for the current branch: the upstream ref and how far the
88/// branch is ahead/behind it. Only meaningful as a whole — git reports the three
89/// together or not at all — so [`RepoSnapshot`] carries it as one
90/// `Option<UpstreamTracking>` rather than three coupled `Option`s.
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize))]
93#[non_exhaustive]
94pub struct UpstreamTracking {
95    /// The upstream tracking branch, e.g. `"origin/main"`.
96    pub branch: String,
97    /// Commits the local branch is ahead of the upstream.
98    pub ahead: usize,
99    /// Commits the local branch is behind the upstream.
100    pub behind: usize,
101}
102
103/// A one-shot snapshot of the common repository state — branch, upstream
104/// tracking, ahead/behind, dirtiness, and operation state — gathered in a
105/// **small fixed** number of process spawns instead of a call per field. The
106/// data a prompt, status line, or TUI refresh needs. See
107/// [`Repo::snapshot`](crate::Repo::snapshot).
108#[derive(Debug, Clone, PartialEq, Eq)]
109#[cfg_attr(feature = "serde", derive(serde::Serialize))]
110#[non_exhaustive]
111pub struct RepoSnapshot {
112    /// The working-copy commit's **full** object id (git `HEAD` oid / jj `@`
113    /// commit id) on both backends; `None` on an unborn git repo. Truncate for
114    /// display.
115    pub head: Option<String>,
116    /// Current branch (git) / bookmark (jj). On jj this is the nearest bookmark
117    /// reachable from `@` (`heads(::@ & bookmarks())`), so it stays set across a
118    /// `jj describe`/`jj new`/`jj commit`; `None` when detached / no bookmark on
119    /// or above `@`. Matches [`Repo::current_branch`](crate::Repo::current_branch)
120    /// by construction.
121    pub branch: Option<String>,
122    /// Upstream tracking and how far the branch is ahead/behind it, as one unit —
123    /// `Some` only when an upstream is configured, `None` otherwise (and **always
124    /// `None` on jj**, which has no git-style upstream tracking). Bundling the
125    /// three together makes the "all-or-nothing" relationship unrepresentable as a
126    /// half-populated state. See [`UpstreamTracking`].
127    pub tracking: Option<UpstreamTracking>,
128    /// Whether the working copy has any uncommitted change (tracked or untracked).
129    pub dirty: bool,
130    /// Number of changed paths (tracked + untracked on git; the `@` change's
131    /// files on jj).
132    pub change_count: usize,
133    /// Whether the working copy has an unresolved conflict.
134    pub conflicted: bool,
135    /// In-progress operation / conflict state (see [`OperationState`]).
136    pub operation: OperationState,
137}
138
139/// The outcome of a [`try_merge`](crate::Repo::try_merge) probe. The probe
140/// itself is rolled back before it returns, whatever the outcome — this only
141/// *reports* what a real merge would do.
142#[derive(Debug, Clone, PartialEq, Eq)]
143#[cfg_attr(feature = "serde", derive(serde::Serialize))]
144// Adjacently tagged so the JSON is a *type-stable object* for both outcomes —
145// `{"outcome":"Clean"}` and `{"outcome":"Conflicts","files":[…]}` — rather than
146// serde's default externally-tagged shape, which would emit a bare string
147// `"Clean"` for one variant and an object for the other (a polymorphic result an
148// agent consumer can't branch on uniformly).
149#[cfg_attr(feature = "serde", serde(tag = "outcome", content = "files"))]
150#[non_exhaustive]
151pub enum MergeProbe {
152    /// The merge would apply without conflicts.
153    Clean,
154    /// The merge would conflict in these paths (repo-relative, `/` separators —
155    /// the same contract as [`conflicted_files`](crate::Repo::conflicted_files)).
156    Conflicts(Vec<String>),
157}
158
159impl MergeProbe {
160    /// Whether the probe found no conflicts.
161    pub fn is_clean(&self) -> bool {
162        matches!(self, MergeProbe::Clean)
163    }
164}
165
166/// How a worktree was materialised. The facade always reports
167/// [`Plain`](CreateOutcome::Plain); the [`CowCloned`](CreateOutcome::CowCloned)
168/// variant exists so a consumer that layers a copy-on-write strategy on top can
169/// reuse this type.
170#[derive(Debug, Clone, Copy, PartialEq, Eq)]
171#[cfg_attr(feature = "serde", derive(serde::Serialize))]
172#[non_exhaustive]
173pub enum CreateOutcome {
174    /// The tool materialised the working copy itself.
175    Plain,
176    /// A copy-on-write clone populated the working copy (consumer-supplied).
177    CowCloned,
178}
179
180// The optional `serde` feature derives `Serialize` on the facade DTOs.
181#[cfg(all(test, feature = "serde"))]
182mod serde_tests {
183    use super::*;
184
185    #[test]
186    fn snapshot_and_file_change_serialize_to_clean_json() {
187        let snap = RepoSnapshot {
188            head: Some("abc".into()),
189            branch: Some("main".into()),
190            tracking: Some(UpstreamTracking {
191                branch: "origin/main".into(),
192                ahead: 1,
193                behind: 0,
194            }),
195            dirty: true,
196            change_count: 2,
197            conflicted: false,
198            operation: OperationState::Merge,
199        };
200        let v = serde_json::to_value(&snap).unwrap();
201        assert_eq!(v["branch"], "main");
202        assert_eq!(v["operation"], "Merge"); // enum → variant name
203        assert_eq!(v["change_count"], 2);
204        // Tracking serialises as one nested object (or null), not three fields.
205        assert_eq!(v["tracking"]["branch"], "origin/main");
206        assert_eq!(v["tracking"]["ahead"], 1);
207
208        let fc = FileChange {
209            path: "a.rs".into(),
210            old_path: None,
211            kind: ChangeKind::Added, // re-exported vcs_diff type, Serialize via vcs-diff/serde
212        };
213        let v = serde_json::to_value(fc).unwrap();
214        assert_eq!(v["path"], "a.rs");
215        assert_eq!(v["kind"], "Added");
216    }
217
218    // `MergeProbe` is adjacently tagged: BOTH outcomes are objects with an
219    // `outcome` discriminant — a stable shape a tool consumer can branch on,
220    // never a bare string for one case and an object for the other.
221    #[test]
222    fn merge_probe_serializes_to_a_type_stable_object() {
223        let clean = serde_json::to_value(MergeProbe::Clean).unwrap();
224        assert_eq!(clean["outcome"], "Clean");
225        assert!(clean.get("files").is_none(), "{clean}");
226
227        let conflicts =
228            serde_json::to_value(MergeProbe::Conflicts(vec!["a.rs".into(), "b.rs".into()]))
229                .unwrap();
230        assert_eq!(conflicts["outcome"], "Conflicts");
231        assert_eq!(conflicts["files"][0], "a.rs");
232        assert_eq!(conflicts["files"][1], "b.rs");
233    }
234}