Skip to main content

vcs_core/
lib.rs

1//! `vcs-core` — a unified facade over [`vcs-git`](vcs_git) and [`vcs-jj`](vcs_jj).
2//!
3//! Two pieces both downstream tools kept re-implementing:
4//!
5//! * [`detect`] — walk up from a directory to find a `.git`/`.jj` repository
6//!   (jj wins when colocated), returning the [`BackendKind`] and root.
7//! * [`Repo`] — a cwd-bound handle that dispatches the *common* VCS operations
8//!   (status, diff stat, partial commit, worktree create/remove, …) to whichever
9//!   backend is present, returning backend-agnostic DTOs. Open it
10//!   once with [`Repo::open`]; re-anchor it to another directory with
11//!   [`Repo::at`] without threading a `dir` argument through every call.
12//!
13//! Tool-specific operations stay on the underlying typed clients, reachable via
14//! the [`Repo::git`] / [`Repo::jj`] escape hatches.
15//!
16//! ```no_run
17//! use vcs_core::Repo;
18//! # fn run() -> vcs_core::Result<()> {
19//! let repo = Repo::open(".")?;
20//! # let _ = repo.kind();
21//! # Ok(()) }
22//! ```
23//!
24//! The handle is generic over the [`ProcessRunner`] so tests can inject a fake;
25//! [`Repo::open`] uses the real job-backed runner.
26
27use std::path::{Path, PathBuf};
28use std::sync::Arc;
29
30use processkit::{JobRunner, ProcessRunner};
31use vcs_git::Git;
32use vcs_jj::Jj;
33
34mod dto;
35mod error;
36mod git_backend;
37mod jj_backend;
38
39pub use dto::{BackendKind, ChangeKind, CreateOutcome, DiffStat, FileChange, WorktreeInfo};
40pub use error::{Error, Result};
41
42// Re-export the underlying typed clients so a consumer depending only on
43// `vcs-core` can still reach raw, tool-specific operations — and their types
44// (`GitApi`, `JjApi`, `WorktreeAdd`, `JjFileset`, …) — without adding `vcs-git`
45// / `vcs-jj` as separate dependencies. [`Repo::git`] / [`Repo::jj`] hand out
46// borrows of these clients; the consumer decides, per call, whether to go
47// through the facade or straight to the tool.
48pub use vcs_git;
49pub use vcs_jj;
50
51/// The result of [`detect`]: which backend, and the repository root it was found
52/// at.
53#[derive(Debug, Clone, PartialEq, Eq)]
54#[non_exhaustive]
55pub struct Located {
56    /// The detected backend.
57    pub kind: BackendKind,
58    /// The directory holding `.git`/`.jj` — the worktree root.
59    pub root: PathBuf,
60}
61
62/// Walk up from `start` to the filesystem root looking for a repository. A `.jj`
63/// directory wins over `.git` (colocated repos are driven through jj); `.git` may
64/// be a directory or a gitlink file (a linked worktree/submodule). Pure
65/// filesystem probing — no subprocess.
66///
67/// `start` is walked exactly as given via [`Path::parent`], so pass an **absolute**
68/// path to search ancestors — a relative path like `"."` has no ancestor chain
69/// and only its own directory is checked. ([`Repo::open`] absolutises for you.)
70pub fn detect(start: &Path) -> Option<Located> {
71    let mut current = Some(start);
72    while let Some(dir) = current {
73        if dir.join(".jj").is_dir() {
74            return Some(Located {
75                kind: BackendKind::Jj,
76                root: dir.to_path_buf(),
77            });
78        }
79        if dir.join(".git").exists() {
80            return Some(Located {
81                kind: BackendKind::Git,
82                root: dir.to_path_buf(),
83            });
84        }
85        current = dir.parent();
86    }
87    None
88}
89
90/// The per-tool client behind a [`Repo`]. Shared via `Arc` so [`Repo::at`] can
91/// re-anchor the cwd cheaply without rebuilding the client.
92enum Backend<R: ProcessRunner> {
93    Git(Arc<Git<R>>),
94    Jj(Arc<Jj<R>>),
95}
96
97impl<R: ProcessRunner> Backend<R> {
98    fn shared(&self) -> Self {
99        match self {
100            Backend::Git(g) => Backend::Git(Arc::clone(g)),
101            Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
102        }
103    }
104}
105
106/// A cwd-bound, backend-agnostic VCS handle. Operations run against the bound
107/// directory ([`cwd`](Repo::cwd)); use [`at`](Repo::at) to get a sibling handle
108/// bound elsewhere.
109pub struct Repo<R: ProcessRunner = JobRunner> {
110    root: PathBuf,
111    cwd: PathBuf,
112    backend: Backend<R>,
113}
114
115impl Repo<JobRunner> {
116    /// Detect the repository at or above `dir` and open a handle bound to `dir`,
117    /// using the real job-backed runner. Errors with
118    /// [`Error::NotARepository`] when no `.git`/`.jj` is found.
119    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
120        // Absolutise first: `detect` walks parents, and a relative path like "."
121        // has no real ancestor chain (`Path::new(".").parent()` is `""`, then
122        // `None`), so a relative input would never find a repo above the cwd.
123        let dir = std::path::absolute(dir.as_ref())?;
124        let located = detect(&dir).ok_or_else(|| Error::NotARepository(dir.clone()))?;
125        let backend = match located.kind {
126            BackendKind::Git => Backend::Git(Arc::new(Git::new())),
127            BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
128        };
129        Ok(Repo {
130            root: located.root,
131            cwd: dir,
132            backend,
133        })
134    }
135}
136
137impl<R: ProcessRunner> Repo<R> {
138    /// Build a git-backed handle from an explicit client — for a custom runner
139    /// (e.g. a test seam) or a pre-configured [`Git`].
140    pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
141        Repo {
142            root: root.into(),
143            cwd: cwd.into(),
144            backend: Backend::Git(Arc::new(client)),
145        }
146    }
147
148    /// Build a jj-backed handle from an explicit client.
149    pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
150        Repo {
151            root: root.into(),
152            cwd: cwd.into(),
153            backend: Backend::Jj(Arc::new(client)),
154        }
155    }
156
157    /// Which backend drives this handle.
158    pub fn kind(&self) -> BackendKind {
159        match &self.backend {
160            Backend::Git(_) => BackendKind::Git,
161            Backend::Jj(_) => BackendKind::Jj,
162        }
163    }
164
165    /// The repository root detected at open time.
166    pub fn root(&self) -> &Path {
167        &self.root
168    }
169
170    /// The directory operations run against.
171    pub fn cwd(&self) -> &Path {
172        &self.cwd
173    }
174
175    /// A sibling handle bound to `dir`, sharing this handle's client and root.
176    pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
177        Repo {
178            root: self.root.clone(),
179            cwd: dir.into(),
180            backend: self.backend.shared(),
181        }
182    }
183
184    /// The underlying [`Git`] client, or `None` when jj-backed — an escape hatch
185    /// to git-only operations not on the common surface.
186    pub fn git(&self) -> Option<&Git<R>> {
187        match &self.backend {
188            Backend::Git(g) => Some(g.as_ref()),
189            Backend::Jj(_) => None,
190        }
191    }
192
193    /// The underlying [`Jj`] client, or `None` when git-backed.
194    pub fn jj(&self) -> Option<&Jj<R>> {
195        match &self.backend {
196            Backend::Jj(j) => Some(j.as_ref()),
197            Backend::Git(_) => None,
198        }
199    }
200
201    /// The current branch (git) or bookmark (jj); `None` when detached / no
202    /// bookmark on the working copy.
203    pub async fn current_branch(&self) -> Result<Option<String>> {
204        match &self.backend {
205            Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
206            Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
207        }
208    }
209
210    /// The trunk branch/bookmark; `None` when it can't be resolved.
211    pub async fn trunk(&self) -> Result<Option<String>> {
212        match &self.backend {
213            Backend::Git(g) => git_backend::trunk(g, &self.cwd).await,
214            Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await,
215        }
216    }
217
218    /// Local branch (git) / bookmark (jj) names.
219    pub async fn local_branches(&self) -> Result<Vec<String>> {
220        match &self.backend {
221            Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
222            Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
223        }
224    }
225
226    /// Whether a local branch/bookmark named `name` exists.
227    pub async fn branch_exists(&self, name: &str) -> Result<bool> {
228        match &self.backend {
229            Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
230            Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
231        }
232    }
233
234    /// Whether the working copy has uncommitted changes (git: a non-empty
235    /// `status`; jj: a non-empty working-copy change `@`).
236    pub async fn has_uncommitted_changes(&self) -> Result<bool> {
237        match &self.backend {
238            Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
239            Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
240        }
241    }
242
243    /// Delete a local branch (git) / bookmark (jj). `force` applies to git only
244    /// (`branch -D` vs `-d`); jj has no force and ignores it.
245    pub async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
246        match &self.backend {
247            Backend::Git(g) => git_backend::delete_branch(g, &self.cwd, name, force).await,
248            Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, name).await,
249        }
250    }
251
252    /// Rename a local branch (git) / bookmark (jj).
253    pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
254        match &self.backend {
255            Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
256            Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
257        }
258    }
259
260    /// The working-copy changes (git `status` / jj `diff -r @ --summary`).
261    pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
262        match &self.backend {
263            Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
264            Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
265        }
266    }
267
268    /// Aggregate insertion/deletion counts for the working copy.
269    pub async fn diff_stat(&self) -> Result<DiffStat> {
270        match &self.backend {
271            Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
272            Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
273        }
274    }
275
276    /// Commit exactly `paths` with `message` (git `commit --only`, jj
277    /// `commit <filesets>`). Paths are repo-relative.
278    pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
279        match &self.backend {
280            Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
281            Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
282        }
283    }
284
285    /// Fetch from the default remote (git `fetch` / jj `git fetch`).
286    pub async fn fetch(&self) -> Result<()> {
287        match &self.backend {
288            Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
289            Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
290        }
291    }
292
293    /// List attached worktrees (git) / workspaces (jj).
294    pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
295        match &self.backend {
296            Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
297            Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
298        }
299    }
300
301    /// Create a worktree/workspace at `path` on a **new** `branch` based on
302    /// `base`. Always [`CreateOutcome::Plain`]; a copy-on-write strategy stays in
303    /// the consumer.
304    ///
305    /// `branch` must not already exist. The jj path is two steps (`workspace add`
306    /// then `bookmark create`) and is not atomic: if the bookmark step fails, the
307    /// freshly-added workspace is left in place for the caller to clean up. A
308    /// consumer needing resume-existing or rollback semantics should drive the
309    /// underlying client via [`jj`](Repo::jj) / [`git`](Repo::git).
310    pub async fn create_worktree(
311        &self,
312        path: &Path,
313        branch: &str,
314        base: &str,
315    ) -> Result<CreateOutcome> {
316        match &self.backend {
317            Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
318            Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
319        }
320    }
321
322    /// Remove the worktree/workspace at `path`. For jj this resolves the
323    /// workspace name by matching `path`, deletes the directory, then forgets it.
324    pub async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
325        match &self.backend {
326            Backend::Git(g) => git_backend::remove_worktree(g, &self.cwd, path, force).await,
327            Backend::Jj(j) => jj_backend::remove_worktree(j, &self.cwd, path, force).await,
328        }
329    }
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use processkit::{Reply, ScriptedRunner};
336
337    // --- detect ------------------------------------------------------------
338
339    /// A unique temp directory, removed on drop.
340    struct TempDir(PathBuf);
341    impl TempDir {
342        fn new(tag: &str) -> Self {
343            // Unique without a temp crate: process id + a monotonic counter, so
344            // parallel tests never collide.
345            use std::sync::atomic::{AtomicU64, Ordering};
346            static COUNTER: AtomicU64 = AtomicU64::new(0);
347            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
348            let dir =
349                std::env::temp_dir().join(format!("vcs-core-{tag}-{}-{n}", std::process::id()));
350            std::fs::create_dir_all(&dir).expect("create temp dir");
351            TempDir(dir)
352        }
353        fn path(&self) -> &Path {
354            &self.0
355        }
356    }
357    impl Drop for TempDir {
358        fn drop(&mut self) {
359            let _ = std::fs::remove_dir_all(&self.0);
360        }
361    }
362
363    #[test]
364    fn detect_finds_git_and_jj_and_prefers_jj() {
365        let tmp = TempDir::new("detect");
366        let root = tmp.path();
367
368        // Plain git repo.
369        std::fs::create_dir_all(root.join(".git")).unwrap();
370        let located = detect(root).expect("git detected");
371        assert_eq!(located.kind, BackendKind::Git);
372        assert_eq!(located.root, root);
373
374        // Colocated: adding .jj makes jj win.
375        std::fs::create_dir_all(root.join(".jj")).unwrap();
376        assert_eq!(detect(root).unwrap().kind, BackendKind::Jj);
377    }
378
379    #[test]
380    fn detect_walks_up_to_ancestor() {
381        let tmp = TempDir::new("walkup");
382        let root = tmp.path();
383        std::fs::create_dir_all(root.join(".git")).unwrap();
384        let nested = root.join("a").join("b");
385        std::fs::create_dir_all(&nested).unwrap();
386        let located = detect(&nested).expect("found via ancestor walk");
387        assert_eq!(located.kind, BackendKind::Git);
388        assert_eq!(located.root, root);
389    }
390
391    #[test]
392    fn detect_returns_none_outside_repo() {
393        let tmp = TempDir::new("norepo");
394        assert!(detect(tmp.path()).is_none());
395    }
396
397    // --- dispatch (hermetic, ScriptedRunner-backed) ------------------------
398
399    fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
400        Repo::from_git("/repo", "/repo", Git::with_runner(runner))
401    }
402
403    fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
404        Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
405    }
406
407    #[tokio::test]
408    async fn kind_and_escape_hatches_reflect_backend() {
409        let repo = git_repo(ScriptedRunner::new());
410        assert_eq!(repo.kind(), BackendKind::Git);
411        assert!(repo.git().is_some());
412        assert!(repo.jj().is_none());
413    }
414
415    #[tokio::test]
416    async fn current_branch_maps_detached_head_to_none() {
417        let named = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
418        assert_eq!(
419            named.current_branch().await.unwrap().as_deref(),
420            Some("main")
421        );
422        let detached = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("HEAD\n")));
423        assert!(detached.current_branch().await.unwrap().is_none());
424    }
425
426    #[tokio::test]
427    async fn changed_files_maps_git_status() {
428        let repo = git_repo(ScriptedRunner::new().on(
429            ["status"],
430            Reply::ok(" M a.rs\0?? b.rs\0R  new.rs\0old.rs\0"),
431        ));
432        let changes = repo.changed_files().await.unwrap();
433        assert_eq!(changes.len(), 3);
434        assert_eq!(changes[0].kind, ChangeKind::Modified);
435        assert_eq!(changes[1].kind, ChangeKind::Added);
436        assert_eq!(changes[2].kind, ChangeKind::Renamed);
437        assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
438    }
439
440    #[tokio::test]
441    async fn local_branches_maps_git_branch_output() {
442        let repo = git_repo(ScriptedRunner::new().on(["branch"], Reply::ok("* main\n  feat\n")));
443        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
444    }
445
446    #[tokio::test]
447    async fn branch_exists_reads_show_ref_exit() {
448        let yes = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
449        assert!(yes.branch_exists("main").await.unwrap());
450        let no = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
451        assert!(!no.branch_exists("nope").await.unwrap());
452    }
453
454    #[tokio::test]
455    async fn has_uncommitted_changes_reflects_status() {
456        let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
457        assert!(dirty.has_uncommitted_changes().await.unwrap());
458        let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
459        assert!(!clean.has_uncommitted_changes().await.unwrap());
460    }
461
462    #[tokio::test]
463    async fn at_rebinds_cwd_and_shares_backend() {
464        let repo = git_repo(ScriptedRunner::new());
465        let moved = repo.at("/repo/sub");
466        assert_eq!(moved.cwd(), Path::new("/repo/sub"));
467        assert_eq!(moved.root(), Path::new("/repo"));
468        assert_eq!(moved.kind(), BackendKind::Git);
469    }
470
471    // --- dispatch: jj backend (hermetic) -----------------------------------
472
473    #[tokio::test]
474    async fn jj_kind_and_escape_hatches_reflect_backend() {
475        let repo = jj_repo(ScriptedRunner::new());
476        assert_eq!(repo.kind(), BackendKind::Jj);
477        assert!(repo.jj().is_some() && repo.git().is_none());
478    }
479
480    #[tokio::test]
481    async fn jj_current_branch_reads_bookmark() {
482        let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
483        assert_eq!(
484            repo.current_branch().await.unwrap().as_deref(),
485            Some("main")
486        );
487    }
488
489    #[tokio::test]
490    async fn jj_local_branches_maps_bookmark_list() {
491        let repo = jj_repo(ScriptedRunner::new().on(
492            ["bookmark", "list"],
493            Reply::ok("main: chg cmt desc\nfeat: c2 m2 d2\n"),
494        ));
495        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
496    }
497
498    #[tokio::test]
499    async fn jj_branch_exists_scans_bookmarks() {
500        let repo = jj_repo(
501            ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
502        );
503        assert!(repo.branch_exists("main").await.unwrap());
504        let repo2 = jj_repo(
505            ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
506        );
507        assert!(!repo2.branch_exists("missing").await.unwrap());
508    }
509
510    #[tokio::test]
511    async fn jj_has_uncommitted_changes_reads_empty_flag() {
512        // CHANGE_TEMPLATE row: change_id \t commit_id \t empty \t description
513        let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
514        assert!(dirty.has_uncommitted_changes().await.unwrap());
515        let clean = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\ttrue\t\n")));
516        assert!(!clean.has_uncommitted_changes().await.unwrap());
517    }
518
519    #[tokio::test]
520    async fn jj_changed_files_maps_diff_summary() {
521        let repo = jj_repo(
522            ScriptedRunner::new().on(["diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
523        );
524        let changes = repo.changed_files().await.unwrap();
525        assert_eq!(changes.len(), 3);
526        assert_eq!(changes[0].kind, ChangeKind::Modified);
527        assert_eq!(changes[1].kind, ChangeKind::Added);
528        assert_eq!(changes[2].kind, ChangeKind::Deleted);
529        assert!(changes.iter().all(|c| c.old_path.is_none()));
530    }
531
532    #[tokio::test]
533    async fn jj_rename_branch_builds_bookmark_rename() {
534        use processkit::RecordingRunner;
535        let rec = RecordingRunner::replying(Reply::ok(""));
536        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
537        repo.rename_branch("old", "new").await.unwrap();
538        assert_eq!(
539            rec.only_call().args_str(),
540            ["bookmark", "rename", "old", "new"]
541        );
542    }
543}