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