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 cwd-bound [`Repo::git_at`] / [`Repo::jj_at`] handles (or the raw
15//! [`Repo::git`] / [`Repo::jj`] escape hatches). Some operations are deliberately
16//! *not* on the common surface because the backends model them too differently to
17//! unify without lying: a full `merge` (jj composes `new` + `squash` + bookmark
18//! moves), operation rollback (jj's `op restore` has no faithful git analogue),
19//! and range/revset-scoped queries (`commit_count`, diff stats over a range —
20//! git's `a..b` and jj's revsets aren't interchangeable). Reach those through the
21//! bound handles.
22//!
23//! ```no_run
24//! use vcs_core::Repo;
25//! # fn run() -> vcs_core::Result<()> {
26//! let repo = Repo::open(".")?;
27//! # let _ = repo.kind();
28//! # Ok(()) }
29//! ```
30//!
31//! The handle is generic over the [`ProcessRunner`] so tests can inject a fake;
32//! [`Repo::open`] uses the real job-backed runner.
33
34use std::path::{Path, PathBuf};
35use std::sync::Arc;
36
37use processkit::{JobRunner, ProcessRunner};
38use vcs_git::{Git, GitAt};
39use vcs_jj::{Jj, JjAt};
40
41mod dto;
42mod error;
43mod git_backend;
44mod jj_backend;
45
46pub use dto::{
47    BackendKind, ChangeKind, CreateOutcome, DiffStat, FileChange, OperationState, WorktreeInfo,
48};
49pub use error::{Error, Result};
50
51// Re-export the underlying typed clients so a consumer depending only on
52// `vcs-core` can still reach raw, tool-specific operations — and their types
53// (`GitApi`, `JjApi`, `WorktreeAdd`, `JjFileset`, …) — without adding `vcs-git`
54// / `vcs-jj` as separate dependencies. [`Repo::git`] / [`Repo::jj`] hand out
55// borrows of these clients; the consumer decides, per call, whether to go
56// through the facade or straight to the tool.
57pub use vcs_git;
58pub use vcs_jj;
59
60/// The result of [`detect`]: which backend, and the repository root it was found
61/// at.
62#[derive(Debug, Clone, PartialEq, Eq)]
63#[non_exhaustive]
64pub struct Located {
65    /// The detected backend.
66    pub kind: BackendKind,
67    /// The directory holding `.git`/`.jj` — the worktree root.
68    pub root: PathBuf,
69}
70
71/// Walk up from `start` to the filesystem root looking for a repository. A `.jj`
72/// directory wins over `.git` (colocated repos are driven through jj); `.git` may
73/// be a directory or a gitlink file (a linked worktree/submodule). Pure
74/// filesystem probing — no subprocess.
75///
76/// `start` is walked exactly as given via [`Path::parent`], so pass an **absolute**
77/// path to search ancestors — a relative path like `"."` has no ancestor chain
78/// and only its own directory is checked. ([`Repo::open`] absolutises for you.)
79pub fn detect(start: &Path) -> Option<Located> {
80    let mut current = Some(start);
81    while let Some(dir) = current {
82        if dir.join(".jj").is_dir() {
83            return Some(Located {
84                kind: BackendKind::Jj,
85                root: dir.to_path_buf(),
86            });
87        }
88        if dir.join(".git").exists() {
89            return Some(Located {
90                kind: BackendKind::Git,
91                root: dir.to_path_buf(),
92            });
93        }
94        current = dir.parent();
95    }
96    None
97}
98
99/// The per-tool client behind a [`Repo`]. Shared via `Arc` so [`Repo::at`] can
100/// re-anchor the cwd cheaply without rebuilding the client.
101enum Backend<R: ProcessRunner> {
102    Git(Arc<Git<R>>),
103    Jj(Arc<Jj<R>>),
104}
105
106impl<R: ProcessRunner> Backend<R> {
107    fn shared(&self) -> Self {
108        match self {
109            Backend::Git(g) => Backend::Git(Arc::clone(g)),
110            Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
111        }
112    }
113}
114
115/// A cwd-bound, backend-agnostic VCS handle. Operations run against the bound
116/// directory ([`cwd`](Repo::cwd)); use [`at`](Repo::at) to get a sibling handle
117/// bound elsewhere.
118pub struct Repo<R: ProcessRunner = JobRunner> {
119    root: PathBuf,
120    cwd: PathBuf,
121    backend: Backend<R>,
122}
123
124impl Repo<JobRunner> {
125    /// Detect the repository at or above `dir` and open a handle bound to `dir`,
126    /// using the real job-backed runner. Errors with
127    /// [`Error::NotARepository`] when no `.git`/`.jj` is found.
128    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
129        // Absolutise first: `detect` walks parents, and a relative path like "."
130        // has no real ancestor chain (`Path::new(".").parent()` is `""`, then
131        // `None`), so a relative input would never find a repo above the cwd.
132        let dir = std::path::absolute(dir.as_ref())?;
133        let located = detect(&dir).ok_or_else(|| Error::NotARepository(dir.clone()))?;
134        let backend = match located.kind {
135            BackendKind::Git => Backend::Git(Arc::new(Git::new())),
136            BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
137        };
138        Ok(Repo {
139            root: located.root,
140            cwd: dir,
141            backend,
142        })
143    }
144}
145
146impl<R: ProcessRunner> Repo<R> {
147    /// Build a git-backed handle from an explicit client — for a custom runner
148    /// (e.g. a test seam) or a pre-configured [`Git`].
149    pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
150        Repo {
151            root: root.into(),
152            cwd: cwd.into(),
153            backend: Backend::Git(Arc::new(client)),
154        }
155    }
156
157    /// Build a jj-backed handle from an explicit client.
158    pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
159        Repo {
160            root: root.into(),
161            cwd: cwd.into(),
162            backend: Backend::Jj(Arc::new(client)),
163        }
164    }
165
166    /// Which backend drives this handle.
167    pub fn kind(&self) -> BackendKind {
168        match &self.backend {
169            Backend::Git(_) => BackendKind::Git,
170            Backend::Jj(_) => BackendKind::Jj,
171        }
172    }
173
174    /// The repository root detected at open time.
175    pub fn root(&self) -> &Path {
176        &self.root
177    }
178
179    /// The directory operations run against.
180    pub fn cwd(&self) -> &Path {
181        &self.cwd
182    }
183
184    /// A sibling handle bound to `dir`, sharing this handle's client and root.
185    pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
186        Repo {
187            root: self.root.clone(),
188            cwd: dir.into(),
189            backend: self.backend.shared(),
190        }
191    }
192
193    /// The underlying [`Git`] client, or `None` when jj-backed — an escape hatch
194    /// to git-only operations not on the common surface.
195    pub fn git(&self) -> Option<&Git<R>> {
196        match &self.backend {
197            Backend::Git(g) => Some(g.as_ref()),
198            Backend::Jj(_) => None,
199        }
200    }
201
202    /// The underlying [`Jj`] client, or `None` when git-backed.
203    pub fn jj(&self) -> Option<&Jj<R>> {
204        match &self.backend {
205            Backend::Jj(j) => Some(j.as_ref()),
206            Backend::Git(_) => None,
207        }
208    }
209
210    /// The git client bound to this handle's [`cwd`](Repo::cwd) — a [`GitAt`] whose
211    /// methods omit the `dir` argument — or `None` when jj-backed. The dir-free
212    /// counterpart of [`git`](Repo::git): `repo.git_at()?.merge_continue().await?`.
213    ///
214    /// The returned view borrows `self`. To work in another worktree, **bind the
215    /// re-anchored handle first** (the view can't outlive a temporary
216    /// [`at`](Repo::at)):
217    ///
218    /// ```no_run
219    /// # async fn f(repo: vcs_core::Repo, wt: &std::path::Path) -> vcs_core::Result<()> {
220    /// let wt = repo.at(wt);          // owns the re-anchored handle
221    /// let git = wt.git_at().unwrap();
222    /// git.fetch().await?;
223    /// # Ok(()) }
224    /// ```
225    pub fn git_at(&self) -> Option<GitAt<'_, R>> {
226        match &self.backend {
227            Backend::Git(g) => Some(g.at(&self.cwd)),
228            Backend::Jj(_) => None,
229        }
230    }
231
232    /// The jj client bound to this handle's [`cwd`](Repo::cwd) — a [`JjAt`] whose
233    /// methods omit the `dir` argument — or `None` when git-backed. The dir-free
234    /// counterpart of [`jj`](Repo::jj). For another workspace, bind the re-anchored
235    /// handle first (`let ws = repo.at(path); ws.jj_at()…`) — see [`git_at`](Repo::git_at).
236    pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
237        match &self.backend {
238            Backend::Jj(j) => Some(j.at(&self.cwd)),
239            Backend::Git(_) => None,
240        }
241    }
242
243    /// The current branch (git) or bookmark (jj); `None` when detached / no
244    /// bookmark on the working copy.
245    pub async fn current_branch(&self) -> Result<Option<String>> {
246        match &self.backend {
247            Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
248            Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
249        }
250    }
251
252    /// The trunk branch/bookmark. Resolution order: the backend's own notion
253    /// (git's `origin/HEAD`, jj's `trunk()` revset), then a fallback to a local
254    /// `main`, then `master`; `None` when none of those resolve.
255    pub async fn trunk(&self) -> Result<Option<String>> {
256        let native = match &self.backend {
257            Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
258            Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
259        };
260        if native.is_some() {
261            return Ok(native);
262        }
263        for candidate in ["main", "master"] {
264            if self.branch_exists(candidate).await? {
265                return Ok(Some(candidate.to_string()));
266            }
267        }
268        Ok(None)
269    }
270
271    /// Local branch (git) / bookmark (jj) names.
272    pub async fn local_branches(&self) -> Result<Vec<String>> {
273        match &self.backend {
274            Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
275            Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
276        }
277    }
278
279    /// Whether a local branch/bookmark named `name` exists.
280    pub async fn branch_exists(&self, name: &str) -> Result<bool> {
281        match &self.backend {
282            Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
283            Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
284        }
285    }
286
287    /// Whether the working copy has uncommitted changes (git: a non-empty
288    /// `status`; jj: a non-empty working-copy change `@`).
289    pub async fn has_uncommitted_changes(&self) -> Result<bool> {
290        match &self.backend {
291            Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
292            Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
293        }
294    }
295
296    /// Delete a local branch (git) / bookmark (jj). `force` applies to git only
297    /// (`branch -D` vs `-d`); jj has no force and ignores it.
298    pub async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
299        match &self.backend {
300            Backend::Git(g) => git_backend::delete_branch(g, &self.cwd, name, force).await,
301            Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, name).await,
302        }
303    }
304
305    /// Rename a local branch (git) / bookmark (jj).
306    pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
307        match &self.backend {
308            Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
309            Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
310        }
311    }
312
313    /// The working-copy changes (git `status` / jj `diff -r @ --summary`).
314    pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
315        match &self.backend {
316            Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
317            Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
318        }
319    }
320
321    /// Aggregate insertion/deletion counts for the working copy.
322    ///
323    /// Backend nuance: git counts the working tree against `HEAD` (`git diff`,
324    /// which **excludes untracked files**), while jj counts the `@` change against
325    /// its parent (which **includes** newly-added files). So on git a brand-new
326    /// file shows in [`changed_files`](Self::changed_files) but not here, whereas
327    /// on jj it shows in both.
328    pub async fn diff_stat(&self) -> Result<DiffStat> {
329        match &self.backend {
330            Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
331            Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
332        }
333    }
334
335    /// Commit exactly `paths` with `message` (git `commit --only`, jj
336    /// `commit <filesets>`). Paths are repo-relative.
337    pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
338        match &self.backend {
339            Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
340            Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
341        }
342    }
343
344    /// Fetch from the default remote (git `fetch` / jj `git fetch`).
345    pub async fn fetch(&self) -> Result<()> {
346        match &self.backend {
347            Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
348            Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
349        }
350    }
351
352    /// Fetch a single branch/bookmark from `origin` into its remote-tracking ref
353    /// (git `fetch_remote_branch` / jj `git fetch -b`). Transient network failures
354    /// are retried by the underlying client.
355    pub async fn fetch_remote_branch(&self, branch: &str) -> Result<()> {
356        match &self.backend {
357            Backend::Git(g) => git_backend::fetch_remote_branch(g, &self.cwd, branch).await,
358            Backend::Jj(j) => jj_backend::fetch_remote_branch(j, &self.cwd, branch).await,
359        }
360    }
361
362    /// Switch the working copy to `reference` (git `checkout` / jj `edit`).
363    pub async fn checkout(&self, reference: &str) -> Result<()> {
364        match &self.backend {
365            Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
366            Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
367        }
368    }
369
370    /// Rebase the current work onto `onto` (git `rebase` / jj `rebase -d`). The
371    /// `onto` is a branch/bookmark name or revision the backend understands.
372    pub async fn rebase(&self, onto: &str) -> Result<()> {
373        match &self.backend {
374            Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
375            Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
376        }
377    }
378
379    /// Whether the working copy is mid-operation or conflicted — see
380    /// [`OperationState`]. Lets a caller decide between abort/continue without
381    /// knowing the backend's model. Note the asymmetry: git reports `Merge`/
382    /// `Rebase` (a git conflict *is* that paused state — the conflict itself
383    /// surfaces on the failed op via [`Error::is_conflict`]), while jj has no
384    /// paused op and reports `Conflict` directly.
385    pub async fn in_progress_state(&self) -> Result<OperationState> {
386        match &self.backend {
387            Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
388            Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
389        }
390    }
391
392    /// List attached worktrees (git) / workspaces (jj).
393    pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
394        match &self.backend {
395            Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
396            Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
397        }
398    }
399
400    /// Create a worktree/workspace at `path` on a **new** `branch` based on
401    /// `base`. Always [`CreateOutcome::Plain`]; a copy-on-write strategy stays in
402    /// the consumer.
403    ///
404    /// `branch` must not already exist. The jj path is two steps (`workspace add`
405    /// then `bookmark create`) and is not atomic: if the bookmark step fails, the
406    /// freshly-added workspace is left in place for the caller to clean up. A
407    /// consumer needing resume-existing or rollback semantics should drive the
408    /// underlying client via [`jj`](Repo::jj) / [`git`](Repo::git).
409    pub async fn create_worktree(
410        &self,
411        path: &Path,
412        branch: &str,
413        base: &str,
414    ) -> Result<CreateOutcome> {
415        match &self.backend {
416            Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
417            Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
418        }
419    }
420
421    /// Remove the worktree/workspace at `path`. For jj this resolves the
422    /// workspace name by matching `path`, deletes the directory, then forgets it.
423    pub async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
424        match &self.backend {
425            Backend::Git(g) => git_backend::remove_worktree(g, &self.cwd, path, force).await,
426            Backend::Jj(j) => jj_backend::remove_worktree(j, &self.cwd, path, force).await,
427        }
428    }
429
430    /// **Synchronous** worktree cleanup for a context that cannot `.await` —
431    /// chiefly a `Drop` guard. Force-removes the worktree at `path` (git:
432    /// `worktree remove --force`; jj: resolve the workspace name by `path`, delete
433    /// the directory, then `workspace forget`). Best-effort and short-lived: it
434    /// shells out directly (no job-containment); a jj `path` that matches no
435    /// workspace is a no-op (`Ok`).
436    pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
437        match &self.backend {
438            Backend::Git(_) => {
439                vcs_git::blocking::worktree_remove(&self.cwd, path, true).map_err(Error::Io)
440            }
441            Backend::Jj(_) => {
442                match vcs_jj::blocking::workspace_name_for_path(&self.cwd, path) {
443                    Some(name) => {
444                        // Delete the on-disk dir first (jj `forget` leaves it), then
445                        // drop jj's record of the workspace.
446                        let _ = std::fs::remove_dir_all(path);
447                        vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
448                    }
449                    None => Ok(()),
450                }
451            }
452        }
453    }
454}
455
456/// The backend-agnostic common surface of [`Repo`], as a trait — so a consumer can
457/// hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` and code against the operations
458/// without naming the [`ProcessRunner`] generic or wrapping `Repo` themselves.
459///
460/// Every method mirrors the like-named inherent method on [`Repo`]; the trait adds
461/// nothing but the abstraction boundary. Tool-specific operations stay off it (see
462/// the crate docs) — reach those through the concrete [`Repo`] and its bound
463/// handles. For hermetic tests, build a `Repo` over a fake runner with
464/// [`Repo::from_git`] / [`Repo::from_jj`] rather than mocking this trait.
465#[async_trait::async_trait]
466pub trait VcsRepo: Send + Sync {
467    /// Which backend drives this handle.
468    fn kind(&self) -> BackendKind;
469    /// The repository root detected at open time.
470    fn root(&self) -> &Path;
471    /// The directory operations run against.
472    fn cwd(&self) -> &Path;
473
474    /// See [`Repo::current_branch`].
475    async fn current_branch(&self) -> Result<Option<String>>;
476    /// See [`Repo::trunk`].
477    async fn trunk(&self) -> Result<Option<String>>;
478    /// See [`Repo::local_branches`].
479    async fn local_branches(&self) -> Result<Vec<String>>;
480    /// See [`Repo::branch_exists`].
481    async fn branch_exists(&self, name: &str) -> Result<bool>;
482    /// See [`Repo::has_uncommitted_changes`].
483    async fn has_uncommitted_changes(&self) -> Result<bool>;
484    /// See [`Repo::delete_branch`].
485    async fn delete_branch(&self, name: &str, force: bool) -> Result<()>;
486    /// See [`Repo::rename_branch`].
487    async fn rename_branch(&self, old: &str, new: &str) -> Result<()>;
488    /// See [`Repo::changed_files`].
489    async fn changed_files(&self) -> Result<Vec<FileChange>>;
490    /// See [`Repo::diff_stat`].
491    async fn diff_stat(&self) -> Result<DiffStat>;
492    /// See [`Repo::commit_paths`].
493    async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()>;
494    /// See [`Repo::fetch`].
495    async fn fetch(&self) -> Result<()>;
496    /// See [`Repo::fetch_remote_branch`].
497    async fn fetch_remote_branch(&self, branch: &str) -> Result<()>;
498    /// See [`Repo::checkout`].
499    async fn checkout(&self, reference: &str) -> Result<()>;
500    /// See [`Repo::rebase`].
501    async fn rebase(&self, onto: &str) -> Result<()>;
502    /// See [`Repo::in_progress_state`].
503    async fn in_progress_state(&self) -> Result<OperationState>;
504    /// See [`Repo::list_worktrees`].
505    async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>>;
506    /// See [`Repo::create_worktree`].
507    async fn create_worktree(&self, path: &Path, branch: &str, base: &str)
508    -> Result<CreateOutcome>;
509    /// See [`Repo::remove_worktree`].
510    async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()>;
511    /// See [`Repo::cleanup_worktree_blocking`].
512    fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()>;
513}
514
515// Delegates to the inherent methods, which method resolution prefers — so these
516// bodies dispatch through `Repo`'s real implementations, not back into the trait.
517#[async_trait::async_trait]
518impl<R: ProcessRunner> VcsRepo for Repo<R> {
519    fn kind(&self) -> BackendKind {
520        self.kind()
521    }
522    fn root(&self) -> &Path {
523        self.root()
524    }
525    fn cwd(&self) -> &Path {
526        self.cwd()
527    }
528    async fn current_branch(&self) -> Result<Option<String>> {
529        self.current_branch().await
530    }
531    async fn trunk(&self) -> Result<Option<String>> {
532        self.trunk().await
533    }
534    async fn local_branches(&self) -> Result<Vec<String>> {
535        self.local_branches().await
536    }
537    async fn branch_exists(&self, name: &str) -> Result<bool> {
538        self.branch_exists(name).await
539    }
540    async fn has_uncommitted_changes(&self) -> Result<bool> {
541        self.has_uncommitted_changes().await
542    }
543    async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
544        self.delete_branch(name, force).await
545    }
546    async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
547        self.rename_branch(old, new).await
548    }
549    async fn changed_files(&self) -> Result<Vec<FileChange>> {
550        self.changed_files().await
551    }
552    async fn diff_stat(&self) -> Result<DiffStat> {
553        self.diff_stat().await
554    }
555    async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
556        self.commit_paths(paths, message).await
557    }
558    async fn fetch(&self) -> Result<()> {
559        self.fetch().await
560    }
561    async fn fetch_remote_branch(&self, branch: &str) -> Result<()> {
562        self.fetch_remote_branch(branch).await
563    }
564    async fn checkout(&self, reference: &str) -> Result<()> {
565        self.checkout(reference).await
566    }
567    async fn rebase(&self, onto: &str) -> Result<()> {
568        self.rebase(onto).await
569    }
570    async fn in_progress_state(&self) -> Result<OperationState> {
571        self.in_progress_state().await
572    }
573    async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
574        self.list_worktrees().await
575    }
576    async fn create_worktree(
577        &self,
578        path: &Path,
579        branch: &str,
580        base: &str,
581    ) -> Result<CreateOutcome> {
582        self.create_worktree(path, branch, base).await
583    }
584    async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
585        self.remove_worktree(path, force).await
586    }
587    fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
588        self.cleanup_worktree_blocking(path)
589    }
590}
591
592#[cfg(test)]
593mod tests {
594    use super::*;
595    use processkit::{Reply, ScriptedRunner};
596
597    // --- detect ------------------------------------------------------------
598
599    /// A unique temp directory, removed on drop.
600    struct TempDir(PathBuf);
601    impl TempDir {
602        fn new(tag: &str) -> Self {
603            // Unique without a temp crate: process id + a monotonic counter, so
604            // parallel tests never collide.
605            use std::sync::atomic::{AtomicU64, Ordering};
606            static COUNTER: AtomicU64 = AtomicU64::new(0);
607            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
608            let dir =
609                std::env::temp_dir().join(format!("vcs-core-{tag}-{}-{n}", std::process::id()));
610            std::fs::create_dir_all(&dir).expect("create temp dir");
611            TempDir(dir)
612        }
613        fn path(&self) -> &Path {
614            &self.0
615        }
616    }
617    impl Drop for TempDir {
618        fn drop(&mut self) {
619            let _ = std::fs::remove_dir_all(&self.0);
620        }
621    }
622
623    #[test]
624    fn detect_finds_git_and_jj_and_prefers_jj() {
625        let tmp = TempDir::new("detect");
626        let root = tmp.path();
627
628        // Plain git repo.
629        std::fs::create_dir_all(root.join(".git")).unwrap();
630        let located = detect(root).expect("git detected");
631        assert_eq!(located.kind, BackendKind::Git);
632        assert_eq!(located.root, root);
633
634        // Colocated: adding .jj makes jj win.
635        std::fs::create_dir_all(root.join(".jj")).unwrap();
636        assert_eq!(detect(root).unwrap().kind, BackendKind::Jj);
637    }
638
639    #[test]
640    fn detect_walks_up_to_ancestor() {
641        let tmp = TempDir::new("walkup");
642        let root = tmp.path();
643        std::fs::create_dir_all(root.join(".git")).unwrap();
644        let nested = root.join("a").join("b");
645        std::fs::create_dir_all(&nested).unwrap();
646        let located = detect(&nested).expect("found via ancestor walk");
647        assert_eq!(located.kind, BackendKind::Git);
648        assert_eq!(located.root, root);
649    }
650
651    #[test]
652    fn detect_returns_none_outside_repo() {
653        let tmp = TempDir::new("norepo");
654        assert!(detect(tmp.path()).is_none());
655    }
656
657    // --- dispatch (hermetic, ScriptedRunner-backed) ------------------------
658
659    fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
660        Repo::from_git("/repo", "/repo", Git::with_runner(runner))
661    }
662
663    fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
664        Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
665    }
666
667    #[tokio::test]
668    async fn kind_and_escape_hatches_reflect_backend() {
669        let repo = git_repo(ScriptedRunner::new());
670        assert_eq!(repo.kind(), BackendKind::Git);
671        assert!(repo.git().is_some());
672        assert!(repo.jj().is_none());
673    }
674
675    // The cwd-bound views mirror the backend, and `at` re-binds them to another
676    // directory without a separate client.
677    #[tokio::test]
678    async fn bound_views_reflect_backend_and_cwd() {
679        let git = git_repo(ScriptedRunner::new());
680        assert!(git.git_at().is_some());
681        assert!(git.jj_at().is_none());
682        // A sibling handle bound elsewhere yields a view rooted at that dir.
683        assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
684
685        let jj = jj_repo(ScriptedRunner::new());
686        assert!(jj.jj_at().is_some());
687        assert!(jj.git_at().is_none());
688    }
689
690    #[tokio::test]
691    async fn current_branch_maps_detached_head_to_none() {
692        let named = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
693        assert_eq!(
694            named.current_branch().await.unwrap().as_deref(),
695            Some("main")
696        );
697        let detached = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("HEAD\n")));
698        assert!(detached.current_branch().await.unwrap().is_none());
699    }
700
701    #[tokio::test]
702    async fn changed_files_maps_git_status() {
703        let repo = git_repo(ScriptedRunner::new().on(
704            ["status"],
705            Reply::ok(" M a.rs\0?? b.rs\0R  new.rs\0old.rs\0"),
706        ));
707        let changes = repo.changed_files().await.unwrap();
708        assert_eq!(changes.len(), 3);
709        assert_eq!(changes[0].kind, ChangeKind::Modified);
710        assert_eq!(changes[1].kind, ChangeKind::Added);
711        assert_eq!(changes[2].kind, ChangeKind::Renamed);
712        assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
713    }
714
715    #[tokio::test]
716    async fn local_branches_maps_git_branch_output() {
717        let repo = git_repo(ScriptedRunner::new().on(["branch"], Reply::ok("* main\n  feat\n")));
718        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
719    }
720
721    #[tokio::test]
722    async fn branch_exists_reads_show_ref_exit() {
723        let yes = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
724        assert!(yes.branch_exists("main").await.unwrap());
725        let no = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
726        assert!(!no.branch_exists("nope").await.unwrap());
727    }
728
729    #[tokio::test]
730    async fn has_uncommitted_changes_reflects_status() {
731        let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
732        assert!(dirty.has_uncommitted_changes().await.unwrap());
733        let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
734        assert!(!clean.has_uncommitted_changes().await.unwrap());
735    }
736
737    #[tokio::test]
738    async fn at_rebinds_cwd_and_shares_backend() {
739        let repo = git_repo(ScriptedRunner::new());
740        let moved = repo.at("/repo/sub");
741        assert_eq!(moved.cwd(), Path::new("/repo/sub"));
742        assert_eq!(moved.root(), Path::new("/repo"));
743        assert_eq!(moved.kind(), BackendKind::Git);
744    }
745
746    // --- dispatch: jj backend (hermetic) -----------------------------------
747
748    #[tokio::test]
749    async fn jj_kind_and_escape_hatches_reflect_backend() {
750        let repo = jj_repo(ScriptedRunner::new());
751        assert_eq!(repo.kind(), BackendKind::Jj);
752        assert!(repo.jj().is_some() && repo.git().is_none());
753    }
754
755    #[tokio::test]
756    async fn jj_current_branch_reads_bookmark() {
757        let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
758        assert_eq!(
759            repo.current_branch().await.unwrap().as_deref(),
760            Some("main")
761        );
762    }
763
764    #[tokio::test]
765    async fn jj_local_branches_maps_bookmark_list() {
766        let repo = jj_repo(ScriptedRunner::new().on(
767            ["bookmark", "list"],
768            Reply::ok("main: chg cmt desc\nfeat: c2 m2 d2\n"),
769        ));
770        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
771    }
772
773    #[tokio::test]
774    async fn jj_branch_exists_scans_bookmarks() {
775        let repo = jj_repo(
776            ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
777        );
778        assert!(repo.branch_exists("main").await.unwrap());
779        let repo2 = jj_repo(
780            ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
781        );
782        assert!(!repo2.branch_exists("missing").await.unwrap());
783    }
784
785    #[tokio::test]
786    async fn jj_has_uncommitted_changes_reads_empty_flag() {
787        // CHANGE_TEMPLATE row: change_id \t commit_id \t empty \t description
788        let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
789        assert!(dirty.has_uncommitted_changes().await.unwrap());
790        let clean = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\ttrue\t\n")));
791        assert!(!clean.has_uncommitted_changes().await.unwrap());
792    }
793
794    #[tokio::test]
795    async fn jj_changed_files_maps_diff_summary() {
796        let repo = jj_repo(
797            ScriptedRunner::new().on(["diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
798        );
799        let changes = repo.changed_files().await.unwrap();
800        assert_eq!(changes.len(), 3);
801        assert_eq!(changes[0].kind, ChangeKind::Modified);
802        assert_eq!(changes[1].kind, ChangeKind::Added);
803        assert_eq!(changes[2].kind, ChangeKind::Deleted);
804        assert!(changes.iter().all(|c| c.old_path.is_none()));
805    }
806
807    #[tokio::test]
808    async fn jj_rename_branch_builds_bookmark_rename() {
809        use processkit::RecordingRunner;
810        let rec = RecordingRunner::replying(Reply::ok(""));
811        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
812        repo.rename_branch("old", "new").await.unwrap();
813        assert_eq!(
814            rec.only_call().args_str(),
815            ["bookmark", "rename", "old", "new", "--color", "never"]
816        );
817    }
818
819    // The widened common surface dispatches `checkout` to each backend's verb:
820    // git `checkout`, jj `edit`.
821    #[tokio::test]
822    async fn checkout_dispatches_per_backend() {
823        use processkit::RecordingRunner;
824        let grec = RecordingRunner::replying(Reply::ok(""));
825        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
826            .checkout("feat")
827            .await
828            .unwrap();
829        assert_eq!(grec.only_call().args_str(), ["checkout", "feat"]);
830
831        let jrec = RecordingRunner::replying(Reply::ok(""));
832        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
833            .checkout("feat")
834            .await
835            .unwrap();
836        assert_eq!(
837            jrec.only_call().args_str(),
838            ["edit", "feat", "--color", "never"]
839        );
840    }
841
842    #[tokio::test]
843    async fn fetch_remote_branch_dispatches_per_backend() {
844        use processkit::RecordingRunner;
845        let grec = RecordingRunner::replying(Reply::ok(""));
846        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
847            .fetch_remote_branch("main")
848            .await
849            .unwrap();
850        assert!(
851            grec.only_call()
852                .args_str()
853                .starts_with(&["fetch".to_string()])
854        );
855
856        let jrec = RecordingRunner::replying(Reply::ok(""));
857        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
858            .fetch_remote_branch("main")
859            .await
860            .unwrap();
861        let args = jrec.only_call().args_str();
862        assert_eq!(&args[..2], &["git", "fetch"]);
863    }
864
865    // jj records conflicts on the change; the facade maps that to `Conflict`.
866    #[tokio::test]
867    async fn jj_in_progress_state_maps_conflict() {
868        let conflicted = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
869        assert_eq!(
870            conflicted.in_progress_state().await.unwrap(),
871            OperationState::Conflict
872        );
873        let clear = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
874        assert_eq!(
875            clear.in_progress_state().await.unwrap(),
876            OperationState::Clear
877        );
878    }
879
880    // `&dyn VcsRepo` must dispatch through the real inherent methods (a delegating
881    // body that recursed would stack-overflow here instead of returning).
882    #[tokio::test]
883    async fn vcs_repo_trait_object_dispatches() {
884        let repo = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
885        let dynamic: &dyn VcsRepo = &repo;
886        assert_eq!(dynamic.kind(), BackendKind::Git);
887        assert_eq!(
888            dynamic.current_branch().await.unwrap().as_deref(),
889            Some("main")
890        );
891    }
892
893    // When the backend has no native trunk (git `origin/HEAD` unset), the facade
894    // falls back to a local `main`, then `master`.
895    #[tokio::test]
896    async fn trunk_falls_back_to_main() {
897        let repo = git_repo(
898            ScriptedRunner::new()
899                .on(["symbolic-ref"], Reply::fail(1, "")) // origin/HEAD unset → None
900                .on(["show-ref"], Reply::ok("")), // branch_exists("main") → exit 0
901        );
902        assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
903    }
904
905    #[test]
906    fn error_classifiers_recognise_markers() {
907        let conflict = Error::Vcs(processkit::Error::Exit {
908            program: "git".into(),
909            code: 1,
910            stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
911            stderr: String::new(),
912        });
913        assert!(conflict.is_conflict());
914        assert!(!conflict.is_nothing_to_commit());
915        // A non-Vcs error classifies as none of them.
916        assert!(!Error::NotARepository("/x".into()).is_conflict());
917    }
918}