Skip to main content

vcs_core/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-core` — one backend-agnostic facade over [`vcs-git`](vcs_git) and
4//! [`vcs-jj`](vcs_jj).
5//!
6//! It [`detect`]s whether a directory is a git or a jj checkout, then dispatches
7//! the operations *both* tools share to whichever backend is present — returning
8//! backend-agnostic DTOs ([`RepoSnapshot`], [`FileChange`], [`MergeProbe`], …), so
9//! a caller codes against "the repository" instead of against `git` or `jj`. Async
10//! throughout, structured errors, and every subprocess inherits the underlying
11//! client's OS-**job** containment (via [`processkit`]) so no `git`/`jj` tree is
12//! ever orphaned.
13//!
14//! It is the **honest least-common-denominator**, not a god-object. The common
15//! surface carries only what unifies *without lying*; operations the backends
16//! model too differently are deliberately left on the bound per-tool handles
17//! rather than faked (see [below](#whats-deliberately-not-unified)). Reach for the
18//! facade when code must work on both backends; drop to the raw client the moment
19//! you need power only one of them offers.
20//!
21//! # Mental model
22//!
23//! The surface is three layers, narrowing from "which tool is this?" to "do the
24//! thing":
25//!
26//! - **[`detect`]** — walk up from a directory to the filesystem root for a
27//!   `.git`/`.jj` repo (jj wins when colocated — it's the tool driving the working
28//!   copy). Pure filesystem probing, no subprocess; yields a [`Located`]
29//!   ([`BackendKind`] + worktree root).
30//! - **[`Repo`]** — the cwd-bound facade handle, the thing you hold. Open one with
31//!   [`Repo::open`] (real job-backed runner) or build it over an explicit client
32//!   with [`Repo::from_git`] / [`Repo::from_jj`] (the test seam). Re-anchor it to
33//!   another directory cheaply with [`Repo::at`] — the backend is shared behind an
34//!   `Arc`, so threading work across worktrees never re-detects or rebuilds the
35//!   client. Inspect it with [`kind`](Repo::kind) / [`root`](Repo::root) /
36//!   [`cwd`](Repo::cwd).
37//! - **[`VcsRepo`]** — the same common surface as an object-safe trait, so a
38//!   consumer can hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` without naming the
39//!   [`ProcessRunner`] generic. Every method mirrors the like-named inherent method
40//!   on [`Repo`]; it adds nothing but the abstraction boundary.
41//!
42//! ## The common operations
43//!
44//! All on [`Repo`] (and [`VcsRepo`]), dir-free, dispatched per backend:
45//!
46//! - **Refs** — [`current_branch`](Repo::current_branch),
47//!   [`trunk`](Repo::trunk), [`local_branches`](Repo::local_branches),
48//!   [`branch_exists`](Repo::branch_exists),
49//!   [`delete_branch`](Repo::delete_branch),
50//!   [`rename_branch`](Repo::rename_branch) (branch on git, bookmark on jj).
51//! - **Status** — [`changed_files`](Repo::changed_files),
52//!   [`diff_stat`](Repo::diff_stat),
53//!   [`has_uncommitted_changes`](Repo::has_uncommitted_changes),
54//!   [`has_tracked_changes`](Repo::has_tracked_changes),
55//!   [`conflicted_files`](Repo::conflicted_files), and
56//!   [`snapshot`](Repo::snapshot) — a **batched** prompt/status-bar read of the
57//!   lot in one or two spawns.
58//! - **Mutations** — [`commit_paths`](Repo::commit_paths) (partial commit),
59//!   [`fetch`](Repo::fetch) / [`fetch_from`](Repo::fetch_from) /
60//!   [`fetch_remote_branch`](Repo::fetch_remote_branch) /
61//!   [`push`](Repo::push), [`checkout`](Repo::checkout),
62//!   [`rebase`](Repo::rebase).
63//! - **Merge & operation state** — [`try_merge`](Repo::try_merge) (a
64//!   trace-free conflict probe → [`MergeProbe`]),
65//!   [`in_progress_state`](Repo::in_progress_state) /
66//!   [`abort_in_progress`](Repo::abort_in_progress) /
67//!   [`continue_in_progress`](Repo::continue_in_progress) → [`OperationState`].
68//! - **Worktrees / workspaces** — [`list_worktrees`](Repo::list_worktrees),
69//!   [`create_worktree`](Repo::create_worktree),
70//!   [`remove_worktree`](Repo::remove_worktree), and the **synchronous**
71//!   [`cleanup_worktree_blocking`](Repo::cleanup_worktree_blocking) for a `Drop`
72//!   guard that cannot `.await`.
73//!
74//! Because the backends genuinely diverge in places, several common methods carry
75//! a documented asymmetry (e.g. `upstream`/`ahead`/`behind` are always `None` on
76//! jj; [`diff_stat`](Repo::diff_stat) excludes untracked files on git but not jj;
77//! [`in_progress_state`](Repo::in_progress_state) never returns `Conflict` on git).
78//! The method docs spell each one out — the facade unifies the *shape*, not away
79//! the truth.
80//!
81//! ## The escape hatches
82//!
83//! Tool-specific work reaches the underlying typed clients without adding
84//! `vcs-git`/`vcs-jj` as separate dependencies (both are re-exported):
85//! [`git_at`](Repo::git_at) / [`jj_at`](Repo::jj_at) hand out a cwd-bound view
86//! ([`GitAt`] / [`JjAt`], `dir` dropped); the raw
87//! [`git`](Repo::git) / [`jj`](Repo::jj) hand out a borrow of the client itself.
88//! Each returns `None` for the other backend.
89//!
90//! ## What's deliberately *not* unified
91//!
92//! Three families stay off the common surface because no honest single shape
93//! exists — reach them through the bound handles:
94//!
95//! - **Full `merge`** — jj composes `new` + `squash` + bookmark moves; git runs a
96//!   single command. Only the *conflict probe* unifies, as
97//!   [`try_merge`](Repo::try_merge).
98//! - **Operation rollback** — jj's `op restore` has no faithful git analogue; use
99//!   [`Jj::transaction`](vcs_jj::Jj::transaction) on the jj client.
100//! - **Range / revset queries** — commit counts and diff stats over a range: git's
101//!   `a..b` and jj's revsets aren't interchangeable, so neither is forced onto a
102//!   shared signature.
103//!
104//! # Recipes
105//!
106//! Open a repo and read a [`snapshot`](Repo::snapshot) for a one-line prompt:
107//!
108//! ```no_run
109//! use vcs_core::Repo;
110//! # async fn demo() -> vcs_core::Result<()> {
111//! let repo = Repo::open(".")?;            // detects git vs jj
112//! let s = repo.snapshot().await?;         // one or two spawns, not a call per field
113//! let branch = s.branch.as_deref().unwrap_or("(detached)");
114//! let head = s.head.as_deref().map(|h| &h[..7.min(h.len())]).unwrap_or("-");
115//! println!("{}@{head} {}", branch, if s.dirty { "*" } else { "" });
116//! # Ok(()) }
117//! ```
118//!
119//! Probe a merge for conflicts (trace-free), or spin up a worktree:
120//!
121//! ```no_run
122//! use std::path::Path;
123//! use vcs_core::{MergeProbe, Repo};
124//! # async fn demo(repo: &Repo) -> vcs_core::Result<()> {
125//! match repo.try_merge("feature").await? {
126//!     MergeProbe::Clean            => println!("merges cleanly"),
127//!     MergeProbe::Conflicts(paths) => println!("would conflict in {paths:?}"),
128//!     _                            => {} // #[non_exhaustive]
129//! }
130//! let wt = repo.create_worktree(Path::new("/tmp/feat"), "feature", "main").await?;
131//! # let _ = wt;
132//! # Ok(()) }
133//! ```
134//!
135//! # Testing
136//!
137//! There is **no mock feature** on the facade traits — the runner is the seam.
138//! Build a [`Repo`] over a fake [`ProcessRunner`] with [`Repo::from_git`] /
139//! [`Repo::from_jj`] (e.g. a [`ScriptedRunner`](processkit::ScriptedRunner)
140//! replying to canned argv), so the *real* per-backend dispatch, argv-building and
141//! parsing run against canned output — exactly what a mocked `VcsRepo` would skip.
142//! The cross-cutting patterns live in
143//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
144//!
145//! ```no_run
146//! use processkit::{Reply, ScriptedRunner};
147//! use vcs_core::{vcs_git::Git, Repo};
148//! # async fn demo() -> vcs_core::Result<()> {
149//! let runner = ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0"));
150//! let repo = Repo::from_git("/repo", "/repo", Git::with_runner(runner));
151//! assert!(repo.has_uncommitted_changes().await?);
152//! # Ok(()) }
153//! ```
154//!
155//! # In-depth guide
156//!
157//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
158//! from `docs/`. See the [`guide`] module, which walks every operation in depth
159//! and hosts the cross-cutting sub-guides: a [`cookbook`](guide::cookbook) of
160//! end-to-end flows, the [`process_model`](guide::process_model) (job containment,
161//! errors, cancellation), [`positioning`](guide::positioning) (facade-vs-raw-client
162//! and the three call shapes), and the [`stability`](guide::stability) contract.
163
164use std::path::{Path, PathBuf};
165use std::sync::Arc;
166
167use processkit::{JobRunner, ProcessRunner};
168use vcs_git::{Git, GitAt};
169use vcs_jj::{Jj, JjAt};
170
171mod dto;
172mod error;
173mod git_backend;
174mod jj_backend;
175
176pub use dto::{
177    BackendKind, ChangeKind, CreateOutcome, DiffStat, FileChange, MergeProbe, OperationState,
178    RepoSnapshot, WorktreeInfo,
179};
180pub use error::{Error, Result};
181
182// Re-export the underlying typed clients so a consumer depending only on
183// `vcs-core` can still reach raw, tool-specific operations — and their types
184// (`GitApi`, `JjApi`, `WorktreeAdd`, `JjFileset`, …) — without adding `vcs-git`
185// / `vcs-jj` as separate dependencies. [`Repo::git`] / [`Repo::jj`] hand out
186// borrows of these clients; the consumer decides, per call, whether to go
187// through the facade or straight to the tool.
188pub use vcs_git;
189pub use vcs_jj;
190// Re-exported under the `cancellation` feature so a `vcs-core`-only consumer can
191// name the token for a `default_cancel_on` client (built via `Git`/`Jj`, then
192// passed to `Repo::from_git`/`from_jj`) without a direct `processkit` dependency.
193#[cfg(feature = "cancellation")]
194#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
195pub use processkit::CancellationToken;
196
197/// The result of [`detect`]: which backend, and the repository root it was found
198/// at.
199#[derive(Debug, Clone, PartialEq, Eq)]
200#[non_exhaustive]
201pub struct Located {
202    /// The detected backend.
203    pub kind: BackendKind,
204    /// The directory holding `.git`/`.jj` — the worktree root.
205    pub root: PathBuf,
206}
207
208/// Walk up from `start` to the filesystem root looking for a repository. A `.jj`
209/// directory wins over `.git` (colocated repos are driven through jj); `.git` may
210/// be a directory or a gitlink file (a linked worktree/submodule). Pure
211/// filesystem probing — no subprocess.
212///
213/// `start` is walked exactly as given via [`Path::parent`], so pass an **absolute**
214/// path to search ancestors — a relative path like `"."` has no ancestor chain
215/// and only its own directory is checked. ([`Repo::open`] absolutises for you.)
216pub fn detect(start: &Path) -> Option<Located> {
217    let mut current = Some(start);
218    while let Some(dir) = current {
219        if dir.join(".jj").is_dir() {
220            return Some(Located {
221                kind: BackendKind::Jj,
222                root: dir.to_path_buf(),
223            });
224        }
225        if dir.join(".git").exists() {
226            return Some(Located {
227                kind: BackendKind::Git,
228                root: dir.to_path_buf(),
229            });
230        }
231        current = dir.parent();
232    }
233    None
234}
235
236/// The per-tool client behind a [`Repo`]. Shared via `Arc` so [`Repo::at`] can
237/// re-anchor the cwd cheaply without rebuilding the client.
238enum Backend<R: ProcessRunner> {
239    Git(Arc<Git<R>>),
240    Jj(Arc<Jj<R>>),
241}
242
243impl<R: ProcessRunner> Backend<R> {
244    fn shared(&self) -> Self {
245        match self {
246            Backend::Git(g) => Backend::Git(Arc::clone(g)),
247            Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
248        }
249    }
250}
251
252/// A cwd-bound, backend-agnostic VCS handle. Operations run against the bound
253/// directory ([`cwd`](Repo::cwd)); use [`at`](Repo::at) to get a sibling handle
254/// bound elsewhere.
255pub struct Repo<R: ProcessRunner = JobRunner> {
256    root: PathBuf,
257    cwd: PathBuf,
258    backend: Backend<R>,
259}
260
261impl Repo<JobRunner> {
262    /// Detect the repository at or above `dir` and open a handle bound to `dir`,
263    /// using the real job-backed runner. Errors with
264    /// [`Error::NotARepository`] when no `.git`/`.jj` is found.
265    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
266        // Absolutise first: `detect` walks parents, and a relative path like "."
267        // has no real ancestor chain (`Path::new(".").parent()` is `""`, then
268        // `None`), so a relative input would never find a repo above the cwd.
269        let dir = std::path::absolute(dir.as_ref())?;
270        let located = detect(&dir).ok_or_else(|| Error::NotARepository(dir.clone()))?;
271        let backend = match located.kind {
272            BackendKind::Git => Backend::Git(Arc::new(Git::new())),
273            BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
274        };
275        Ok(Repo {
276            root: located.root,
277            cwd: dir,
278            backend,
279        })
280    }
281}
282
283impl<R: ProcessRunner> Repo<R> {
284    /// Build a git-backed handle from an explicit client — for a custom runner
285    /// (e.g. a test seam) or a pre-configured [`Git`].
286    pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
287        Repo {
288            root: root.into(),
289            cwd: cwd.into(),
290            backend: Backend::Git(Arc::new(client)),
291        }
292    }
293
294    /// Build a jj-backed handle from an explicit client.
295    pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
296        Repo {
297            root: root.into(),
298            cwd: cwd.into(),
299            backend: Backend::Jj(Arc::new(client)),
300        }
301    }
302
303    /// Which backend drives this handle.
304    pub fn kind(&self) -> BackendKind {
305        match &self.backend {
306            Backend::Git(_) => BackendKind::Git,
307            Backend::Jj(_) => BackendKind::Jj,
308        }
309    }
310
311    /// The repository root detected at open time.
312    pub fn root(&self) -> &Path {
313        &self.root
314    }
315
316    /// The directory operations run against.
317    pub fn cwd(&self) -> &Path {
318        &self.cwd
319    }
320
321    /// A sibling handle bound to `dir`, sharing this handle's client and root.
322    pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
323        Repo {
324            root: self.root.clone(),
325            cwd: dir.into(),
326            backend: self.backend.shared(),
327        }
328    }
329
330    /// The underlying [`Git`] client, or `None` when jj-backed — an escape hatch
331    /// to git-only operations not on the common surface.
332    pub fn git(&self) -> Option<&Git<R>> {
333        match &self.backend {
334            Backend::Git(g) => Some(g.as_ref()),
335            Backend::Jj(_) => None,
336        }
337    }
338
339    /// The underlying [`Jj`] client, or `None` when git-backed.
340    pub fn jj(&self) -> Option<&Jj<R>> {
341        match &self.backend {
342            Backend::Jj(j) => Some(j.as_ref()),
343            Backend::Git(_) => None,
344        }
345    }
346
347    /// The git client bound to this handle's [`cwd`](Repo::cwd) — a [`GitAt`] whose
348    /// methods omit the `dir` argument — or `None` when jj-backed. The dir-free
349    /// counterpart of [`git`](Repo::git): `repo.git_at()?.merge_continue().await?`.
350    ///
351    /// The returned view borrows `self`. To work in another worktree, **bind the
352    /// re-anchored handle first** (the view can't outlive a temporary
353    /// [`at`](Repo::at)):
354    ///
355    /// ```no_run
356    /// # async fn f(repo: vcs_core::Repo, wt: &std::path::Path) -> vcs_core::Result<()> {
357    /// let wt = repo.at(wt);          // owns the re-anchored handle
358    /// let git = wt.git_at().unwrap();
359    /// git.fetch().await?;
360    /// # Ok(()) }
361    /// ```
362    pub fn git_at(&self) -> Option<GitAt<'_, R>> {
363        match &self.backend {
364            Backend::Git(g) => Some(g.at(&self.cwd)),
365            Backend::Jj(_) => None,
366        }
367    }
368
369    /// The jj client bound to this handle's [`cwd`](Repo::cwd) — a [`JjAt`] whose
370    /// methods omit the `dir` argument — or `None` when git-backed. The dir-free
371    /// counterpart of [`jj`](Repo::jj). For another workspace, bind the re-anchored
372    /// handle first (`let ws = repo.at(path); ws.jj_at()…`) — see [`git_at`](Repo::git_at).
373    pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
374        match &self.backend {
375            Backend::Jj(j) => Some(j.at(&self.cwd)),
376            Backend::Git(_) => None,
377        }
378    }
379
380    /// The current branch (git) or bookmark (jj); `None` when detached / no
381    /// bookmark on the working copy.
382    pub async fn current_branch(&self) -> Result<Option<String>> {
383        match &self.backend {
384            Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
385            Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
386        }
387    }
388
389    /// The trunk branch/bookmark. Resolution order: the backend's own notion
390    /// (git's `origin/HEAD`, jj's `trunk()` revset), then a fallback to a local
391    /// `main`, then `master`; `None` when none of those resolve.
392    pub async fn trunk(&self) -> Result<Option<String>> {
393        let native = match &self.backend {
394            Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
395            Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
396        };
397        if native.is_some() {
398            return Ok(native);
399        }
400        for candidate in ["main", "master"] {
401            if self.branch_exists(candidate).await? {
402                return Ok(Some(candidate.to_string()));
403            }
404        }
405        Ok(None)
406    }
407
408    /// Local branch (git) / bookmark (jj) names.
409    pub async fn local_branches(&self) -> Result<Vec<String>> {
410        match &self.backend {
411            Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
412            Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
413        }
414    }
415
416    /// Whether a local branch/bookmark named `name` exists.
417    pub async fn branch_exists(&self, name: &str) -> Result<bool> {
418        match &self.backend {
419            Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
420            Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
421        }
422    }
423
424    /// Whether the working copy has uncommitted changes (git: a non-empty
425    /// `status`; jj: a non-empty working-copy change `@`).
426    pub async fn has_uncommitted_changes(&self) -> Result<bool> {
427        match &self.backend {
428            Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
429            Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
430        }
431    }
432
433    /// Whether the working copy has uncommitted changes to *tracked* files.
434    ///
435    /// Backend nuance: git ignores untracked files here
436    /// (`status --untracked-files=no`); jj auto-tracks new files, so there is no
437    /// untracked concept and this equals
438    /// [`has_uncommitted_changes`](Self::has_uncommitted_changes).
439    pub async fn has_tracked_changes(&self) -> Result<bool> {
440        match &self.backend {
441            Backend::Git(g) => git_backend::has_tracked_changes(g, &self.cwd).await,
442            Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
443        }
444    }
445
446    /// Paths with unresolved merge conflicts in the working copy, repo-relative
447    /// with `/` separators (git `diff --diff-filter=U` / jj `resolve --list -r @`).
448    /// Empty when there are none.
449    pub async fn conflicted_files(&self) -> Result<Vec<String>> {
450        match &self.backend {
451            Backend::Git(g) => git_backend::conflicted_files(g, &self.cwd).await,
452            Backend::Jj(j) => jj_backend::conflicted_files(j, &self.cwd).await,
453        }
454    }
455
456    /// Delete a local branch (git) / bookmark (jj). `force` applies to git only
457    /// (`branch -D` vs `-d`); jj has no force and ignores it.
458    pub async fn delete_branch(&self, name: &str, force: bool) -> Result<()> {
459        match &self.backend {
460            Backend::Git(g) => git_backend::delete_branch(g, &self.cwd, name, force).await,
461            Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, name).await,
462        }
463    }
464
465    /// Rename a local branch (git) / bookmark (jj).
466    pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
467        match &self.backend {
468            Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
469            Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
470        }
471    }
472
473    /// The working-copy changes (git `status` / jj `diff -r @ --summary`).
474    pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
475        match &self.backend {
476            Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
477            Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
478        }
479    }
480
481    /// Aggregate insertion/deletion counts for the working copy.
482    ///
483    /// Backend nuance: git counts the working tree against `HEAD` (`git diff`,
484    /// which **excludes untracked files**), while jj counts the `@` change against
485    /// its parent (which **includes** newly-added files). So on git a brand-new
486    /// file shows in [`changed_files`](Self::changed_files) but not here, whereas
487    /// on jj it shows in both. On an unborn git repo (no commits yet) the count is
488    /// taken against the empty tree, so a pre-first-commit working tree stats
489    /// instead of erroring.
490    pub async fn diff_stat(&self) -> Result<DiffStat> {
491        match &self.backend {
492            Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
493            Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
494        }
495    }
496
497    /// A batched [`RepoSnapshot`] of the common repo state — branch, upstream,
498    /// ahead/behind, dirtiness, change count, and operation state — in **one or
499    /// two** spawns instead of a call per field (git: `status --porcelain=v2
500    /// --branch` + the in-progress probe; jj: one `log -r @` template + a change
501    /// count). Built for prompt/status-bar/TUI refreshes. Note the asymmetry:
502    /// `upstream`/`ahead`/`behind` are always `None` on jj.
503    pub async fn snapshot(&self) -> Result<RepoSnapshot> {
504        match &self.backend {
505            Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
506            Backend::Jj(j) => jj_backend::snapshot(j, &self.cwd).await,
507        }
508    }
509
510    /// Commit exactly `paths` with `message` (git `commit --only`, jj
511    /// `commit <filesets>`). Paths are repo-relative. `paths` must be non-empty:
512    /// an empty set is refused up front, because the backends would diverge
513    /// dangerously — git errors out, while jj's `commit` with no filesets would
514    /// silently commit the **entire** working copy.
515    pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
516        if paths.is_empty() {
517            return Err(Error::Io(std::io::Error::new(
518                std::io::ErrorKind::InvalidInput,
519                "commit_paths requires at least one path: an empty set would error \
520                 on git but commit the entire working copy on jj",
521            )));
522        }
523        match &self.backend {
524            Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
525            Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
526        }
527    }
528
529    /// Fetch from the default remote (git `fetch` / jj `git fetch`).
530    pub async fn fetch(&self) -> Result<()> {
531        match &self.backend {
532            Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
533            Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
534        }
535    }
536
537    /// Fetch from a *named* remote (git `fetch <remote>` / jj
538    /// `git fetch --remote <remote>`). Transient network failures are retried by
539    /// the underlying client.
540    pub async fn fetch_from(&self, remote: &str) -> Result<()> {
541        match &self.backend {
542            Backend::Git(g) => git_backend::fetch_from(g, &self.cwd, remote).await,
543            Backend::Jj(j) => jj_backend::fetch_from(j, &self.cwd, remote).await,
544        }
545    }
546
547    /// Fetch a single branch/bookmark from `origin` into its remote-tracking ref
548    /// (git `fetch_remote_branch` / jj `git fetch -b`). Transient network failures
549    /// are retried by the underlying client.
550    pub async fn fetch_remote_branch(&self, branch: &str) -> Result<()> {
551        match &self.backend {
552            Backend::Git(g) => git_backend::fetch_remote_branch(g, &self.cwd, branch).await,
553            Backend::Jj(j) => jj_backend::fetch_remote_branch(j, &self.cwd, branch).await,
554        }
555    }
556
557    /// Push `branch` to `origin` (git `push -u origin <branch>` / jj
558    /// `git push -b <branch>`).
559    ///
560    /// The branch (jj: bookmark) must already exist locally. The two backends
561    /// honestly differ in what "push" means: git pushes the *ref* and records
562    /// the upstream (`-u`; idempotent on repeat pushes), while jj pushes the
563    /// *bookmark's state* — including deleting the remote branch if the
564    /// bookmark was deleted locally. Renamed refspecs (`local:remote`) and
565    /// non-`origin` remotes are git-only concepts; use the
566    /// [`git()`](Repo::git) escape hatch ([`vcs_git::GitPush`]) for those.
567    pub async fn push(&self, branch: &str) -> Result<()> {
568        match &self.backend {
569            Backend::Git(g) => git_backend::push(g, &self.cwd, branch).await,
570            Backend::Jj(j) => jj_backend::push(j, &self.cwd, branch).await,
571        }
572    }
573
574    /// Switch the working copy to `reference` (git `checkout` / jj `edit`).
575    pub async fn checkout(&self, reference: &str) -> Result<()> {
576        match &self.backend {
577            Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
578            Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
579        }
580    }
581
582    /// Rebase the current work onto `onto` (git `rebase` / jj `rebase -d`). The
583    /// `onto` is a branch/bookmark name or revision the backend understands.
584    pub async fn rebase(&self, onto: &str) -> Result<()> {
585        match &self.backend {
586            Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
587            Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
588        }
589    }
590
591    /// Probe whether merging `source` into the current work would conflict,
592    /// **without leaving any trace**: the probe is rolled back before returning
593    /// (git: `merge --no-commit --no-ff` then `merge --abort`; jj: a merge
594    /// change probed and undone via `op restore`).
595    ///
596    /// Preconditions/behaviour:
597    /// - git: requires a clean-enough working tree — a dirty-tree refusal
598    ///   propagates as a plain error, not as [`MergeProbe::Conflicts`].
599    /// - A failing rollback **propagates as an error** rather than returning a
600    ///   result that misdescribes the on-disk state.
601    /// - **Cancellation caveat:** the rollback runs on the same client, so if the
602    ///   client carries a `default_cancel_on` token (the `cancellation` feature)
603    ///   that fires during the probe, the rollback command is cancelled too and the
604    ///   probe change may be left behind (`Error::Cancelled` surfaces). Re-probe and
605    ///   reset with an un-cancelled client if you need a clean tree.
606    pub async fn try_merge(&self, source: &str) -> Result<MergeProbe> {
607        match &self.backend {
608            Backend::Git(g) => git_backend::try_merge(g, &self.cwd, source).await,
609            Backend::Jj(j) => jj_backend::try_merge(j, &self.cwd, source).await,
610        }
611    }
612
613    /// Abort the in-progress operation, if any (git: `merge --abort` /
614    /// `rebase --abort`; jj: a no-op — there are no paused operations, roll back
615    /// explicitly via `Jj::transaction` / `op_restore`). Returns the fresh
616    /// *post-call* [`OperationState`]; `Clear` when nothing was (or remains) in
617    /// progress.
618    pub async fn abort_in_progress(&self) -> Result<OperationState> {
619        match &self.backend {
620            Backend::Git(g) => git_backend::abort_in_progress(g, &self.cwd).await,
621            Backend::Jj(j) => jj_backend::abort_in_progress(j, &self.cwd).await,
622        }
623    }
624
625    /// Continue the in-progress operation after conflict resolution (git:
626    /// `commit --no-edit` for a merge / `rebase --continue`; jj: a no-op —
627    /// resolving the files *is* the continuation). Returns the fresh *post-call*
628    /// [`OperationState`]:
629    /// - `Conflict` when unresolved paths still block continuing (also on git —
630    ///   unlike [`in_progress_state`](Self::in_progress_state), this method
631    ///   *does* report `Conflict` for git), or when a continued rebase stops on
632    ///   the next patch's conflict.
633    /// - `Clear` when the operation finished.
634    pub async fn continue_in_progress(&self) -> Result<OperationState> {
635        match &self.backend {
636            Backend::Git(g) => git_backend::continue_in_progress(g, &self.cwd).await,
637            Backend::Jj(j) => jj_backend::continue_in_progress(j, &self.cwd).await,
638        }
639    }
640
641    /// Whether the working copy is mid-operation or conflicted — see
642    /// [`OperationState`]. Lets a caller decide between abort/continue without
643    /// knowing the backend's model. Note the asymmetry: *this method* reports
644    /// `Merge`/`Rebase` (never `Conflict`) on git — a git conflict *is* that
645    /// paused state, and the conflict itself surfaces on the failed op via
646    /// [`Error::is_merge_conflict`] (or as `Conflict` from
647    /// [`continue_in_progress`](Self::continue_in_progress)) — while jj has no
648    /// paused op and reports `Conflict` directly.
649    pub async fn in_progress_state(&self) -> Result<OperationState> {
650        match &self.backend {
651            Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
652            Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
653        }
654    }
655
656    /// List attached worktrees (git) / workspaces (jj).
657    pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
658        match &self.backend {
659            Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
660            Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
661        }
662    }
663
664    /// Create a worktree/workspace at `path` on a **new** `branch` based on
665    /// `base`. Always [`CreateOutcome::Plain`]; a copy-on-write strategy stays in
666    /// the consumer.
667    ///
668    /// `branch` must not already exist. The jj path is two steps (`workspace add`
669    /// then `bookmark create`) and is not atomic: if the bookmark step fails, the
670    /// freshly-added workspace is left in place for the caller to clean up. A
671    /// consumer needing resume-existing or rollback semantics should drive the
672    /// underlying client via [`jj`](Repo::jj) / [`git`](Repo::git).
673    pub async fn create_worktree(
674        &self,
675        path: &Path,
676        branch: &str,
677        base: &str,
678    ) -> Result<CreateOutcome> {
679        match &self.backend {
680            Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
681            Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
682        }
683    }
684
685    /// Remove the worktree/workspace at `path`. For jj this resolves the
686    /// workspace name by matching `path`, deletes the directory, then forgets it;
687    /// a `path` that matches no attached jj workspace returns
688    /// [`Error::WorktreeNotFound`]. (For the best-effort, never-erroring variant,
689    /// see [`cleanup_worktree_blocking`](Self::cleanup_worktree_blocking).)
690    pub async fn remove_worktree(&self, path: &Path, force: bool) -> Result<()> {
691        match &self.backend {
692            Backend::Git(g) => git_backend::remove_worktree(g, &self.cwd, path, force).await,
693            Backend::Jj(j) => jj_backend::remove_worktree(j, &self.cwd, path, force).await,
694        }
695    }
696
697    /// **Synchronous** worktree cleanup for a context that cannot `.await` —
698    /// chiefly a `Drop` guard. Force-removes the worktree at `path` (git:
699    /// `worktree remove --force`; jj: resolve the workspace name by `path`, delete
700    /// the directory, then `workspace forget`). Best-effort and short-lived: it
701    /// shells out directly (no job-containment); a jj `path` that matches no
702    /// workspace is a no-op (`Ok`).
703    pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
704        match &self.backend {
705            Backend::Git(_) => {
706                vcs_git::blocking::worktree_remove(&self.cwd, path, true).map_err(Error::Io)
707            }
708            Backend::Jj(_) => {
709                match vcs_jj::blocking::workspace_name_for_path(&self.cwd, path) {
710                    Some(name) => {
711                        // Delete the on-disk dir first (jj `forget` leaves it), then
712                        // drop jj's record of the workspace.
713                        let _ = std::fs::remove_dir_all(path);
714                        vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
715                    }
716                    None => Ok(()),
717                }
718            }
719        }
720    }
721}
722
723/// Generate a facade trait from one signature table: the `#[async_trait]` trait
724/// declaration *and* the delegating `impl … for $Ty<R>`, so the two can never drift
725/// out of sync (a hazard when each is hand-maintained). Every generated body is a
726/// trivial delegation to the like-named inherent method — which method resolution
727/// prefers, so this never recurses; the real backend-`match` dispatch stays
728/// hand-written on the inherent `impl`. `async` methods doc-link to their inherent
729/// twin; `sync` methods carry an explicit doc string (their docs aren't uniform).
730///
731/// A near-identical copy lives in `vcs-forge`; the two are deliberately not shared
732/// (separate crates, ~40-line macro — duplication beats a new dependency).
733///
734/// Signatures only: each entry is a bare `&self` (or sync) method — no method-level
735/// generics, no `&mut self`, no default bodies (a new method shaped that way needs a
736/// grammar tweak, not just a table row).
737///
738/// No `mockall::automock`: a Wave-S spike proved it can't process a trait whose
739/// signatures come from `macro_rules!`. Captured `$_:ty` fragments reach `automock`
740/// as opaque nonterminal token groups; its `syn` parser rejects them ("unsupported
741/// type in this position"), whereas `#[async_trait]` tolerates them. So the facade
742/// traits stay test-seam-tested (build a handle over a fake runner — see the trait
743/// docs), which is also what their docs already recommend over mocking.
744macro_rules! facade_trait {
745    (
746        $(#[doc = $tdoc:expr])*
747        trait $Trait:ident for $Ty:ident;
748        sync {
749            $( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
750        }
751        async {
752            $( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
753        }
754    ) => {
755        $(#[doc = $tdoc])*
756        #[async_trait::async_trait]
757        pub trait $Trait: Send + Sync {
758            $(
759                #[doc = $sdoc]
760                fn $sn(&self, $($sa: $sat),*) -> $sr;
761            )*
762            $(
763                #[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
764                async fn $an(&self, $($aa: $aat),*) -> $ar;
765            )*
766        }
767
768        // Delegates to the inherent methods, which method resolution prefers — so
769        // these bodies dispatch through the concrete type's real implementations,
770        // not back into the trait.
771        #[async_trait::async_trait]
772        impl<R: ProcessRunner> $Trait for $Ty<R> {
773            $(
774                fn $sn(&self, $($sa: $sat),*) -> $sr {
775                    self.$sn($($sa),*)
776                }
777            )*
778            $(
779                async fn $an(&self, $($aa: $aat),*) -> $ar {
780                    self.$an($($aa),*).await
781                }
782            )*
783        }
784    };
785}
786
787facade_trait! {
788    /// The backend-agnostic common surface of [`Repo`], as a trait — so a consumer can
789    /// hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` and code against the operations
790    /// without naming the [`ProcessRunner`] generic or wrapping `Repo` themselves.
791    ///
792    /// Every method mirrors the like-named inherent method on [`Repo`]; the trait adds
793    /// nothing but the abstraction boundary. Tool-specific operations stay off it (see
794    /// the crate docs) — reach those through the concrete [`Repo`] and its bound
795    /// handles. For hermetic tests, build a `Repo` over a fake runner with
796    /// [`Repo::from_git`] / [`Repo::from_jj`] rather than mocking this trait.
797    trait VcsRepo for Repo;
798    sync {
799        #[doc = "Which backend drives this handle."]
800        fn kind() -> BackendKind;
801        #[doc = "The repository root detected at open time."]
802        fn root() -> &Path;
803        #[doc = "The directory operations run against."]
804        fn cwd() -> &Path;
805        #[doc = "See [`Repo::cleanup_worktree_blocking`]."]
806        fn cleanup_worktree_blocking(path: &Path) -> Result<()>;
807    }
808    async {
809        fn current_branch() -> Result<Option<String>>;
810        fn trunk() -> Result<Option<String>>;
811        fn local_branches() -> Result<Vec<String>>;
812        fn branch_exists(name: &str) -> Result<bool>;
813        fn has_uncommitted_changes() -> Result<bool>;
814        fn has_tracked_changes() -> Result<bool>;
815        fn conflicted_files() -> Result<Vec<String>>;
816        fn delete_branch(name: &str, force: bool) -> Result<()>;
817        fn rename_branch(old: &str, new: &str) -> Result<()>;
818        fn changed_files() -> Result<Vec<FileChange>>;
819        fn diff_stat() -> Result<DiffStat>;
820        fn snapshot() -> Result<RepoSnapshot>;
821        fn commit_paths(paths: &[String], message: &str) -> Result<()>;
822        fn fetch() -> Result<()>;
823        fn fetch_from(remote: &str) -> Result<()>;
824        fn fetch_remote_branch(branch: &str) -> Result<()>;
825        fn push(branch: &str) -> Result<()>;
826        fn checkout(reference: &str) -> Result<()>;
827        fn rebase(onto: &str) -> Result<()>;
828        fn try_merge(source: &str) -> Result<MergeProbe>;
829        fn abort_in_progress() -> Result<OperationState>;
830        fn continue_in_progress() -> Result<OperationState>;
831        fn in_progress_state() -> Result<OperationState>;
832        fn list_worktrees() -> Result<Vec<WorktreeInfo>>;
833        fn create_worktree(path: &Path, branch: &str, base: &str) -> Result<CreateOutcome>;
834        fn remove_worktree(path: &Path, force: bool) -> Result<()>;
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841    use processkit::{Reply, ScriptedRunner};
842
843    // --- detect ------------------------------------------------------------
844
845    /// A unique temp directory, removed on drop.
846    struct TempDir(PathBuf);
847    impl TempDir {
848        fn new(tag: &str) -> Self {
849            // Unique without a temp crate: process id + a monotonic counter, so
850            // parallel tests never collide. Kept short — a long prefix can tip a
851            // nested jj `op_store` path over Windows' MAX_PATH.
852            use std::sync::atomic::{AtomicU64, Ordering};
853            static COUNTER: AtomicU64 = AtomicU64::new(0);
854            let n = COUNTER.fetch_add(1, Ordering::Relaxed);
855            let dir =
856                std::env::temp_dir().join(format!("vcs-core-{tag}-{}-{n}", std::process::id()));
857            std::fs::create_dir_all(&dir).expect("create temp dir");
858            TempDir(dir)
859        }
860        fn path(&self) -> &Path {
861            &self.0
862        }
863    }
864    impl Drop for TempDir {
865        fn drop(&mut self) {
866            let _ = std::fs::remove_dir_all(&self.0);
867        }
868    }
869
870    #[test]
871    fn detect_finds_git_and_jj_and_prefers_jj() {
872        let tmp = TempDir::new("detect");
873        let root = tmp.path();
874
875        // Plain git repo.
876        std::fs::create_dir_all(root.join(".git")).unwrap();
877        let located = detect(root).expect("git detected");
878        assert_eq!(located.kind, BackendKind::Git);
879        assert_eq!(located.root, root);
880
881        // Colocated: adding .jj makes jj win.
882        std::fs::create_dir_all(root.join(".jj")).unwrap();
883        assert_eq!(detect(root).unwrap().kind, BackendKind::Jj);
884    }
885
886    #[test]
887    fn detect_walks_up_to_ancestor() {
888        let tmp = TempDir::new("walkup");
889        let root = tmp.path();
890        std::fs::create_dir_all(root.join(".git")).unwrap();
891        let nested = root.join("a").join("b");
892        std::fs::create_dir_all(&nested).unwrap();
893        let located = detect(&nested).expect("found via ancestor walk");
894        assert_eq!(located.kind, BackendKind::Git);
895        assert_eq!(located.root, root);
896    }
897
898    #[test]
899    fn detect_returns_none_outside_repo() {
900        let tmp = TempDir::new("norepo");
901        assert!(detect(tmp.path()).is_none());
902    }
903
904    // --- dispatch (hermetic, ScriptedRunner-backed) ------------------------
905
906    fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
907        Repo::from_git("/repo", "/repo", Git::with_runner(runner))
908    }
909
910    fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
911        Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
912    }
913
914    // --- snapshot ----------------------------------------------------------
915
916    // git: one porcelain-v2 call + a git-dir probe → a combined RepoSnapshot.
917    #[tokio::test]
918    async fn git_snapshot_combines_v2_status_and_op_state() {
919        let v2 = concat!(
920            "# branch.oid abc123\0",
921            "# branch.head main\0",
922            "# branch.upstream origin/main\0",
923            "# branch.ab +2 -0\0",
924            "1 .M N... 100644 100644 100644 1 2 a.rs\0",
925            "? new.txt\0",
926        );
927        // An empty git dir → no MERGE_HEAD / rebase dir → Clear.
928        let gitdir = TempDir::new("snap-git");
929        let repo = git_repo(
930            ScriptedRunner::new()
931                .on(["status", "--porcelain=v2"], Reply::ok(v2))
932                .on(
933                    ["rev-parse", "--git-dir"],
934                    Reply::ok(gitdir.path().to_str().unwrap()),
935                ),
936        );
937        let s = repo.snapshot().await.unwrap();
938        assert_eq!(s.branch.as_deref(), Some("main"));
939        assert_eq!(s.upstream.as_deref(), Some("origin/main"));
940        assert_eq!((s.ahead, s.behind), (Some(2), Some(0)));
941        assert!(s.dirty);
942        assert_eq!(s.change_count, 2, "1 tracked + 1 untracked");
943        assert!(!s.conflicted);
944        assert_eq!(s.operation, OperationState::Clear);
945    }
946
947    // jj: one template row + a status count; a conflicted @ maps to Conflict; no
948    // git-style upstream/ahead/behind.
949    #[tokio::test]
950    async fn jj_snapshot_from_template_with_change_count() {
951        let repo = jj_repo(
952            ScriptedRunner::new()
953                .on(["log"], Reply::ok("deadbeef\tmain\t0\t1\n")) // empty=0 dirty, conflict=1
954                .on(["diff"], Reply::ok("M a.rs\nA b.rs\n")), // status -r @ --summary → 2
955        );
956        let s = repo.snapshot().await.unwrap();
957        assert_eq!(s.head.as_deref(), Some("deadbeef"));
958        assert_eq!(s.branch.as_deref(), Some("main"));
959        assert!(s.dirty);
960        assert_eq!(s.change_count, 2);
961        assert!(s.conflicted);
962        assert_eq!(s.operation, OperationState::Conflict);
963        assert_eq!(s.upstream, None);
964        assert_eq!((s.ahead, s.behind), (None, None));
965    }
966
967    // jj: a clean `@` (empty=1) skips the change-count spawn entirely — the test
968    // scripts NO `diff` rule, so calling `status` would error.
969    #[tokio::test]
970    async fn jj_snapshot_clean_skips_change_count() {
971        let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("c0ffee\t\t1\t0\n")));
972        let s = repo.snapshot().await.unwrap();
973        assert_eq!(s.head.as_deref(), Some("c0ffee"));
974        assert_eq!(s.branch, None, "no bookmark");
975        assert!(!s.dirty);
976        assert_eq!(s.change_count, 0);
977        assert!(!s.conflicted);
978        assert_eq!(s.operation, OperationState::Clear);
979    }
980
981    // jj `list_worktrees` resolves each workspace's root via the batched
982    // `workspace_roots` fan-out (one `workspace root --name <n>` per `workspace
983    // list` row), then builds a `WorktreeInfo` per workspace. Hermetic: scripts the
984    // template rows + the per-name root replies — the backend glue that the
985    // `#[ignore]` integration tests otherwise cover only with a real `jj`.
986    #[tokio::test]
987    async fn jj_list_worktrees_batches_root_lookups() {
988        let repo = jj_repo(
989            ScriptedRunner::new()
990                .on(
991                    ["workspace", "list"],
992                    Reply::ok("default\tc0ffee\tmain\nws1\tdecaf0\t\n"),
993                )
994                .on(
995                    ["workspace", "root", "--name", "default"],
996                    Reply::ok("/repo\n"),
997                )
998                .on(
999                    ["workspace", "root", "--name", "ws1"],
1000                    Reply::ok("/repo/ws1\n"),
1001                ),
1002        );
1003        let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1004        assert_eq!(worktrees.len(), 2);
1005        assert_eq!(worktrees[0].path, Path::new("/repo"));
1006        assert_eq!(worktrees[0].branch.as_deref(), Some("main"));
1007        assert_eq!(worktrees[1].path, Path::new("/repo/ws1"));
1008        assert_eq!(worktrees[1].branch, None);
1009    }
1010
1011    // A workspace whose `workspace root` lookup errors is skipped (no useful path),
1012    // mirroring the old sequential loop — the batch maps that slot to `Err`.
1013    #[tokio::test]
1014    async fn jj_list_worktrees_skips_unresolvable_root() {
1015        let repo = jj_repo(
1016            ScriptedRunner::new()
1017                .on(
1018                    ["workspace", "list"],
1019                    Reply::ok("default\tc0ffee\tmain\ngone\tdecaf0\t\n"),
1020                )
1021                .on(
1022                    ["workspace", "root", "--name", "default"],
1023                    Reply::ok("/repo\n"),
1024                )
1025                .on(
1026                    ["workspace", "root", "--name", "gone"],
1027                    Reply::fail(1, "Error: No such workspace"),
1028                ),
1029        );
1030        let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1031        assert_eq!(worktrees.len(), 1, "the unresolvable workspace is skipped");
1032        assert_eq!(worktrees[0].path, Path::new("/repo"));
1033    }
1034
1035    #[tokio::test]
1036    async fn kind_and_escape_hatches_reflect_backend() {
1037        let repo = git_repo(ScriptedRunner::new());
1038        assert_eq!(repo.kind(), BackendKind::Git);
1039        assert!(repo.git().is_some());
1040        assert!(repo.jj().is_none());
1041    }
1042
1043    // The cwd-bound views mirror the backend, and `at` re-binds them to another
1044    // directory without a separate client.
1045    #[tokio::test]
1046    async fn bound_views_reflect_backend_and_cwd() {
1047        let git = git_repo(ScriptedRunner::new());
1048        assert!(git.git_at().is_some());
1049        assert!(git.jj_at().is_none());
1050        // A sibling handle bound elsewhere yields a view rooted at that dir.
1051        assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
1052
1053        let jj = jj_repo(ScriptedRunner::new());
1054        assert!(jj.jj_at().is_some());
1055        assert!(jj.git_at().is_none());
1056    }
1057
1058    #[tokio::test]
1059    async fn current_branch_maps_detached_head_to_none() {
1060        let named = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("main\n")));
1061        assert_eq!(
1062            named.current_branch().await.unwrap().as_deref(),
1063            Some("main")
1064        );
1065        let detached = git_repo(ScriptedRunner::new().on(["rev-parse"], Reply::ok("HEAD\n")));
1066        assert!(detached.current_branch().await.unwrap().is_none());
1067    }
1068
1069    #[tokio::test]
1070    async fn changed_files_maps_git_status() {
1071        let repo = git_repo(ScriptedRunner::new().on(
1072            ["status"],
1073            Reply::ok(" M a.rs\0?? b.rs\0R  new.rs\0old.rs\0"),
1074        ));
1075        let changes = repo.changed_files().await.unwrap();
1076        assert_eq!(changes.len(), 3);
1077        assert_eq!(changes[0].kind, ChangeKind::Modified);
1078        assert_eq!(changes[1].kind, ChangeKind::Added);
1079        assert_eq!(changes[2].kind, ChangeKind::Renamed);
1080        assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
1081    }
1082
1083    #[tokio::test]
1084    async fn local_branches_maps_git_branch_output() {
1085        let repo = git_repo(ScriptedRunner::new().on(["branch"], Reply::ok("* main\n  feat\n")));
1086        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
1087    }
1088
1089    #[tokio::test]
1090    async fn branch_exists_reads_show_ref_exit() {
1091        let yes = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::ok("")));
1092        assert!(yes.branch_exists("main").await.unwrap());
1093        let no = git_repo(ScriptedRunner::new().on(["show-ref"], Reply::fail(1, "")));
1094        assert!(!no.branch_exists("nope").await.unwrap());
1095    }
1096
1097    #[tokio::test]
1098    async fn has_uncommitted_changes_reflects_status() {
1099        let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
1100        assert!(dirty.has_uncommitted_changes().await.unwrap());
1101        let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
1102        assert!(!clean.has_uncommitted_changes().await.unwrap());
1103    }
1104
1105    #[tokio::test]
1106    async fn at_rebinds_cwd_and_shares_backend() {
1107        let repo = git_repo(ScriptedRunner::new());
1108        let moved = repo.at("/repo/sub");
1109        assert_eq!(moved.cwd(), Path::new("/repo/sub"));
1110        assert_eq!(moved.root(), Path::new("/repo"));
1111        assert_eq!(moved.kind(), BackendKind::Git);
1112    }
1113
1114    // --- dispatch: jj backend (hermetic) -----------------------------------
1115
1116    #[tokio::test]
1117    async fn jj_kind_and_escape_hatches_reflect_backend() {
1118        let repo = jj_repo(ScriptedRunner::new());
1119        assert_eq!(repo.kind(), BackendKind::Jj);
1120        assert!(repo.jj().is_some() && repo.git().is_none());
1121    }
1122
1123    #[tokio::test]
1124    async fn jj_current_branch_reads_bookmark() {
1125        let repo = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
1126        assert_eq!(
1127            repo.current_branch().await.unwrap().as_deref(),
1128            Some("main")
1129        );
1130    }
1131
1132    #[tokio::test]
1133    async fn jj_local_branches_maps_bookmark_list() {
1134        let repo = jj_repo(ScriptedRunner::new().on(
1135            ["bookmark", "list"],
1136            Reply::ok("main: chg cmt desc\nfeat: c2 m2 d2\n"),
1137        ));
1138        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
1139    }
1140
1141    #[tokio::test]
1142    async fn jj_branch_exists_scans_bookmarks() {
1143        let repo = jj_repo(
1144            ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
1145        );
1146        assert!(repo.branch_exists("main").await.unwrap());
1147        let repo2 = jj_repo(
1148            ScriptedRunner::new().on(["bookmark", "list"], Reply::ok("main: chg cmt desc\n")),
1149        );
1150        assert!(!repo2.branch_exists("missing").await.unwrap());
1151    }
1152
1153    #[tokio::test]
1154    async fn jj_has_uncommitted_changes_reads_empty_flag() {
1155        // CHANGE_TEMPLATE row: change_id \t commit_id \t empty \t description
1156        let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
1157        assert!(dirty.has_uncommitted_changes().await.unwrap());
1158        let clean = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\ttrue\t\n")));
1159        assert!(!clean.has_uncommitted_changes().await.unwrap());
1160    }
1161
1162    #[tokio::test]
1163    async fn jj_changed_files_maps_diff_summary() {
1164        let repo = jj_repo(
1165            ScriptedRunner::new().on(["diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
1166        );
1167        let changes = repo.changed_files().await.unwrap();
1168        assert_eq!(changes.len(), 3);
1169        assert_eq!(changes[0].kind, ChangeKind::Modified);
1170        assert_eq!(changes[1].kind, ChangeKind::Added);
1171        assert_eq!(changes[2].kind, ChangeKind::Deleted);
1172        assert!(changes.iter().all(|c| c.old_path.is_none()));
1173    }
1174
1175    // jj DOES supply the rename's original path (its `{old => new}` summary
1176    // form) — `old_path` is populated on both backends, as the DTO documents.
1177    #[tokio::test]
1178    async fn jj_changed_files_populates_rename_old_path() {
1179        let repo =
1180            jj_repo(ScriptedRunner::new().on(["diff"], Reply::ok("R src/{old.rs => new.rs}\n")));
1181        let changes = repo.changed_files().await.unwrap();
1182        assert_eq!(changes.len(), 1);
1183        assert_eq!(changes[0].kind, ChangeKind::Renamed);
1184        assert_eq!(changes[0].path, "src/new.rs");
1185        assert_eq!(changes[0].old_path.as_deref(), Some("src/old.rs"));
1186    }
1187
1188    // `commit_paths(&[])` is refused up front on BOTH backends: the runners have
1189    // no rules, so reaching the CLI would error differently — the guard must trip
1190    // first (on jj an empty fileset would otherwise commit the whole working
1191    // copy; on git it would exit 128).
1192    #[tokio::test]
1193    async fn commit_paths_refuses_an_empty_path_set() {
1194        for repo in [
1195            git_repo(ScriptedRunner::new()),
1196            jj_repo(ScriptedRunner::new()),
1197        ] {
1198            let err = repo
1199                .commit_paths(&[], "msg")
1200                .await
1201                .expect_err("empty paths must be refused");
1202            assert!(
1203                err.to_string().contains("at least one path"),
1204                "unexpected error: {err}"
1205            );
1206        }
1207    }
1208
1209    #[tokio::test]
1210    async fn jj_rename_branch_builds_bookmark_rename() {
1211        use processkit::RecordingRunner;
1212        let rec = RecordingRunner::replying(Reply::ok(""));
1213        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
1214        repo.rename_branch("old", "new").await.unwrap();
1215        assert_eq!(
1216            rec.only_call().args_str(),
1217            ["bookmark", "rename", "old", "new", "--color", "never"]
1218        );
1219    }
1220
1221    // The widened common surface dispatches `checkout` to each backend's verb:
1222    // git `checkout`, jj `edit`.
1223    #[tokio::test]
1224    async fn checkout_dispatches_per_backend() {
1225        use processkit::RecordingRunner;
1226        let grec = RecordingRunner::replying(Reply::ok(""));
1227        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1228            .checkout("feat")
1229            .await
1230            .unwrap();
1231        assert_eq!(grec.only_call().args_str(), ["checkout", "feat"]);
1232
1233        let jrec = RecordingRunner::replying(Reply::ok(""));
1234        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1235            .checkout("feat")
1236            .await
1237            .unwrap();
1238        assert_eq!(
1239            jrec.only_call().args_str(),
1240            ["edit", "feat", "--color", "never"]
1241        );
1242    }
1243
1244    #[tokio::test]
1245    async fn fetch_remote_branch_dispatches_per_backend() {
1246        use processkit::RecordingRunner;
1247        let grec = RecordingRunner::replying(Reply::ok(""));
1248        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1249            .fetch_remote_branch("main")
1250            .await
1251            .unwrap();
1252        assert!(
1253            grec.only_call()
1254                .args_str()
1255                .starts_with(&["fetch".to_string()])
1256        );
1257
1258        let jrec = RecordingRunner::replying(Reply::ok(""));
1259        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1260            .fetch_remote_branch("main")
1261            .await
1262            .unwrap();
1263        let args = jrec.only_call().args_str();
1264        assert_eq!(&args[..2], &["git", "fetch"]);
1265    }
1266
1267    // The facade push is the honest LCD: git pushes the ref with `-u origin`,
1268    // jj pushes the bookmark's state with `-b`. Argv pinned on both backends.
1269    #[tokio::test]
1270    async fn push_dispatches_per_backend() {
1271        use processkit::RecordingRunner;
1272        let grec = RecordingRunner::replying(Reply::ok(""));
1273        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1274            .push("feature")
1275            .await
1276            .unwrap();
1277        assert_eq!(
1278            grec.only_call().args_str(),
1279            ["push", "-u", "origin", "feature"]
1280        );
1281
1282        let jrec = RecordingRunner::replying(Reply::ok(""));
1283        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1284            .push("feature")
1285            .await
1286            .unwrap();
1287        let args = jrec.only_call().args_str();
1288        assert_eq!(&args[..4], &["git", "push", "-b", "feature"]);
1289    }
1290
1291    // The two backends handle a flag-like branch per the documented guard
1292    // convention: git rejects it BEFORE spawning (the branch lands in GitPush's
1293    // bare-positional refspec slot, where `--force` would otherwise be parsed
1294    // as a flag); jj passes it verbatim in the `-b` flag-VALUE slot, where jj
1295    // reads it as a bookmark name and errors itself — no flag injection is
1296    // possible there, so no pre-spawn guard exists (same as rebase/fetch_from).
1297    #[tokio::test]
1298    async fn push_flag_like_branch_follows_guard_convention() {
1299        use processkit::RecordingRunner;
1300        let grec = RecordingRunner::replying(Reply::ok(""));
1301        let err = Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1302            .push("--force")
1303            .await
1304            .unwrap_err();
1305        assert!(
1306            matches!(err, Error::Vcs(processkit::Error::Spawn { .. })),
1307            "got: {err:?}"
1308        );
1309        assert_eq!(grec.calls().len(), 0, "no process must have spawned");
1310
1311        let jrec = RecordingRunner::replying(Reply::ok(""));
1312        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1313            .push("--force")
1314            .await
1315            .expect("jj path spawns; the value rides -b verbatim");
1316        assert_eq!(
1317            &jrec.only_call().args_str()[..4],
1318            &["git", "push", "-b", "--force"],
1319            "the flag-like value must ride the -b flag-VALUE slot, not become argv"
1320        );
1321    }
1322
1323    #[tokio::test]
1324    async fn fetch_from_names_the_remote_on_both_backends() {
1325        use processkit::RecordingRunner;
1326        let grec = RecordingRunner::replying(Reply::ok(""));
1327        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
1328            .fetch_from("upstream")
1329            .await
1330            .unwrap();
1331        assert_eq!(
1332            grec.only_call().args_str(),
1333            ["fetch", "--quiet", "upstream"]
1334        );
1335
1336        let jrec = RecordingRunner::replying(Reply::ok(""));
1337        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
1338            .fetch_from("upstream")
1339            .await
1340            .unwrap();
1341        let args = jrec.only_call().args_str();
1342        assert_eq!(&args[..4], &["git", "fetch", "--remote", "upstream"]);
1343    }
1344
1345    // git: untracked files count as uncommitted but not as *tracked* changes.
1346    #[tokio::test]
1347    async fn git_has_tracked_changes_ignores_untracked() {
1348        let dirty = git_repo(ScriptedRunner::new().on(["status"], Reply::ok(" M a.rs\0")));
1349        assert!(dirty.has_tracked_changes().await.unwrap());
1350        // `--untracked-files=no` means git itself omits `??` entries; an empty
1351        // reply is what a tracked-clean tree returns.
1352        let clean = git_repo(ScriptedRunner::new().on(["status"], Reply::ok("")));
1353        assert!(!clean.has_tracked_changes().await.unwrap());
1354    }
1355
1356    // jj has no untracked concept — `has_tracked_changes` follows `@`'s emptiness.
1357    #[tokio::test]
1358    async fn jj_has_tracked_changes_follows_working_copy() {
1359        let dirty = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("kz\t38\tfalse\twip\n")));
1360        assert!(dirty.has_tracked_changes().await.unwrap());
1361    }
1362
1363    #[tokio::test]
1364    async fn conflicted_files_dispatches_per_backend() {
1365        let git = git_repo(ScriptedRunner::new().on(["diff"], Reply::ok("a.rs\0b dir/c.rs\0")));
1366        assert_eq!(
1367            git.conflicted_files().await.unwrap(),
1368            ["a.rs", "b dir/c.rs"]
1369        );
1370
1371        let jj =
1372            jj_repo(ScriptedRunner::new().on(["resolve"], Reply::ok("a.rs    2-sided conflict\n")));
1373        assert_eq!(jj.conflicted_files().await.unwrap(), ["a.rs"]);
1374        // The benign "no conflicts" non-zero exit still reads as an empty list.
1375        let clean = jj_repo(ScriptedRunner::new().on(
1376            ["resolve"],
1377            Reply::fail(2, "Error: No conflicts found at this revision"),
1378        ));
1379        assert!(clean.conflicted_files().await.unwrap().is_empty());
1380    }
1381
1382    #[test]
1383    fn merge_probe_is_clean() {
1384        assert!(MergeProbe::Clean.is_clean());
1385        assert!(!MergeProbe::Conflicts(vec!["a.rs".into()]).is_clean());
1386    }
1387
1388    // git try_merge, clean: probe merge, no MERGE_HEAD afterwards (the scripted
1389    // git-dir doesn't exist) → no abort, `Clean`.
1390    #[tokio::test]
1391    async fn git_try_merge_reports_clean_and_skips_needless_abort() {
1392        use processkit::RecordingRunner;
1393        let rec = RecordingRunner::new(
1394            ScriptedRunner::new()
1395                .on(["merge"], Reply::ok("Already up to date.\n"))
1396                .on(["rev-parse"], Reply::ok("/vcs-core-no-such-git-dir")),
1397        );
1398        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1399        assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
1400        assert!(
1401            rec.calls()
1402                .iter()
1403                .all(|c| !c.args_str().contains(&"--abort".to_string())),
1404            "no merge to abort"
1405        );
1406    }
1407
1408    // git try_merge, conflict: conflicted paths are read BEFORE the abort (abort
1409    // clears the unmerged index), then the merge is aborted.
1410    #[tokio::test]
1411    async fn git_try_merge_collects_conflicts_then_aborts() {
1412        use processkit::RecordingRunner;
1413        let rec = RecordingRunner::new(
1414            ScriptedRunner::new()
1415                // Order matters: ["merge","--abort"] must outrank the ["merge"] rule.
1416                .on(["merge", "--abort"], Reply::ok(""))
1417                .on(
1418                    ["merge"],
1419                    Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
1420                )
1421                .on(["diff"], Reply::ok("a.rs\0")),
1422        );
1423        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1424        assert_eq!(
1425            repo.try_merge("other").await.unwrap(),
1426            MergeProbe::Conflicts(vec!["a.rs".to_string()])
1427        );
1428        let calls = rec.calls();
1429        let diff_pos = calls.iter().position(|c| c.args_str()[0] == "diff");
1430        let abort_pos = calls
1431            .iter()
1432            .position(|c| c.args_str().contains(&"--abort".to_string()));
1433        assert!(diff_pos.unwrap() < abort_pos.unwrap(), "{calls:?}");
1434    }
1435
1436    // git try_merge: a failing rollback must propagate, not be reported as a
1437    // clean/conflicted probe.
1438    #[tokio::test]
1439    async fn git_try_merge_propagates_abort_failure() {
1440        let tmp = TempDir::new("probe-abort");
1441        std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
1442        let repo = git_repo(
1443            ScriptedRunner::new()
1444                .on(
1445                    ["merge", "--abort"],
1446                    Reply::fail(128, "fatal: cannot abort"),
1447                )
1448                .on(["merge"], Reply::ok(""))
1449                .on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap())),
1450        );
1451        assert!(repo.try_merge("other").await.is_err());
1452    }
1453
1454    // jj try_merge: op head captured first, probe runs, op restore always runs.
1455    #[tokio::test]
1456    async fn jj_try_merge_probes_and_restores() {
1457        use processkit::RecordingRunner;
1458        let rec = RecordingRunner::new(
1459            ScriptedRunner::new()
1460                .on(["op", "log"], Reply::ok("op42\n"))
1461                .on(["op", "restore"], Reply::ok(""))
1462                .on(["new"], Reply::ok(""))
1463                .on(["log"], Reply::ok("1\n")) // is_conflicted → true
1464                .on(["resolve"], Reply::ok("a.rs    2-sided conflict\n")),
1465        );
1466        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
1467        assert_eq!(
1468            repo.try_merge("feature").await.unwrap(),
1469            MergeProbe::Conflicts(vec!["a.rs".to_string()])
1470        );
1471        let calls = rec.calls();
1472        assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
1473        assert_eq!(calls[1].args_str()[0], "new");
1474        let last = calls.last().unwrap().args_str();
1475        assert_eq!(last[..3], ["op", "restore", "op42"]);
1476    }
1477
1478    #[tokio::test]
1479    async fn jj_try_merge_clean_and_restore_failure() {
1480        // Conflict-free probe → Clean (no resolve call needed).
1481        let clean = jj_repo(
1482            ScriptedRunner::new()
1483                .on(["op", "log"], Reply::ok("op42\n"))
1484                .on(["op", "restore"], Reply::ok(""))
1485                .on(["new"], Reply::ok(""))
1486                .on(["log"], Reply::ok("0\n")),
1487        );
1488        assert_eq!(clean.try_merge("feature").await.unwrap(), MergeProbe::Clean);
1489
1490        // A failing op restore breaks the rollback guarantee → error, not Clean.
1491        let broken = jj_repo(
1492            ScriptedRunner::new()
1493                .on(["op", "log"], Reply::ok("op42\n"))
1494                .on(["op", "restore"], Reply::fail(1, "op not found"))
1495                .on(["new"], Reply::ok(""))
1496                .on(["log"], Reply::ok("0\n")),
1497        );
1498        assert!(broken.try_merge("feature").await.is_err());
1499    }
1500
1501    // continue_in_progress with unresolved paths reports `Conflict` and must NOT
1502    // attempt the continue (git would hard-error).
1503    #[tokio::test]
1504    async fn git_continue_blocked_by_conflicts_does_not_act() {
1505        use processkit::RecordingRunner;
1506        let rec = RecordingRunner::new(ScriptedRunner::new().on(["diff"], Reply::ok("a.rs\0")));
1507        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1508        assert_eq!(
1509            repo.continue_in_progress().await.unwrap(),
1510            OperationState::Conflict
1511        );
1512        assert!(
1513            rec.calls().iter().all(|c| c.args_str()[0] == "diff"),
1514            "only the conflict probe may run: {:?}",
1515            rec.calls()
1516        );
1517    }
1518
1519    // A continued rebase that stops on the NEXT patch's conflict exits non-zero;
1520    // continue_in_progress must report that as `Conflict`, not as an error. The
1521    // first conflict probe must see a clean index (else continue is blocked), the
1522    // post-continue probe must see the new conflict — a stateful predicate
1523    // sequences the two `diff` replies.
1524    #[tokio::test]
1525    async fn git_continue_maps_rebase_re_conflict() {
1526        use std::sync::Arc as StdArc;
1527        use std::sync::atomic::{AtomicBool, Ordering};
1528        let tmp = TempDir::new("rebase-restop");
1529        std::fs::create_dir_all(tmp.path().join("rebase-merge")).unwrap();
1530        let seen_first_diff = StdArc::new(AtomicBool::new(false));
1531        let flag = StdArc::clone(&seen_first_diff);
1532        let repo = git_repo(
1533            ScriptedRunner::new()
1534                .when(
1535                    move |cmd| {
1536                        cmd.arguments().first().and_then(|a| a.to_str()) == Some("diff")
1537                            && flag.swap(true, Ordering::SeqCst)
1538                    },
1539                    Reply::ok("a.rs\0"),
1540                )
1541                .on(["diff"], Reply::ok(""))
1542                .on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap()))
1543                .on(
1544                    ["rebase", "--continue"],
1545                    Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
1546                ),
1547        );
1548        assert_eq!(
1549            repo.continue_in_progress().await.unwrap(),
1550            OperationState::Conflict
1551        );
1552    }
1553
1554    // abort_in_progress dispatches to `merge --abort` when MERGE_HEAD is present.
1555    #[tokio::test]
1556    async fn git_abort_dispatches_on_merge_in_progress() {
1557        use processkit::RecordingRunner;
1558        let tmp = TempDir::new("abort");
1559        std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
1560        let rec = RecordingRunner::new(
1561            ScriptedRunner::new()
1562                .on(["rev-parse"], Reply::ok(tmp.path().to_str().unwrap()))
1563                .on(["merge", "--abort"], Reply::ok("")),
1564        );
1565        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1566        repo.abort_in_progress().await.unwrap();
1567        assert!(
1568            rec.calls()
1569                .iter()
1570                .any(|c| c.args_str() == ["merge", "--abort"]),
1571            "{:?}",
1572            rec.calls()
1573        );
1574    }
1575
1576    // git surfaces an interrupted op as on-disk state: in_progress_state returns
1577    // Merge when MERGE_HEAD is present and Rebase when a rebase dir is — the
1578    // documented asymmetry (git's conflict IS that paused state, never `Conflict`
1579    // from this method).
1580    #[tokio::test]
1581    async fn git_in_progress_state_maps_merge_and_rebase() {
1582        let merging = TempDir::new("inprog-merge");
1583        std::fs::write(merging.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
1584        let merge_repo = Repo::from_git(
1585            "/repo",
1586            "/repo",
1587            Git::with_runner(
1588                ScriptedRunner::new()
1589                    .on(["rev-parse"], Reply::ok(merging.path().to_str().unwrap())),
1590            ),
1591        );
1592        assert_eq!(
1593            merge_repo.in_progress_state().await.unwrap(),
1594            OperationState::Merge
1595        );
1596
1597        let rebasing = TempDir::new("inprog-rebase");
1598        std::fs::create_dir_all(rebasing.path().join("rebase-merge")).unwrap();
1599        let rebase_repo = Repo::from_git(
1600            "/repo",
1601            "/repo",
1602            Git::with_runner(
1603                ScriptedRunner::new()
1604                    .on(["rev-parse"], Reply::ok(rebasing.path().to_str().unwrap())),
1605            ),
1606        );
1607        assert_eq!(
1608            rebase_repo.in_progress_state().await.unwrap(),
1609            OperationState::Rebase
1610        );
1611    }
1612
1613    // On an unborn git repo (no commits) diff_stat probes is_unborn and stats
1614    // against the empty tree instead of the unresolvable HEAD, so a fresh working
1615    // tree reports its additions rather than erroring.
1616    #[tokio::test]
1617    async fn git_diff_stat_unborn_uses_empty_tree() {
1618        use processkit::RecordingRunner;
1619        let rec = RecordingRunner::new(
1620            ScriptedRunner::new()
1621                .on(["rev-parse"], Reply::fail(1, "")) // HEAD unborn
1622                .on(
1623                    ["diff", "--shortstat"],
1624                    Reply::ok(" 1 file changed, 2 insertions(+)\n"),
1625                ),
1626        );
1627        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
1628        let stat = repo.diff_stat().await.unwrap();
1629        assert_eq!(stat.insertions, 2);
1630        assert!(
1631            rec.calls()
1632                .iter()
1633                .any(|c| c.args_str() == ["diff", "--shortstat", vcs_git::EMPTY_TREE]),
1634            "diff_stat should target the empty tree on an unborn repo: {:?}",
1635            rec.calls()
1636        );
1637    }
1638
1639    // On jj, abort/continue are reporting no-ops (nothing is ever paused).
1640    #[tokio::test]
1641    async fn jj_abort_and_continue_are_reporting_noops() {
1642        let conflicted = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
1643        assert_eq!(
1644            conflicted.abort_in_progress().await.unwrap(),
1645            OperationState::Conflict
1646        );
1647        let clear = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
1648        assert_eq!(
1649            clear.continue_in_progress().await.unwrap(),
1650            OperationState::Clear
1651        );
1652    }
1653
1654    // jj records conflicts on the change; the facade maps that to `Conflict`.
1655    #[tokio::test]
1656    async fn jj_in_progress_state_maps_conflict() {
1657        let conflicted = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
1658        assert_eq!(
1659            conflicted.in_progress_state().await.unwrap(),
1660            OperationState::Conflict
1661        );
1662        let clear = jj_repo(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
1663        assert_eq!(
1664            clear.in_progress_state().await.unwrap(),
1665            OperationState::Clear
1666        );
1667    }
1668
1669    // `&dyn VcsRepo` must dispatch through the real inherent methods (a delegating
1670    // body that recursed would stack-overflow here instead of returning).
1671    #[tokio::test]
1672    async fn vcs_repo_trait_object_dispatches() {
1673        let repo = git_repo(
1674            ScriptedRunner::new()
1675                .on(["rev-parse"], Reply::ok("main\n"))
1676                .on(["show-ref"], Reply::ok("")),
1677        );
1678        let dynamic: &dyn VcsRepo = &repo;
1679        assert_eq!(dynamic.kind(), BackendKind::Git);
1680        assert_eq!(
1681            dynamic.current_branch().await.unwrap().as_deref(),
1682            Some("main")
1683        );
1684        // Exercise a reference-argument async method through `&dyn` — pins the
1685        // async_trait lifetime capture the macro relies on (no-arg calls don't).
1686        assert!(dynamic.branch_exists("main").await.unwrap());
1687    }
1688
1689    // When the backend has no native trunk (git `origin/HEAD` unset), the facade
1690    // falls back to a local `main`, then `master`.
1691    #[tokio::test]
1692    async fn trunk_falls_back_to_main() {
1693        let repo = git_repo(
1694            ScriptedRunner::new()
1695                .on(["symbolic-ref"], Reply::fail(1, "")) // origin/HEAD unset → None
1696                .on(["show-ref"], Reply::ok("")), // branch_exists("main") → exit 0
1697        );
1698        assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
1699    }
1700
1701    #[test]
1702    fn error_classifiers_recognise_markers() {
1703        let conflict = Error::Vcs(processkit::Error::Exit {
1704            program: "git".into(),
1705            code: 1,
1706            stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
1707            stderr: String::new(),
1708        });
1709        assert!(conflict.is_merge_conflict());
1710        assert!(!conflict.is_nothing_to_commit());
1711        // A non-Vcs error classifies as none of them.
1712        assert!(!Error::NotARepository("/x".into()).is_merge_conflict());
1713    }
1714}
1715
1716// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
1717#[doc = include_str!("../docs/core.md")]
1718#[allow(rustdoc::broken_intra_doc_links)]
1719pub mod guide {
1720    #[doc = include_str!("../docs/cookbook.md")]
1721    #[allow(rustdoc::broken_intra_doc_links)]
1722    pub mod cookbook {}
1723    #[doc = include_str!("../docs/process-model.md")]
1724    #[allow(rustdoc::broken_intra_doc_links)]
1725    pub mod process_model {}
1726    #[doc = include_str!("../docs/positioning.md")]
1727    #[allow(rustdoc::broken_intra_doc_links)]
1728    pub mod positioning {}
1729    #[doc = include_str!("../docs/stability.md")]
1730    #[allow(rustdoc::broken_intra_doc_links)]
1731    pub mod stability {}
1732}