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/// A one-shot snapshot of the common repository state — branch, upstream
88/// tracking, ahead/behind, dirtiness, and operation state — gathered in **one or
89/// two** process spawns instead of a call per field. The data a prompt, status
90/// line, or TUI refresh needs. See [`Repo::snapshot`](crate::Repo::snapshot).
91#[derive(Debug, Clone, PartialEq, Eq)]
92#[cfg_attr(feature = "serde", derive(serde::Serialize))]
93#[non_exhaustive]
94pub struct RepoSnapshot {
95    /// The working-copy commit's **full** object id (git `HEAD` oid / jj `@`
96    /// commit id) on both backends; `None` on an unborn git repo. Truncate for
97    /// display.
98    pub head: Option<String>,
99    /// Current branch (git) / bookmark (jj); `None` when detached or unset.
100    pub branch: Option<String>,
101    /// Upstream tracking branch; `None` when unset, and **always `None` on jj**
102    /// (jj has no git-style upstream tracking).
103    pub upstream: Option<String>,
104    /// Commits ahead of the upstream; `None` with no upstream (always on jj).
105    pub ahead: Option<usize>,
106    /// Commits behind the upstream; `None` with no upstream (always on jj).
107    pub behind: Option<usize>,
108    /// Whether the working copy has any uncommitted change (tracked or untracked).
109    pub dirty: bool,
110    /// Number of changed paths (tracked + untracked on git; the `@` change's
111    /// files on jj).
112    pub change_count: usize,
113    /// Whether the working copy has an unresolved conflict.
114    pub conflicted: bool,
115    /// In-progress operation / conflict state (see [`OperationState`]).
116    pub operation: OperationState,
117}
118
119/// The outcome of a [`try_merge`](crate::Repo::try_merge) probe. The probe
120/// itself is rolled back before it returns, whatever the outcome — this only
121/// *reports* what a real merge would do.
122#[derive(Debug, Clone, PartialEq, Eq)]
123#[cfg_attr(feature = "serde", derive(serde::Serialize))]
124// Adjacently tagged so the JSON is a *type-stable object* for both outcomes —
125// `{"outcome":"Clean"}` and `{"outcome":"Conflicts","files":[…]}` — rather than
126// serde's default externally-tagged shape, which would emit a bare string
127// `"Clean"` for one variant and an object for the other (a polymorphic result an
128// agent consumer can't branch on uniformly).
129#[cfg_attr(feature = "serde", serde(tag = "outcome", content = "files"))]
130#[non_exhaustive]
131pub enum MergeProbe {
132    /// The merge would apply without conflicts.
133    Clean,
134    /// The merge would conflict in these paths (repo-relative, `/` separators —
135    /// the same contract as [`conflicted_files`](crate::Repo::conflicted_files)).
136    Conflicts(Vec<String>),
137}
138
139impl MergeProbe {
140    /// Whether the probe found no conflicts.
141    pub fn is_clean(&self) -> bool {
142        matches!(self, MergeProbe::Clean)
143    }
144}
145
146/// How a worktree was materialised. The facade always reports
147/// [`Plain`](CreateOutcome::Plain); the [`CowCloned`](CreateOutcome::CowCloned)
148/// variant exists so a consumer that layers a copy-on-write strategy on top can
149/// reuse this type.
150#[derive(Debug, Clone, Copy, PartialEq, Eq)]
151#[cfg_attr(feature = "serde", derive(serde::Serialize))]
152#[non_exhaustive]
153pub enum CreateOutcome {
154    /// The tool materialised the working copy itself.
155    Plain,
156    /// A copy-on-write clone populated the working copy (consumer-supplied).
157    CowCloned,
158}
159
160// The optional `serde` feature derives `Serialize` on the facade DTOs.
161#[cfg(all(test, feature = "serde"))]
162mod serde_tests {
163    use super::*;
164
165    #[test]
166    fn snapshot_and_file_change_serialize_to_clean_json() {
167        let snap = RepoSnapshot {
168            head: Some("abc".into()),
169            branch: Some("main".into()),
170            upstream: None,
171            ahead: Some(1),
172            behind: Some(0),
173            dirty: true,
174            change_count: 2,
175            conflicted: false,
176            operation: OperationState::Merge,
177        };
178        let v = serde_json::to_value(&snap).unwrap();
179        assert_eq!(v["branch"], "main");
180        assert_eq!(v["operation"], "Merge"); // enum → variant name
181        assert_eq!(v["change_count"], 2);
182
183        let fc = FileChange {
184            path: "a.rs".into(),
185            old_path: None,
186            kind: ChangeKind::Added, // re-exported vcs_diff type, Serialize via vcs-diff/serde
187        };
188        let v = serde_json::to_value(fc).unwrap();
189        assert_eq!(v["path"], "a.rs");
190        assert_eq!(v["kind"], "Added");
191    }
192
193    // `MergeProbe` is adjacently tagged: BOTH outcomes are objects with an
194    // `outcome` discriminant — a stable shape a tool consumer can branch on,
195    // never a bare string for one case and an object for the other.
196    #[test]
197    fn merge_probe_serializes_to_a_type_stable_object() {
198        let clean = serde_json::to_value(MergeProbe::Clean).unwrap();
199        assert_eq!(clean["outcome"], "Clean");
200        assert!(clean.get("files").is_none(), "{clean}");
201
202        let conflicts =
203            serde_json::to_value(MergeProbe::Conflicts(vec!["a.rs".into(), "b.rs".into()]))
204                .unwrap();
205        assert_eq!(conflicts["outcome"], "Conflicts");
206        assert_eq!(conflicts["files"][0], "a.rs");
207        assert_eq!(conflicts["files"][1], "b.rs");
208    }
209}