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::discover(".")?;        // walks up, 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//! - **[`discover`]** — 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::discover`] (walks up to find the repo; real job-backed runner) or
52//!   [`Repo::open`] (strict — exactly `dir`, no walking up), or build it over an
53//!   explicit client with [`Repo::from_git`] / [`Repo::from_jj`] (the test seam).
54//!   Re-anchor it to another directory cheaply with [`Repo::at`] — the backend is
55//!   shared behind an `Arc`, so threading work across worktrees never re-detects
56//!   or rebuilds the client. Inspect it with [`kind`](Repo::kind) /
57//!   [`root`](Repo::root) / [`cwd`](Repo::cwd).
58//! - **[`VcsRepo`]** — the same common surface as an object-safe trait, so a
59//!   consumer can hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` without naming the
60//!   [`ProcessRunner`] generic. Every method mirrors the like-named inherent method
61//!   on [`Repo`]; it adds nothing but the abstraction boundary.
62//!
63//! ## The common operations
64//!
65//! All on [`Repo`] (and [`VcsRepo`]), dir-free, dispatched per backend:
66//!
67//! - **Refs** — [`current_branch`](Repo::current_branch),
68//!   [`trunk`](Repo::trunk), [`local_branches`](Repo::local_branches),
69//!   [`branch_exists`](Repo::branch_exists),
70//!   [`delete_branch`](Repo::delete_branch),
71//!   [`rename_branch`](Repo::rename_branch) (branch on git, bookmark on jj).
72//! - **Status** — [`changed_files`](Repo::changed_files),
73//!   [`diff_stat`](Repo::diff_stat),
74//!   [`has_uncommitted_changes`](Repo::has_uncommitted_changes),
75//!   [`has_tracked_changes`](Repo::has_tracked_changes),
76//!   [`conflicted_files`](Repo::conflicted_files), and
77//!   [`snapshot`](Repo::snapshot) — a **batched** prompt/status-bar read of the
78//!   lot in one or two spawns.
79//! - **Mutations** — [`commit_paths`](Repo::commit_paths) (partial commit),
80//!   [`fetch`](Repo::fetch) / [`fetch_from`](Repo::fetch_from) /
81//!   [`fetch_branch`](Repo::fetch_branch) /
82//!   [`push`](Repo::push), [`checkout`](Repo::checkout),
83//!   [`rebase`](Repo::rebase).
84//! - **Merge & operation state** — [`try_merge`](Repo::try_merge) (a
85//!   trace-free conflict probe → [`MergeProbe`]),
86//!   [`in_progress_state`](Repo::in_progress_state) /
87//!   [`abort_in_progress`](Repo::abort_in_progress) /
88//!   [`continue_in_progress`](Repo::continue_in_progress) → [`OperationState`].
89//! - **Worktrees / workspaces** — [`list_worktrees`](Repo::list_worktrees),
90//!   [`create_worktree`](Repo::create_worktree),
91//!   [`remove_worktree`](Repo::remove_worktree), and the **synchronous**
92//!   [`cleanup_worktree_blocking`](Repo::cleanup_worktree_blocking) for a `Drop`
93//!   guard that cannot `.await`.
94//!
95//! Because the backends genuinely diverge in places, several common methods carry
96//! a documented asymmetry (e.g. `upstream`/`ahead`/`behind` are always `None` on
97//! jj; [`diff_stat`](Repo::diff_stat) excludes untracked files on git but not jj;
98//! [`in_progress_state`](Repo::in_progress_state) never returns `Conflict` on git).
99//! The method docs spell each one out — the facade unifies the *shape*, not away
100//! the truth.
101//!
102//! ## The escape hatches
103//!
104//! Tool-specific work reaches the underlying typed clients without adding
105//! `vcs-git`/`vcs-jj` as separate dependencies (both are re-exported):
106//! [`git_at`](Repo::git_at) / [`jj_at`](Repo::jj_at) hand out a cwd-bound view
107//! ([`GitAt`] / [`JjAt`], `dir` dropped); the raw
108//! [`git`](Repo::git) / [`jj`](Repo::jj) hand out a borrow of the client itself.
109//! Each returns `None` for the other backend.
110//!
111//! ## What's deliberately *not* unified
112//!
113//! Three families stay off the common surface because no honest single shape
114//! exists — reach them through the bound handles:
115//!
116//! - **Full `merge`** — jj composes `new` + `squash` + bookmark moves; git runs a
117//!   single command. Only the *conflict probe* unifies, as
118//!   [`try_merge`](Repo::try_merge).
119//! - **Operation rollback** — jj's `op restore` has no faithful git analogue; use
120//!   [`Jj::transaction`](vcs_jj::Jj::transaction) on the jj client.
121//! - **Range / revset queries** — commit counts and diff stats over a range: git's
122//!   `a..b` and jj's revsets aren't interchangeable, so neither is forced onto a
123//!   shared signature.
124//!
125//! # Recipes
126//!
127//! Probe a merge for conflicts (trace-free), or spin up a worktree:
128//!
129//! ```no_run
130//! use std::path::Path;
131//! use vcs_core::{MergeProbe, Repo, WorktreeCreate};
132//! # async fn demo(repo: &Repo) -> vcs_core::Result<()> {
133//! match repo.try_merge("feature").await? {
134//!     MergeProbe::Clean            => println!("merges cleanly"),
135//!     MergeProbe::Conflicts(paths) => println!("would conflict in {paths:?}"),
136//!     _                            => {} // #[non_exhaustive]
137//! }
138//! let wt = repo
139//!     .create_worktree(WorktreeCreate::new(Path::new("/tmp/feat"), "feature").base("main"))
140//!     .await?;
141//! # let _ = wt;
142//! # Ok(()) }
143//! ```
144//!
145//! # Testing
146//!
147//! There is **no mock feature** on the facade traits — the runner is the seam.
148//! Build a [`Repo`] over a fake [`ProcessRunner`] with [`Repo::from_git`] /
149//! [`Repo::from_jj`] (e.g. a [`ScriptedRunner`](processkit::testing::ScriptedRunner)
150//! replying to canned argv), so the *real* per-backend dispatch, argv-building and
151//! parsing run against canned output — exactly what a mocked `VcsRepo` would skip.
152//! The cross-cutting patterns live in
153//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
154//!
155//! ```no_run
156//! use processkit::testing::{Reply, ScriptedRunner};
157//! use vcs_core::{vcs_git::Git, Repo};
158//! # async fn demo() -> vcs_core::Result<()> {
159//! let runner = ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0"));
160//! let repo = Repo::from_git("/repo", "/repo", Git::with_runner(runner));
161//! assert!(repo.has_uncommitted_changes().await?);
162//! # Ok(()) }
163//! ```
164//!
165//! # In-depth guide
166//!
167//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
168//! from `docs/`. See the [`guide`] module, which walks every operation in depth
169//! and hosts the cross-cutting sub-guides: a [`cookbook`](guide::cookbook) of
170//! end-to-end flows, the [`process_model`](guide::process_model) (job containment,
171//! errors, cancellation), [`positioning`](guide::positioning) (facade-vs-raw-client
172//! and the three call shapes), and the [`stability`](guide::stability) contract.
173
174use std::fmt::{self, Debug, Formatter};
175use std::path::{Path, PathBuf};
176use std::sync::Arc;
177
178use processkit::{JobRunner, ProcessRunner};
179use vcs_git::{Git, GitAt};
180use vcs_jj::{Jj, JjAt};
181
182mod dto;
183mod error;
184mod git_backend;
185mod jj_backend;
186
187pub use dto::{
188    BackendKind, BranchDelete, ChangeKind, CreateOutcome, DiffStat, FileChange, MergeProbe,
189    OperationState, RepoSnapshot, UpstreamTracking, WorktreeCreate, WorktreeCreatePartial,
190    WorktreeInfo, WorktreeRemove,
191};
192pub use error::{Error, Result};
193
194// Re-export the underlying typed clients so a consumer depending only on
195// `vcs-core` can still reach raw, tool-specific operations — and their types
196// (`GitApi`, `JjApi`, `WorktreeAdd`, `JjFileset`, …) — without adding `vcs-git`
197// / `vcs-jj` as separate dependencies. [`Repo::git`] / [`Repo::jj`] hand out
198// borrows of these clients; the consumer decides, per call, whether to go
199// through the facade or straight to the tool.
200pub use vcs_git;
201pub use vcs_jj;
202// Re-export `processkit` itself so a `vcs-core`-only consumer can name the
203// wrapped error directly — `match err { Error::Vcs(vcs_core::processkit::Error::
204// Timeout { .. }) => … }` — and reach `Outcome`/`CancellationToken`/… without
205// adding `processkit` as a separate dependency. (`Error::Vcs` carries a
206// `processkit::Error`; the classifiers below cover the common branches.)
207pub use processkit;
208// Also surfaced at the crate root so the token a `default_cancel_on` client takes
209// (built via `Git`/`Jj`, then passed to `Repo::from_git`/`from_jj`) is one name
210// away. (Cancellation is core in processkit 0.10 — always available, no feature.)
211pub use processkit::CancellationToken;
212
213/// The result of [`discover`]: which backend, and the repository root it was
214/// found at.
215#[derive(Debug, Clone, PartialEq, Eq)]
216#[non_exhaustive]
217pub struct Located {
218    /// The detected backend.
219    pub kind: BackendKind,
220    /// The directory holding `.git`/`.jj` — the worktree root.
221    pub root: PathBuf,
222}
223
224/// Walk up from `start` to the filesystem root looking for a repository. A `.jj`
225/// directory wins over `.git` (colocated repos are driven through jj); `.git` may
226/// be a directory or a gitlink file (a linked worktree/submodule). Pure
227/// filesystem probing — no subprocess.
228///
229/// `start` is walked exactly as given via [`Path::parent`], so pass an **absolute**
230/// path to search ancestors — a relative path like `"."` has no ancestor chain
231/// and only its own directory is checked. ([`Repo::discover`] absolutises for
232/// you.) See [`Repo::open`] for a strict, non-walking check of exactly one
233/// directory.
234pub fn discover(start: &Path) -> Option<Located> {
235    let mut current = Some(start);
236    while let Some(dir) = current {
237        if is_jj_marker(&dir.join(".jj")) {
238            return Some(Located {
239                kind: BackendKind::Jj,
240                root: dir.to_path_buf(),
241            });
242        }
243        if is_git_marker(&dir.join(".git")) {
244            return Some(Located {
245                kind: BackendKind::Git,
246                root: dir.to_path_buf(),
247            });
248        }
249        current = dir.parent();
250    }
251    None
252}
253
254/// Whether `path` (a candidate `.jj`) is a real jj repository marker — a `.jj`
255/// **directory** that contains a **`repo`** entry (the store: a *directory* in a
256/// repo's main workspace / a colocated repo, a *file* pointer in a secondary
257/// workspace). A stray/empty directory merely *named* `.jj` (e.g. a leftover
258/// `mkdir .jj`) has no `repo` entry, so it can't shadow a healthy `.git` repo in the
259/// same or a higher directory (M19). Symmetric with [`is_git_marker`]: both require a
260/// *valid* marker, not mere existence.
261fn is_jj_marker(path: &Path) -> bool {
262    path.is_dir() && path.join("repo").exists()
263}
264
265/// Whether `path` (a candidate `.git`) is a real git repository marker — a `.git`
266/// **directory**, or a **gitlink file** (a linked worktree / submodule) whose
267/// content starts with `gitdir:`. A stray/garbage file merely *named* `.git` is
268/// rejected, so it can't shadow a real repository higher up the tree, and a binary
269/// or unreadable file is rejected too (the read fails → `false`). Symmetric with
270/// [`is_jj_marker`]: both require a *valid* marker, not mere existence.
271fn is_git_marker(path: &Path) -> bool {
272    use std::io::Read;
273    match std::fs::metadata(path) {
274        Ok(meta) if meta.is_dir() => true,
275        Ok(meta) if meta.is_file() => {
276            // A gitlink file is tiny (`gitdir: <path>\n`), so read only a small
277            // prefix: `discover` walks *up to the filesystem root*, so a huge/garbage
278            // file merely named `.git` in an ancestor we don't own must not force an
279            // unbounded read. `read_to_end` loops over short reads (unlike a single
280            // `read`, which the `Read` contract lets return fewer bytes), and
281            // `from_utf8_lossy` tolerates a binary file or a multibyte char split at
282            // the cap — the `gitdir:` marker is ASCII and within the first bytes.
283            let Ok(file) = std::fs::File::open(path) else {
284                return false;
285            };
286            let mut buf = Vec::new();
287            let _ = file.take(32).read_to_end(&mut buf);
288            String::from_utf8_lossy(&buf)
289                .trim_start()
290                .starts_with("gitdir:")
291        }
292        _ => false,
293    }
294}
295
296/// Whether `dir` (the candidate itself, not a `.git` beneath it) is a **bare**
297/// git repository — created with `git init --bare` (or an equivalent bare
298/// clone): `HEAD`/`config`/`objects`/`refs` sit directly in `dir`, with no
299/// `.git` subdirectory. Requires all four markers together (`HEAD` a file,
300/// `config` a file, `objects`/`refs` directories) so a directory that merely
301/// happens to contain one or two similarly-named entries isn't misdetected —
302/// symmetric with [`is_jj_marker`]/[`is_git_marker`]: a *valid* marker, not
303/// mere partial name overlap. Used to give bare repositories their own
304/// [`Error::BareRepository`](crate::Error::BareRepository) instead of the
305/// generic [`Error::NotARepository`](crate::Error::NotARepository) (issue #6).
306fn is_bare_git_repo_marker(dir: &Path) -> bool {
307    dir.join("HEAD").is_file()
308        && dir.join("config").is_file()
309        && dir.join("objects").is_dir()
310        && dir.join("refs").is_dir()
311}
312
313/// Walk up from `start` to the filesystem root looking for a **bare** git
314/// repository marker (see [`is_bare_git_repo_marker`]). Only called after
315/// [`discover`] has already walked the same chain and found no `.jj`/`.git`, so
316/// any hit here is unambiguous — no real (non-bare) repository intervenes
317/// between `start` and the bare repository root.
318fn find_bare_git_repo(start: &Path) -> Option<PathBuf> {
319    let mut current = Some(start);
320    while let Some(dir) = current {
321        if is_bare_git_repo_marker(dir) {
322            return Some(dir.to_path_buf());
323        }
324        current = dir.parent();
325    }
326    None
327}
328
329/// The per-tool client behind a [`Repo`]. Shared via `Arc` so [`Repo::at`] can
330/// re-anchor the cwd cheaply without rebuilding the client.
331enum Backend<R: ProcessRunner> {
332    Git(Arc<Git<R>>),
333    Jj(Arc<Jj<R>>),
334}
335
336impl<R: ProcessRunner> Debug for Backend<R> {
337    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
338        let variant_name = match self {
339            Backend::Git(_) => "Git",
340            Backend::Jj(_) => "Jj",
341        };
342        f.debug_tuple(variant_name).finish_non_exhaustive()
343    }
344}
345
346impl<R: ProcessRunner> Backend<R> {
347    fn shared(&self) -> Self {
348        match self {
349            Backend::Git(g) => Backend::Git(Arc::clone(g)),
350            Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
351        }
352    }
353}
354
355/// A cwd-bound, backend-agnostic VCS handle. Operations run against the bound
356/// directory ([`cwd`](Repo::cwd)); use [`at`](Repo::at) to get a sibling handle
357/// bound elsewhere.
358pub struct Repo<R: ProcessRunner = JobRunner> {
359    root: PathBuf,
360    cwd: PathBuf,
361    backend: Backend<R>,
362}
363// need a manual impl to avoid `R: Debug` bound.
364impl<R: ProcessRunner> Debug for Repo<R> {
365    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
366        let Repo { root, cwd, backend } = self;
367        f.debug_struct("Repo")
368            .field("root", root)
369            .field("cwd", cwd)
370            .field("backend", backend)
371            .finish()
372    }
373}
374
375impl Repo<JobRunner> {
376    /// Discover the repository at or above `dir` and open a handle bound to
377    /// `dir`, using the real job-backed runner. Walks up from `dir` toward the
378    /// filesystem root — see [`discover`] — so it finds a repository whose root
379    /// is `dir` itself or any ancestor. Errors with [`Error::NotARepository`]
380    /// when no `.git`/`.jj` is found, or with [`Error::BareRepository`] when the
381    /// walk instead reaches a **bare** git repository (`git init --bare`) before
382    /// any `.jj`/`.git` — a bare repo has no working tree for this facade to
383    /// drive (issue #6).
384    ///
385    /// For a strict check of exactly `dir` — no walking up — see [`Repo::open`].
386    pub fn discover(dir: impl AsRef<Path>) -> Result<Self> {
387        // Absolutise first: `discover` walks parents, and a relative path like "."
388        // has no real ancestor chain (`Path::new(".").parent()` is `""`, then
389        // `None`), so a relative input would never find a repo above the cwd.
390        let dir = std::path::absolute(dir.as_ref())?;
391        let located = match discover(&dir) {
392            Some(located) => located,
393            None => {
394                // `discover` already walked the full chain and found nothing — a
395                // second, cheap walk tells us whether the reason is "no
396                // repository at all" or "a bare git repository sits in the
397                // way", so the caller gets the more precise error.
398                return Err(match find_bare_git_repo(&dir) {
399                    Some(bare_root) => Error::BareRepository(bare_root),
400                    None => Error::NotARepository(dir),
401                });
402            }
403        };
404        let backend = match located.kind {
405            BackendKind::Git => Backend::Git(Arc::new(Git::new())),
406            BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
407        };
408        Ok(Repo {
409            root: located.root,
410            cwd: dir,
411            backend,
412        })
413    }
414
415    /// Open the repository at **exactly** `dir` — unlike [`Repo::discover`],
416    /// this does **not** walk up through parent directories: `dir` itself must
417    /// hold the `.jj`/`.git` marker (a `.jj` directory with a `repo` entry, or a
418    /// `.git` directory / gitlink file — the same validated markers [`discover`]
419    /// uses), or this errors with
420    /// [`Error::NotARepository(dir)`](Error::NotARepository)
421    /// even if a repository exists somewhere above `dir`. Mirrors the
422    /// discover-vs-open split in gitoxide (`gix::discover` vs `gix::open`) and
423    /// libgit2 (`git_repository_discover` vs `git_repository_open`) — see
424    /// issue #8.
425    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
426        // Absolutise so the bound `cwd`/`root` are consistent with `discover`'s
427        // and so a relative "." names the actual directory, not an empty path.
428        let dir = std::path::absolute(dir.as_ref())?;
429        let kind = if is_jj_marker(&dir.join(".jj")) {
430            BackendKind::Jj
431        } else if is_git_marker(&dir.join(".git")) {
432            BackendKind::Git
433        } else {
434            return Err(Error::NotARepository(dir));
435        };
436        let backend = match kind {
437            BackendKind::Git => Backend::Git(Arc::new(Git::new())),
438            BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
439        };
440        Ok(Repo {
441            root: dir.clone(),
442            cwd: dir,
443            backend,
444        })
445    }
446}
447
448impl<R: ProcessRunner> Repo<R> {
449    /// Build a git-backed handle from an explicit client — for a custom runner
450    /// (e.g. a test seam) or a pre-configured [`Git`].
451    pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
452        Repo {
453            root: root.into(),
454            cwd: cwd.into(),
455            backend: Backend::Git(Arc::new(client)),
456        }
457    }
458
459    /// Build a jj-backed handle from an explicit client.
460    pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
461        Repo {
462            root: root.into(),
463            cwd: cwd.into(),
464            backend: Backend::Jj(Arc::new(client)),
465        }
466    }
467
468    /// Which backend drives this handle.
469    pub fn kind(&self) -> BackendKind {
470        match &self.backend {
471            Backend::Git(_) => BackendKind::Git,
472            Backend::Jj(_) => BackendKind::Jj,
473        }
474    }
475
476    /// The repository root detected at open time.
477    pub fn root(&self) -> &Path {
478        &self.root
479    }
480
481    /// The directory operations run against.
482    pub fn cwd(&self) -> &Path {
483        &self.cwd
484    }
485
486    /// A sibling handle bound to `dir`, sharing this handle's client and root.
487    pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
488        Repo {
489            root: self.root.clone(),
490            cwd: dir.into(),
491            backend: self.backend.shared(),
492        }
493    }
494
495    /// The underlying [`Git`] client, or `None` when jj-backed — an escape hatch
496    /// to git-only operations not on the common surface.
497    pub fn git(&self) -> Option<&Git<R>> {
498        match &self.backend {
499            Backend::Git(g) => Some(g.as_ref()),
500            Backend::Jj(_) => None,
501        }
502    }
503
504    /// The underlying [`Jj`] client, or `None` when git-backed.
505    pub fn jj(&self) -> Option<&Jj<R>> {
506        match &self.backend {
507            Backend::Jj(j) => Some(j.as_ref()),
508            Backend::Git(_) => None,
509        }
510    }
511
512    /// The git client bound to this handle's [`cwd`](Repo::cwd) — a [`GitAt`] whose
513    /// methods omit the `dir` argument — or `None` when jj-backed. The dir-free
514    /// counterpart of [`git`](Repo::git): `repo.git_at()?.merge_continue().await?`.
515    ///
516    /// The returned view borrows `self`. To work in another worktree, **bind the
517    /// re-anchored handle first** (the view can't outlive a temporary
518    /// [`at`](Repo::at)):
519    ///
520    /// ```no_run
521    /// # async fn f(repo: vcs_core::Repo, wt: &std::path::Path) -> vcs_core::Result<()> {
522    /// let wt = repo.at(wt);          // owns the re-anchored handle
523    /// let git = wt.git_at().unwrap();
524    /// git.fetch().await?;
525    /// # Ok(()) }
526    /// ```
527    pub fn git_at(&self) -> Option<GitAt<'_, R>> {
528        match &self.backend {
529            Backend::Git(g) => Some(g.at(&self.cwd)),
530            Backend::Jj(_) => None,
531        }
532    }
533
534    /// The jj client bound to this handle's [`cwd`](Repo::cwd) — a [`JjAt`] whose
535    /// methods omit the `dir` argument — or `None` when git-backed. The dir-free
536    /// counterpart of [`jj`](Repo::jj). For another workspace, bind the re-anchored
537    /// handle first (`let ws = repo.at(path); ws.jj_at()…`) — see [`git_at`](Repo::git_at).
538    pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
539        match &self.backend {
540            Backend::Jj(j) => Some(j.at(&self.cwd)),
541            Backend::Git(_) => None,
542        }
543    }
544
545    /// The current branch (git) or bookmark (jj). On jj this is the nearest
546    /// bookmark reachable from the working copy (`heads(::@ & bookmarks())`),
547    /// so it stays set across a `jj describe`/`jj new`/`jj commit` — which leave
548    /// the bookmark on the described parent while the new change carries none —
549    /// matching git's "still on my branch" reporting. When several bookmarks are
550    /// equally near `@`, the lexicographically-smallest name is returned
551    /// (deterministic). `None` only when detached / no bookmark on or above `@`.
552    pub async fn current_branch(&self) -> Result<Option<String>> {
553        match &self.backend {
554            Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
555            Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
556        }
557    }
558
559    /// The trunk branch/bookmark. Resolution order: the backend's own notion
560    /// (git's `origin/HEAD`, jj's `trunk()` revset), then a fallback to a local
561    /// `main`, then `master`; `None` when none of those resolve.
562    pub async fn trunk(&self) -> Result<Option<String>> {
563        let native = match &self.backend {
564            Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
565            Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
566        };
567        if native.is_some() {
568            return Ok(native);
569        }
570        for candidate in ["main", "master"] {
571            if self.branch_exists(candidate).await? {
572                return Ok(Some(candidate.to_string()));
573            }
574        }
575        Ok(None)
576    }
577
578    /// Local branch (git) / bookmark (jj) names.
579    ///
580    /// Backend divergence: on **jj**, a bookmark deleted locally but still **tracked**
581    /// on a remote lingers as a *tombstone* row (jj keeps it so the deletion can be
582    /// propagated) until the deletion is pushed — so this can list a name a
583    /// `delete_branch` just removed, unlike git. (The tombstone is not filtered here
584    /// because jj renders it and a *conflicted* bookmark identically — filtering would
585    /// also hide a real, conflicted bookmark; M21.)
586    pub async fn local_branches(&self) -> Result<Vec<String>> {
587        match &self.backend {
588            Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
589            Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
590        }
591    }
592
593    /// Whether a local branch/bookmark named `name` exists. See
594    /// [`local_branches`](Repo::local_branches) for the jj deleted-but-tracked
595    /// *tombstone* divergence (a just-deleted tracked bookmark can still read as
596    /// existing until the deletion is pushed).
597    pub async fn branch_exists(&self, name: &str) -> Result<bool> {
598        match &self.backend {
599            Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
600            Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
601        }
602    }
603
604    /// Whether the working copy has uncommitted changes (git: a non-empty
605    /// `status`; jj: a non-empty working-copy change `@`).
606    pub async fn has_uncommitted_changes(&self) -> Result<bool> {
607        match &self.backend {
608            Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
609            Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
610        }
611    }
612
613    /// Whether the working copy has uncommitted changes to *tracked* files.
614    ///
615    /// Backend nuance: git ignores untracked files here
616    /// (`status --untracked-files=no`); jj auto-tracks new files, so there is no
617    /// untracked concept and this equals
618    /// [`has_uncommitted_changes`](Self::has_uncommitted_changes).
619    pub async fn has_tracked_changes(&self) -> Result<bool> {
620        match &self.backend {
621            Backend::Git(g) => git_backend::has_tracked_changes(g, &self.cwd).await,
622            Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
623        }
624    }
625
626    /// Paths with unresolved merge conflicts in the working copy, repo-relative
627    /// with `/` separators (git `diff --diff-filter=U` / jj `resolve --list -r @`).
628    /// Empty when there are none.
629    pub async fn conflicted_files(&self) -> Result<Vec<String>> {
630        match &self.backend {
631            Backend::Git(g) => git_backend::conflicted_files(g, &self.cwd).await,
632            Backend::Jj(j) => jj_backend::conflicted_files(j, &self.cwd).await,
633        }
634    }
635
636    /// Delete a local branch (git) / bookmark (jj). The [`BranchDelete`] spec's
637    /// [`force`](BranchDelete::force) applies to git only (`branch -D` vs `-d`); jj
638    /// has no force and ignores it.
639    pub async fn delete_branch(&self, spec: BranchDelete) -> Result<()> {
640        match &self.backend {
641            Backend::Git(g) => {
642                git_backend::delete_branch(g, &self.cwd, &spec.name, spec.force).await
643            }
644            Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, &spec.name).await,
645        }
646    }
647
648    /// Rename a local branch (git) / bookmark (jj).
649    pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
650        match &self.backend {
651            Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
652            Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
653        }
654    }
655
656    /// The working-copy changes (git `status` / jj `diff -r @ --summary`).
657    pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
658        match &self.backend {
659            Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
660            Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
661        }
662    }
663
664    /// Aggregate insertion/deletion counts for the working copy.
665    ///
666    /// Backend nuance: git counts the working tree against `HEAD` (`git diff`,
667    /// which **excludes untracked files**), while jj counts the `@` change against
668    /// its parent (which **includes** newly-added files). So on git a brand-new
669    /// file shows in [`changed_files`](Self::changed_files) but not here, whereas
670    /// on jj it shows in both. On an unborn git repo (no commits yet) the count is
671    /// taken against the empty tree, so a pre-first-commit working tree stats
672    /// instead of erroring.
673    pub async fn diff_stat(&self) -> Result<DiffStat> {
674        match &self.backend {
675            Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
676            Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
677        }
678    }
679
680    /// A batched [`RepoSnapshot`] of the common repo state — branch, upstream,
681    /// ahead/behind, dirtiness, change count, and operation state — in a **small
682    /// fixed** number of spawns instead of a call per field (git: `status
683    /// --porcelain=v2 --branch` + the in-progress probe; jj: a `log -r @`
684    /// template for head/empty/conflict, a `reachable_bookmarks` query for
685    /// `branch`, and a change count only when dirty). Built for prompt/status-bar/
686    /// TUI refreshes. Note the asymmetry: [`tracking`](RepoSnapshot::tracking)
687    /// (the upstream ref + ahead/behind) is always `None` on jj, which has no
688    /// git-style upstream tracking.
689    pub async fn snapshot(&self) -> Result<RepoSnapshot> {
690        match &self.backend {
691            Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
692            Backend::Jj(j) => jj_backend::snapshot(j, &self.cwd).await,
693        }
694    }
695
696    /// Commit exactly `paths` with `message` (git `commit --only`, jj
697    /// `commit <filesets>`). Paths are repo-relative. `paths` must be non-empty:
698    /// an empty set is refused up front, because the backends would diverge
699    /// dangerously — git errors out, while jj's `commit` with no filesets would
700    /// silently commit the **entire** working copy.
701    pub async fn commit_paths(&self, paths: &[String], message: &str) -> Result<()> {
702        if paths.is_empty() {
703            return Err(Error::Io(std::io::Error::new(
704                std::io::ErrorKind::InvalidInput,
705                "commit_paths requires at least one path: an empty set would error \
706                 on git but commit the entire working copy on jj",
707            )));
708        }
709        match &self.backend {
710            Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
711            Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
712        }
713    }
714
715    /// Fetch from the default remote (git `fetch` / jj `git fetch`).
716    pub async fn fetch(&self) -> Result<()> {
717        match &self.backend {
718            Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
719            Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
720        }
721    }
722
723    /// Fetch from a *named* remote (git `fetch <remote>` / jj
724    /// `git fetch --remote <remote>`). Transient network failures are retried by
725    /// the underlying client.
726    pub async fn fetch_from(&self, remote: &str) -> Result<()> {
727        match &self.backend {
728            Backend::Git(g) => git_backend::fetch_from(g, &self.cwd, remote).await,
729            Backend::Jj(j) => jj_backend::fetch_from(j, &self.cwd, remote).await,
730        }
731    }
732
733    /// Fetch a single branch/bookmark from `origin` into its remote-tracking ref
734    /// (git `fetch_branch` / jj `git fetch -b`). Transient network failures
735    /// are retried by the underlying client.
736    pub async fn fetch_branch(&self, branch: &str) -> Result<()> {
737        match &self.backend {
738            Backend::Git(g) => git_backend::fetch_branch(g, &self.cwd, branch).await,
739            Backend::Jj(j) => jj_backend::fetch_branch(j, &self.cwd, branch).await,
740        }
741    }
742
743    /// Push `branch` to `origin` (git `push -u origin <branch>` / jj
744    /// `git push -b <branch>`).
745    ///
746    /// The branch (jj: bookmark) must already exist locally. The two backends
747    /// honestly differ in what "push" means: git pushes the *ref* and records
748    /// the upstream (`-u`; idempotent on repeat pushes), while jj pushes the
749    /// *bookmark's state* — including deleting the remote branch if the
750    /// bookmark was deleted locally. Renamed refspecs (`local:remote`) and
751    /// non-`origin` remotes are git-only concepts; use the
752    /// [`git()`](Repo::git) escape hatch ([`vcs_git::GitPush`]) for those.
753    pub async fn push(&self, branch: &str) -> Result<()> {
754        match &self.backend {
755            Backend::Git(g) => git_backend::push(g, &self.cwd, branch).await,
756            Backend::Jj(j) => jj_backend::push(j, &self.cwd, branch).await,
757        }
758    }
759
760    /// Switch the working copy to `reference` (git `checkout` / jj `edit`).
761    ///
762    /// ⚠ **Backend divergence — this is not "detach and build on top" on jj.** On
763    /// **git**, a subsequent commit *appends* on top of `reference` (its tip is
764    /// untouched). On **jj**, `checkout` maps to `jj edit`, which makes `reference`'s
765    /// commit *itself* the working-copy change — so a following
766    /// [`commit_paths`](Repo::commit_paths) (or any edit) **rewrites that commit in
767    /// place** (a new change-id, a replaced
768    /// description), silently amending a possibly-already-pushed commit rather than
769    /// adding a new one.
770    ///
771    /// So backend-agnostic "start fresh work on top of `main`" code must **not** rely
772    /// on `checkout` alone. If you want git-like append-on-top semantics on both
773    /// backends, start a new child change explicitly — on jj that is `jj new
774    /// <reference>` via the raw [`jj`](Repo::jj) client (a first-class `new_child`
775    /// facade primitive is planned); on git, `checkout` already appends.
776    pub async fn checkout(&self, reference: &str) -> Result<()> {
777        match &self.backend {
778            Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
779            Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
780        }
781    }
782
783    /// Rebase the current line onto `onto`. The two backends **diverge** on
784    /// non-linear layouts, so this is a documented least-common-denominator:
785    /// - **git** (`rebase <onto>` = `merge-base(HEAD,onto)..HEAD`) moves only
786    ///   `HEAD`'s own ancestor line; commits stacked on `HEAD` stay put.
787    /// - **jj** (`rebase -d <onto>` = the default `-b @` = `(onto..@)::`) moves
788    ///   that line *and its whole descendant closure* — anything stacked on `@`,
789    ///   and any sibling off an *intermediate* commit of the line, move too.
790    ///
791    /// They agree on a linear `HEAD`/`@`; on a **stacked or intermediate-fork**
792    /// layout jj moves strictly more. A sibling that shares only the fork point is
793    /// moved by neither. `onto` is a branch/bookmark name or revision the backend
794    /// understands.
795    pub async fn rebase(&self, onto: &str) -> Result<()> {
796        match &self.backend {
797            Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
798            Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
799        }
800    }
801
802    /// Probe whether merging `source` into the current work would conflict,
803    /// **without leaving any trace**: the probe is rolled back before returning
804    /// (git: `merge --no-commit --no-ff` then `merge --abort`; jj: a merge
805    /// change probed and undone via `op restore`).
806    ///
807    /// Preconditions/behaviour:
808    /// - git: requires a clean-enough working tree — a dirty-tree refusal
809    ///   propagates as a plain error, not as [`MergeProbe::Conflicts`].
810    /// - A failing rollback **propagates as an error** rather than returning a
811    ///   result that misdescribes the on-disk state.
812    /// - **Cancellation caveat:** the rollback runs on the same client, so if the
813    ///   client carries a `default_cancel_on` token (the `cancellation` feature)
814    ///   that fires during the probe, the rollback command is cancelled too and the
815    ///   probe change may be left behind (`Error::Cancelled` surfaces). Re-probe and
816    ///   reset with an un-cancelled client if you need a clean tree.
817    pub async fn try_merge(&self, source: &str) -> Result<MergeProbe> {
818        match &self.backend {
819            Backend::Git(g) => git_backend::try_merge(g, &self.cwd, source).await,
820            Backend::Jj(j) => jj_backend::try_merge(j, &self.cwd, source).await,
821        }
822    }
823
824    /// Abort the in-progress operation, if any (git: `merge --abort` /
825    /// `rebase --abort`; jj: a no-op — there are no paused operations, roll back
826    /// explicitly via `Jj::transaction` / `op_restore`). Returns the fresh
827    /// *post-call* [`OperationState`]; `Clear` when nothing was (or remains) in
828    /// progress.
829    pub async fn abort_in_progress(&self) -> Result<OperationState> {
830        match &self.backend {
831            Backend::Git(g) => git_backend::abort_in_progress(g, &self.cwd).await,
832            Backend::Jj(j) => jj_backend::abort_in_progress(j, &self.cwd).await,
833        }
834    }
835
836    /// Continue the in-progress operation after conflict resolution (git:
837    /// `commit --no-edit` for a merge / `rebase --continue`; jj: a no-op —
838    /// resolving the files *is* the continuation). Returns the fresh *post-call*
839    /// [`OperationState`]:
840    /// - `Conflict` when unresolved paths still block continuing (also on git —
841    ///   unlike [`in_progress_state`](Self::in_progress_state), this method
842    ///   *does* report `Conflict` for git), or when a continued rebase stops on
843    ///   the next patch's conflict.
844    /// - `Clear` when the operation finished.
845    pub async fn continue_in_progress(&self) -> Result<OperationState> {
846        match &self.backend {
847            Backend::Git(g) => git_backend::continue_in_progress(g, &self.cwd).await,
848            Backend::Jj(j) => jj_backend::continue_in_progress(j, &self.cwd).await,
849        }
850    }
851
852    /// Whether the working copy is mid-operation or conflicted — see
853    /// [`OperationState`]. Lets a caller decide between abort/continue without
854    /// knowing the backend's model. Note the asymmetry: *this method* reports
855    /// `Merge`/`Rebase` (never `Conflict`) on git — a git conflict *is* that
856    /// paused state, and the conflict itself surfaces on the failed op via
857    /// [`Error::is_merge_conflict`] (or as `Conflict` from
858    /// [`continue_in_progress`](Self::continue_in_progress)) — while jj has no
859    /// paused op and reports `Conflict` directly.
860    pub async fn in_progress_state(&self) -> Result<OperationState> {
861        match &self.backend {
862            Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
863            Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
864        }
865    }
866
867    /// List attached worktrees (git) / workspaces (jj).
868    pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
869        match &self.backend {
870            Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
871            Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
872        }
873    }
874
875    /// Create a worktree/workspace at `path` on a **new** `branch` based on
876    /// `base`. Always [`CreateOutcome::Plain`]; a copy-on-write strategy stays in
877    /// the consumer.
878    ///
879    /// `branch` must not already exist. The jj path is two steps (`workspace add`
880    /// then `bookmark create`) and is not atomic, but a failed bookmark step
881    /// **rolls back**: the workspace directory is removed only when `workspace add`
882    /// created it (a pre-existing directory the caller already had is left intact),
883    /// the workspace is forgotten best-effort, and the original error is surfaced —
884    /// so a failed call doesn't leak a half-made worktree.
885    pub async fn create_worktree(&self, spec: WorktreeCreate) -> Result<CreateOutcome> {
886        let WorktreeCreate { path, branch, base } = &spec;
887        match &self.backend {
888            Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
889            Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
890        }
891    }
892
893    /// Remove the worktree/workspace at `path`. For jj this resolves the
894    /// workspace name by matching `path`, deletes the directory, then forgets it;
895    /// a `path` that matches no attached jj workspace returns
896    /// [`Error::WorktreeNotFound`]. (For the best-effort, never-erroring variant,
897    /// see [`cleanup_worktree_blocking`](Self::cleanup_worktree_blocking).)
898    ///
899    /// The [`WorktreeRemove`] spec's [`force`](WorktreeRemove::force) mirrors git's
900    /// `worktree remove`: without it a worktree that still has **uncommitted changes**
901    /// is refused (`Err`) rather than deleted, so a stray edit isn't silently lost —
902    /// build `WorktreeRemove::new(path).force()` to remove it anyway. On **jj** the
903    /// changes are snapshotted into the op log before the check, so a refusal keeps
904    /// them recoverable; note that checking spawns a jj command in the target
905    /// workspace, so a genuinely stale working copy can surface an error without
906    /// `force` (use `.force()` there). The repository's **main** workspace is always
907    /// refused (it can't be removed without destroying the repo), regardless of `force`.
908    pub async fn remove_worktree(&self, spec: WorktreeRemove) -> Result<()> {
909        match &self.backend {
910            Backend::Git(g) => {
911                git_backend::remove_worktree(g, &self.cwd, &spec.path, spec.force).await
912            }
913            Backend::Jj(j) => {
914                jj_backend::remove_worktree(j, &self.cwd, &spec.path, spec.force).await
915            }
916        }
917    }
918
919    /// **Synchronous** worktree cleanup for a context that cannot `.await` —
920    /// chiefly a `Drop` guard. Force-removes the worktree at `path` (git:
921    /// `worktree remove --force`; jj: resolve the workspace name by `path`, delete
922    /// the directory, then `workspace forget`). Best-effort and short-lived: it
923    /// shells out directly (no job-containment); a jj `path` that matches no
924    /// workspace is a no-op (`Ok`). Like the async
925    /// [`remove_worktree`](Self::remove_worktree), it **refuses the repository's
926    /// main workspace** (whose directory is the main working copy) — deleting it
927    /// would wipe the repo — even on this force-by-contract path.
928    pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
929        match &self.backend {
930            Backend::Git(_) => {
931                vcs_git::blocking::worktree_remove(&self.cwd, path, true).map_err(Error::Io)
932            }
933            Backend::Jj(_) => {
934                // jj resolves a relative worktree path against the repo dir (its
935                // cwd), so resolve it the same way here — the lookup and the dir
936                // removal must target the location jj used, not one under the process
937                // cwd (which may differ from `self.cwd`).
938                let abs_path = self.cwd.join(path);
939                match vcs_jj::blocking::workspace_name_for_path(&self.cwd, &abs_path) {
940                    Some(name) => {
941                        // Same main-workspace guard as the async `remove_worktree`
942                        // (jj_backend.rs): never `remove_dir_all` the repository's
943                        // main working copy — its directory owns the object store, so
944                        // deleting it wipes the whole repo. The `default` name and the
945                        // store-owning `.jj/repo` *directory* (a secondary's is a file
946                        // pointer) both flag it, so a `jj workspace rename` can't
947                        // bypass it. Force is implied on this Drop path, but this guard
948                        // is unconditional — a repo-wipe is never the intent.
949                        if name == "default" || abs_path.join(".jj").join("repo").is_dir() {
950                            return Err(Error::Io(std::io::Error::new(
951                                std::io::ErrorKind::InvalidInput,
952                                "refusing to remove the repository's main workspace",
953                            )));
954                        }
955                        // Delete the on-disk dir first (jj `forget` leaves it), then
956                        // drop jj's record of the workspace.
957                        let _ = std::fs::remove_dir_all(&abs_path);
958                        vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
959                    }
960                    None => Ok(()),
961                }
962            }
963        }
964    }
965}
966
967/// Generate a facade trait from one signature table: the `#[async_trait]` trait
968/// declaration *and* the delegating `impl … for $Ty<R>`, so the two can never drift
969/// out of sync (a hazard when each is hand-maintained). Every generated body is a
970/// trivial delegation to the like-named inherent method — which method resolution
971/// prefers, so this never recurses; the real backend-`match` dispatch stays
972/// hand-written on the inherent `impl`. `async` methods doc-link to their inherent
973/// twin; `sync` methods carry an explicit doc string (their docs aren't uniform).
974///
975/// A near-identical copy lives in `vcs-forge`; the two are deliberately not shared
976/// (separate crates, ~40-line macro — duplication beats a new dependency).
977///
978/// Signatures only: each entry is a bare `&self` (or sync) method — no method-level
979/// generics, no `&mut self`, no default bodies (a new method shaped that way needs a
980/// grammar tweak, not just a table row).
981///
982/// No `mockall::automock`: a Wave-S spike proved it can't process a trait whose
983/// signatures come from `macro_rules!`. Captured `$_:ty` fragments reach `automock`
984/// as opaque nonterminal token groups; its `syn` parser rejects them ("unsupported
985/// type in this position"), whereas `#[async_trait]` tolerates them. So the facade
986/// traits stay test-seam-tested (build a handle over a fake runner — see the trait
987/// docs), which is also what their docs already recommend over mocking.
988macro_rules! facade_trait {
989    (
990        $(#[doc = $tdoc:expr])*
991        trait $Trait:ident for $Ty:ident;
992        sync {
993            $( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
994        }
995        async {
996            $( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
997        }
998    ) => {
999        $(#[doc = $tdoc])*
1000        #[async_trait::async_trait]
1001        pub trait $Trait: Send + Sync {
1002            $(
1003                #[doc = $sdoc]
1004                fn $sn(&self, $($sa: $sat),*) -> $sr;
1005            )*
1006            $(
1007                #[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
1008                async fn $an(&self, $($aa: $aat),*) -> $ar;
1009            )*
1010        }
1011
1012        // Delegates to the inherent methods, which method resolution prefers — so
1013        // these bodies dispatch through the concrete type's real implementations,
1014        // not back into the trait.
1015        #[async_trait::async_trait]
1016        impl<R: ProcessRunner> $Trait for $Ty<R> {
1017            $(
1018                fn $sn(&self, $($sa: $sat),*) -> $sr {
1019                    self.$sn($($sa),*)
1020                }
1021            )*
1022            $(
1023                async fn $an(&self, $($aa: $aat),*) -> $ar {
1024                    self.$an($($aa),*).await
1025                }
1026            )*
1027        }
1028    };
1029}
1030
1031facade_trait! {
1032    /// The backend-agnostic common surface of [`Repo`], as a trait — so a consumer can
1033    /// hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` and code against the operations
1034    /// without naming the [`ProcessRunner`] generic or wrapping `Repo` themselves.
1035    ///
1036    /// Every method mirrors the like-named inherent method on [`Repo`]; the trait adds
1037    /// nothing but the abstraction boundary. Tool-specific operations stay off it (see
1038    /// the crate docs) — reach those through the concrete [`Repo`] and its bound
1039    /// handles. For hermetic tests, build a `Repo` over a fake runner with
1040    /// [`Repo::from_git`] / [`Repo::from_jj`] rather than mocking this trait.
1041    trait VcsRepo for Repo;
1042    sync {
1043        #[doc = "Which backend drives this handle."]
1044        fn kind() -> BackendKind;
1045        #[doc = "The repository root detected at open time."]
1046        fn root() -> &Path;
1047        #[doc = "The directory operations run against."]
1048        fn cwd() -> &Path;
1049        #[doc = "See [`Repo::cleanup_worktree_blocking`]."]
1050        fn cleanup_worktree_blocking(path: &Path) -> Result<()>;
1051    }
1052    async {
1053        fn current_branch() -> Result<Option<String>>;
1054        fn trunk() -> Result<Option<String>>;
1055        fn local_branches() -> Result<Vec<String>>;
1056        fn branch_exists(name: &str) -> Result<bool>;
1057        fn has_uncommitted_changes() -> Result<bool>;
1058        fn has_tracked_changes() -> Result<bool>;
1059        fn conflicted_files() -> Result<Vec<String>>;
1060        fn delete_branch(spec: BranchDelete) -> Result<()>;
1061        fn rename_branch(old: &str, new: &str) -> Result<()>;
1062        fn changed_files() -> Result<Vec<FileChange>>;
1063        fn diff_stat() -> Result<DiffStat>;
1064        fn snapshot() -> Result<RepoSnapshot>;
1065        fn commit_paths(paths: &[String], message: &str) -> Result<()>;
1066        fn fetch() -> Result<()>;
1067        fn fetch_from(remote: &str) -> Result<()>;
1068        fn fetch_branch(branch: &str) -> Result<()>;
1069        fn push(branch: &str) -> Result<()>;
1070        fn checkout(reference: &str) -> Result<()>;
1071        fn rebase(onto: &str) -> Result<()>;
1072        fn try_merge(source: &str) -> Result<MergeProbe>;
1073        fn abort_in_progress() -> Result<OperationState>;
1074        fn continue_in_progress() -> Result<OperationState>;
1075        fn in_progress_state() -> Result<OperationState>;
1076        fn list_worktrees() -> Result<Vec<WorktreeInfo>>;
1077        fn create_worktree(spec: WorktreeCreate) -> Result<CreateOutcome>;
1078        fn remove_worktree(spec: WorktreeRemove) -> Result<()>;
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085    use processkit::testing::{Reply, ScriptedRunner};
1086    // The shared sandbox fixture — a unique temp dir removed on drop. Using the
1087    // testkit's one impl instead of a private copy means the wrappers/facades
1088    // don't each carry a fixture that could drift.
1089    use vcs_testkit::TempDir;
1090
1091    // --- discover ------------------------------------------------------------
1092
1093    #[test]
1094    fn discover_finds_git_and_jj_and_prefers_jj() {
1095        let tmp = TempDir::new("discover");
1096        let root = tmp.path();
1097
1098        // Plain git repo.
1099        std::fs::create_dir_all(root.join(".git")).unwrap();
1100        let located = discover(root).expect("git detected");
1101        assert_eq!(located.kind, BackendKind::Git);
1102        assert_eq!(located.root, root);
1103
1104        // Colocated: adding a *valid* .jj (with its `repo` store) makes jj win.
1105        std::fs::create_dir_all(root.join(".jj").join("repo")).unwrap();
1106        assert_eq!(discover(root).unwrap().kind, BackendKind::Jj);
1107    }
1108
1109    // M19: a stray/empty `.jj` directory (no `repo` store — e.g. a leftover
1110    // `mkdir .jj`) is NOT a jj marker and must not shadow a healthy `.git` repo in the
1111    // same directory. A valid `.jj` (with `repo`, dir or file) still wins.
1112    #[test]
1113    fn discover_ignores_a_dotjj_without_a_repo_store() {
1114        let tmp = TempDir::new("stray-jj");
1115        let root = tmp.path();
1116        std::fs::create_dir_all(root.join(".git")).unwrap();
1117        std::fs::create_dir_all(root.join(".jj")).unwrap(); // empty — no `repo`
1118        assert_eq!(
1119            discover(root).expect("git still detected").kind,
1120            BackendKind::Git,
1121            "an empty .jj must not shadow a real .git"
1122        );
1123
1124        // A secondary workspace's `.jj/repo` is a *file* pointer — still valid.
1125        let sec = TempDir::new("jj-secondary");
1126        std::fs::create_dir_all(sec.path().join(".jj")).unwrap();
1127        std::fs::write(sec.path().join(".jj").join("repo"), b"/path/to/store\n").unwrap();
1128        assert_eq!(discover(sec.path()).unwrap().kind, BackendKind::Jj);
1129    }
1130
1131    #[test]
1132    fn discover_walks_up_to_ancestor() {
1133        let tmp = TempDir::new("walkup");
1134        let root = tmp.path();
1135        std::fs::create_dir_all(root.join(".git")).unwrap();
1136        let nested = root.join("a").join("b");
1137        std::fs::create_dir_all(&nested).unwrap();
1138        let located = discover(&nested).expect("found via ancestor walk");
1139        assert_eq!(located.kind, BackendKind::Git);
1140        assert_eq!(located.root, root);
1141    }
1142
1143    #[test]
1144    fn discover_returns_none_outside_repo() {
1145        let tmp = TempDir::new("norepo");
1146        assert!(discover(tmp.path()).is_none());
1147    }
1148
1149    // A gitlink `.git` *file* (a linked worktree / submodule) is a valid git marker;
1150    // a stray file merely named `.git` is NOT — so it can't shadow a real repo above.
1151    #[test]
1152    fn discover_validates_dotgit_file_is_a_gitlink() {
1153        let tmp = TempDir::new("gitlink");
1154        let root = tmp.path();
1155
1156        // A gitlink file → detected as a git repo at this dir.
1157        std::fs::write(root.join(".git"), "gitdir: /somewhere/.git/worktrees/wt\n").unwrap();
1158        assert_eq!(
1159            discover(root).expect("gitlink detected").kind,
1160            BackendKind::Git
1161        );
1162
1163        // A garbage file named `.git` (not a gitlink) is rejected — and must NOT
1164        // shadow a real `.git` directory in the parent.
1165        let parent = TempDir::new("gitlink-parent");
1166        std::fs::create_dir_all(parent.path().join(".git")).unwrap();
1167        let child = parent.path().join("sub");
1168        std::fs::create_dir_all(&child).unwrap();
1169        std::fs::write(child.join(".git"), "not a gitlink, just noise\n").unwrap();
1170        let located = discover(&child).expect("walks up past the bogus .git file");
1171        assert_eq!(located.root, parent.path(), "the real repo is the parent");
1172
1173        // An empty `.git` file is not a marker.
1174        let empty = TempDir::new("gitlink-empty");
1175        std::fs::write(empty.path().join(".git"), "").unwrap();
1176        assert!(discover(empty.path()).is_none(), "empty .git is not a repo");
1177
1178        // Leading whitespace before `gitdir:` is tolerated (the `trim_start`).
1179        let spaced = TempDir::new("gitlink-spaced");
1180        std::fs::write(
1181            spaced.path().join(".git"),
1182            "  gitdir: /x/.git/worktrees/w\n",
1183        )
1184        .unwrap();
1185        assert_eq!(
1186            discover(spaced.path())
1187                .expect("spaced gitlink detected")
1188                .kind,
1189            BackendKind::Git
1190        );
1191    }
1192
1193    // --- bare git repository (issue #6) -------------------------------------
1194
1195    // The issue #6 repro: a `git init --bare` directory (no `.git` subdir, just
1196    // `HEAD`/`config`/`objects`/`refs` in the root) must open as
1197    // `Error::BareRepository`, not the generic `Error::NotARepository` — matched
1198    // by variant, not by message substring, so the distinction can't silently
1199    // regress into the old generic error.
1200    #[test]
1201    fn discover_reports_bare_repository_not_generic_not_a_repository() {
1202        let tmp = TempDir::new("bare-repo");
1203        let root = tmp.path();
1204        std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1205        std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1206        std::fs::create_dir_all(root.join("objects")).unwrap();
1207        std::fs::create_dir_all(root.join("refs")).unwrap();
1208
1209        match Repo::discover(root) {
1210            Err(Error::BareRepository(p)) => assert_eq!(p, root),
1211            other => panic!("expected Error::BareRepository, got {other:?}"),
1212        }
1213
1214        // The strict, non-walking `open` doesn't special-case bare repos — a
1215        // bare repo has no `.git`/`.jj` marker in itself, so it's just
1216        // `NotARepository` there (only `discover`'s walk-then-classify path
1217        // distinguishes it).
1218        match Repo::open(root) {
1219            Err(Error::NotARepository(p)) => assert_eq!(p, root),
1220            other => panic!("expected Error::NotARepository, got {other:?}"),
1221        }
1222    }
1223
1224    // A bare repository nested a few levels below `dir` is still found by
1225    // walking up — mirrors `discover_walks_up_to_ancestor` for the bare case.
1226    #[test]
1227    fn discover_finds_bare_repository_via_ancestor_walk() {
1228        let tmp = TempDir::new("bare-walkup");
1229        let root = tmp.path();
1230        std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1231        std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1232        std::fs::create_dir_all(root.join("objects")).unwrap();
1233        std::fs::create_dir_all(root.join("refs")).unwrap();
1234        let nested = root.join("a").join("b");
1235        std::fs::create_dir_all(&nested).unwrap();
1236
1237        match Repo::discover(&nested) {
1238            Err(Error::BareRepository(p)) => assert_eq!(p, root),
1239            other => panic!("expected Error::BareRepository, got {other:?}"),
1240        }
1241
1242        // The strict `open` never walks up, so it reports `NotARepository` on
1243        // the nested dir regardless of what sits above it.
1244        match Repo::open(&nested) {
1245            Err(Error::NotARepository(p)) => assert_eq!(p, nested),
1246            other => panic!("expected Error::NotARepository, got {other:?}"),
1247        }
1248    }
1249
1250    // A directory that merely happens to hold some, but not all four, of the
1251    // bare-repo marker entries must NOT be misdetected as a bare repository —
1252    // it's just an ordinary non-repository directory.
1253    #[test]
1254    fn discover_does_not_misdetect_partial_bare_markers_as_bare_repository() {
1255        let tmp = TempDir::new("bare-partial");
1256        let root = tmp.path();
1257        // Only `HEAD` and `config` — no `objects`/`refs` directories.
1258        std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1259        std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1260
1261        match Repo::discover(root) {
1262            Err(Error::NotARepository(p)) => assert_eq!(p, root),
1263            other => panic!("expected Error::NotARepository, got {other:?}"),
1264        }
1265    }
1266
1267    // A real (non-bare) git repository — `.git` subdirectory present — must
1268    // keep opening as before, not get swept up by the new bare-detection path.
1269    #[test]
1270    fn open_still_opens_a_normal_git_repository() {
1271        let tmp = TempDir::new("normal-git");
1272        let root = tmp.path();
1273        std::fs::create_dir_all(root.join(".git")).unwrap();
1274
1275        let repo = Repo::open(root).expect("normal git repo still opens");
1276        assert_eq!(repo.kind(), BackendKind::Git);
1277        assert_eq!(repo.root(), root);
1278    }
1279
1280    // A real jj repository must also keep opening as before.
1281    #[test]
1282    fn open_still_opens_a_normal_jj_repository() {
1283        let tmp = TempDir::new("normal-jj");
1284        let root = tmp.path();
1285        std::fs::create_dir_all(root.join(".jj").join("repo")).unwrap();
1286
1287        let repo = Repo::open(root).expect("normal jj repo still opens");
1288        assert_eq!(repo.kind(), BackendKind::Jj);
1289        assert_eq!(repo.root(), root);
1290    }
1291
1292    // A directory that is neither a repo nor a bare repo still reports the
1293    // generic `NotARepository`.
1294    #[test]
1295    fn open_reports_not_a_repository_when_nothing_found() {
1296        let tmp = TempDir::new("norepo-open");
1297        match Repo::open(tmp.path()) {
1298            Err(Error::NotARepository(p)) => assert_eq!(p, tmp.path()),
1299            other => panic!("expected Error::NotARepository, got {other:?}"),
1300        }
1301    }
1302
1303    // Unlike `discover`, the strict `open` never walks up — a repository at an
1304    // ancestor of `dir` must NOT make `open(dir)` succeed, even though
1305    // `discover(dir)` would find it.
1306    #[test]
1307    fn open_does_not_walk_up_even_though_discover_would() {
1308        let tmp = TempDir::new("open-no-walkup");
1309        let root = tmp.path();
1310        std::fs::create_dir_all(root.join(".git")).unwrap();
1311        let nested = root.join("a").join("b");
1312        std::fs::create_dir_all(&nested).unwrap();
1313
1314        match Repo::open(&nested) {
1315            Err(Error::NotARepository(p)) => assert_eq!(p, nested),
1316            other => panic!("expected Error::NotARepository, got {other:?}"),
1317        }
1318        // `discover` from the same nested dir finds the repo at `root`.
1319        assert_eq!(
1320            Repo::discover(&nested).expect("discover walks up").root(),
1321            root
1322        );
1323    }
1324
1325    // --- dispatch (hermetic, ScriptedRunner-backed) ------------------------
1326
1327    fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
1328        Repo::from_git("/repo", "/repo", Git::with_runner(runner))
1329    }
1330
1331    fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
1332        Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
1333    }
1334
1335    // --- Debug -------------------------------------------------------------
1336    //
1337    // Regression tests for the `Repo`/`Backend` `Debug` impl (PR #7): formatting
1338    // the facade must show the elided shape (`Repo { .. }` with a `Git(..)`/
1339    // `Jj(..)` backend) but never expose the wrapped CLI client — and therefore
1340    // never a credential token that client might hold. These exist to catch a
1341    // future refactor that accidentally starts formatting the client (e.g.
1342    // deriving `Debug` on `Backend` directly, or dropping `finish_non_exhaustive`).
1343
1344    // A git-backed `Repo` built over a `Git` client holding a token via
1345    // `with_token` must format to the expected elided shape and must NOT leak
1346    // the token (or any other inner-client internal) through `{:?}`.
1347    #[test]
1348    fn debug_output_shows_elided_git_backend_and_never_leaks_the_token() {
1349        let repo = Repo::from_git(
1350            "/repo",
1351            "/repo",
1352            Git::with_runner(ScriptedRunner::new()).with_token("ghp_super_secret_token"),
1353        );
1354        let out = format!("{repo:?}");
1355        assert!(out.contains("Repo {"), "{out}");
1356        assert!(out.contains("root"), "{out}");
1357        assert!(out.contains("cwd"), "{out}");
1358        assert!(out.contains("Git(.."), "{out}");
1359        assert!(
1360            !out.contains("ghp_super_secret_token"),
1361            "token must not leak through Debug: {out}"
1362        );
1363        // Nothing from the inner `Git`/`ManagedClient`/`CliClient` internals
1364        // (e.g. its env-var bookkeeping) should surface either — the backend
1365        // must render as a bare, elided discriminant.
1366        assert!(!out.contains("ManagedClient"), "{out}");
1367        assert!(!out.contains("CliClient"), "{out}");
1368    }
1369
1370    // A jj-backed `Repo` (jj is ambient-auth-only — no `with_token`) must format
1371    // to the analogous elided shape, with the `Jj(..)` discriminant and no inner
1372    // client internals.
1373    #[test]
1374    fn debug_output_shows_elided_jj_backend() {
1375        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(ScriptedRunner::new()));
1376        let out = format!("{repo:?}");
1377        assert!(out.contains("Repo {"), "{out}");
1378        assert!(out.contains("Jj(.."), "{out}");
1379        assert!(!out.contains("ManagedClient"), "{out}");
1380        assert!(!out.contains("CliClient"), "{out}");
1381    }
1382
1383    // --- snapshot ----------------------------------------------------------
1384
1385    // git: one porcelain-v2 call + a git-dir probe → a combined RepoSnapshot.
1386    #[tokio::test]
1387    async fn git_snapshot_combines_v2_status_and_op_state() {
1388        let v2 = concat!(
1389            "# branch.oid abc123\0",
1390            "# branch.head main\0",
1391            "# branch.upstream origin/main\0",
1392            "# branch.ab +2 -0\0",
1393            "1 .M N... 100644 100644 100644 1 2 a.rs\0",
1394            "? new.txt\0",
1395        );
1396        // An empty git dir → no MERGE_HEAD / rebase dir → Clear.
1397        let gitdir = TempDir::new("snap-git");
1398        let repo = git_repo(
1399            ScriptedRunner::new()
1400                .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1401                .on(
1402                    ["git", "rev-parse", "--git-dir"],
1403                    Reply::ok(gitdir.path().to_str().unwrap()),
1404                ),
1405        );
1406        let s = repo.snapshot().await.unwrap();
1407        assert_eq!(s.branch.as_deref(), Some("main"));
1408        let tracking = s.tracking.as_ref().expect("upstream tracking");
1409        assert_eq!(tracking.branch, "origin/main");
1410        assert_eq!((tracking.ahead, tracking.behind), (Some(2), Some(0)));
1411        assert!(s.dirty);
1412        assert_eq!(s.change_count, 2, "1 tracked + 1 untracked");
1413        assert!(!s.conflicted);
1414        assert_eq!(s.operation, OperationState::Clear);
1415    }
1416
1417    // M20 (whole-solution): `snapshot()` has its OWN operation probe (separate from
1418    // `in_progress_state`); it too must report a `git am` as `ApplyMailbox`, not
1419    // `Rebase` — otherwise the new variant is dead on the snapshot → watch → mcp path.
1420    #[tokio::test]
1421    async fn git_snapshot_reports_git_am_as_apply_mailbox() {
1422        let v2 = concat!("# branch.oid abc\0", "# branch.head main\0");
1423        let gitdir = TempDir::new("snap-git-am");
1424        // A `git am` in progress: `rebase-apply/` WITH the `applying` marker.
1425        let apply = gitdir.path().join("rebase-apply");
1426        std::fs::create_dir_all(&apply).unwrap();
1427        std::fs::write(apply.join("applying"), b"").unwrap();
1428        let repo = git_repo(
1429            ScriptedRunner::new()
1430                .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1431                .on(
1432                    ["git", "rev-parse", "--git-dir"],
1433                    Reply::ok(gitdir.path().to_str().unwrap()),
1434                ),
1435        );
1436        let s = repo.snapshot().await.unwrap();
1437        assert_eq!(
1438            s.operation,
1439            OperationState::ApplyMailbox,
1440            "a git am must not read as Rebase in snapshot()"
1441        );
1442    }
1443
1444    // git with NO upstream configured: porcelain v2 omits the `# branch.upstream`
1445    // and `# branch.ab` lines, so `tracking` is None (the all-or-nothing invariant —
1446    // git is the only backend that can produce either) — mirrors the jj None case.
1447    #[tokio::test]
1448    async fn git_snapshot_without_upstream_has_no_tracking() {
1449        let v2 = concat!("# branch.oid abc123\0", "# branch.head main\0");
1450        let gitdir = TempDir::new("snap-git-noup");
1451        let repo = git_repo(
1452            ScriptedRunner::new()
1453                .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1454                .on(
1455                    ["git", "rev-parse", "--git-dir"],
1456                    Reply::ok(gitdir.path().to_str().unwrap()),
1457                ),
1458        );
1459        let s = repo.snapshot().await.unwrap();
1460        assert_eq!(s.branch.as_deref(), Some("main"));
1461        assert!(s.tracking.is_none(), "no upstream → no tracking");
1462    }
1463
1464    // M17: an upstream that is SET but GONE (deleted on the remote, or not yet
1465    // fetched) — porcelain v2 emits `# branch.upstream` but OMITS `# branch.ab`, so the
1466    // counts are uncountable. `tracking` must be `Some { branch, ahead: None, behind:
1467    // None }` (tracking configured but uncountable), NOT a fabricated in-sync `0`/`0`.
1468    #[tokio::test]
1469    async fn git_snapshot_upstream_set_but_gone_is_uncountable() {
1470        let v2 = concat!(
1471            "# branch.oid abc123\0",
1472            "# branch.head main\0",
1473            "# branch.upstream origin/main\0", // upstream named…
1474                                               // …but no `# branch.ab` line — it doesn't resolve.
1475        );
1476        let gitdir = TempDir::new("snap-git-gone");
1477        let repo = git_repo(
1478            ScriptedRunner::new()
1479                .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1480                .on(
1481                    ["git", "rev-parse", "--git-dir"],
1482                    Reply::ok(gitdir.path().to_str().unwrap()),
1483                ),
1484        );
1485        let s = repo.snapshot().await.unwrap();
1486        let tracking = s.tracking.as_ref().expect("upstream is set");
1487        assert_eq!(tracking.branch, "origin/main");
1488        assert_eq!(
1489            (tracking.ahead, tracking.behind),
1490            (None, None),
1491            "a gone upstream is uncountable, not in-sync 0/0"
1492        );
1493    }
1494
1495    // jj: one template row + a status count; a conflicted @ maps to Conflict; no
1496    // git-style upstream/ahead/behind.
1497    #[tokio::test]
1498    async fn jj_snapshot_dirty_with_change_count() {
1499        let repo = jj_repo(
1500            ScriptedRunner::new()
1501                // snapshot template (`jj log -r @`): commit_id \t empty \t conflict
1502                .on(["jj", "log", "-r", "@"], Reply::ok("deadbeef\t0\t1\n")) // empty=0 dirty, conflict=1
1503                // `branch` via `current_branch` → `reachable_bookmarks`
1504                // (`jj log -r heads(::@ & bookmarks())`): bookmarks \t commit
1505                .on(
1506                    ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1507                    Reply::ok("main\tdeadbeef\n"),
1508                )
1509                .on(["jj", "diff"], Reply::ok("M a.rs\nA b.rs\n")), // status -r @ --summary → 2
1510        );
1511        let s = repo.snapshot().await.unwrap();
1512        assert_eq!(s.head.as_deref(), Some("deadbeef"));
1513        assert_eq!(s.branch.as_deref(), Some("main"));
1514        assert!(s.dirty);
1515        assert_eq!(s.change_count, 2);
1516        assert!(s.conflicted);
1517        assert_eq!(s.operation, OperationState::Conflict);
1518        assert!(s.tracking.is_none(), "jj has no upstream tracking");
1519    }
1520
1521    // jj: a clean `@` (empty=1) skips the change-count spawn entirely — the test
1522    // scripts NO `diff` rule, so calling `status` would error.
1523    #[tokio::test]
1524    async fn jj_snapshot_clean_skips_change_count() {
1525        let repo = jj_repo(
1526            ScriptedRunner::new()
1527                .on(["jj", "log", "-r", "@"], Reply::ok("c0ffee\t1\t0\n"))
1528                .on(
1529                    ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1530                    Reply::ok(""),
1531                ),
1532        );
1533        let s = repo.snapshot().await.unwrap();
1534        assert_eq!(s.head.as_deref(), Some("c0ffee"));
1535        assert_eq!(s.branch, None, "no bookmark");
1536        assert!(!s.dirty);
1537        assert_eq!(s.change_count, 0);
1538        assert!(!s.conflicted);
1539        assert_eq!(s.operation, OperationState::Clear);
1540    }
1541
1542    // jj: a conflicted `@` that jj marks `empty` (conflict but no net content change)
1543    // is still reported `dirty` — the conflict is uncommitted state needing
1544    // resolution — so the count runs and the snapshot is coherent (no
1545    // `conflicted: true` next to `dirty: false`), mirroring git's conflict handling.
1546    #[tokio::test]
1547    async fn jj_snapshot_conflicted_empty_change_is_dirty() {
1548        let repo = jj_repo(
1549            ScriptedRunner::new()
1550                .on(["jj", "log", "-r", "@"], Reply::ok("c0ffee\t1\t1\n")) // empty=1, conflict=1
1551                .on(
1552                    ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1553                    Reply::ok(""),
1554                ) // no bookmark
1555                .on(["jj", "diff"], Reply::ok("M conflicted.rs\n")), // status → 1
1556        );
1557        let s = repo.snapshot().await.unwrap();
1558        assert!(s.conflicted);
1559        assert!(s.dirty, "a conflicted change is a dirty working copy");
1560        assert_eq!(s.change_count, 1);
1561        assert_eq!(s.operation, OperationState::Conflict);
1562    }
1563
1564    // jj `list_worktrees` resolves each workspace's root via the batched
1565    // `workspace_roots` fan-out (one `workspace root --name <n>` per `workspace
1566    // list` row), then builds a `WorktreeInfo` per workspace. Hermetic: scripts the
1567    // template rows + the per-name root replies — the backend glue that the
1568    // `#[ignore]` integration tests otherwise cover only with a real `jj`.
1569    #[tokio::test]
1570    async fn jj_list_worktrees_batches_root_lookups() {
1571        let repo = jj_repo(
1572            ScriptedRunner::new()
1573                .on(
1574                    ["jj", "workspace", "list"],
1575                    Reply::ok("default\tc0ffee\tmain\nws1\tdecaf0\t\n"),
1576                )
1577                .on(
1578                    [
1579                        "jj",
1580                        "--ignore-working-copy",
1581                        "workspace",
1582                        "root",
1583                        "--name",
1584                        "default",
1585                    ],
1586                    Reply::ok("/repo\n"),
1587                )
1588                .on(
1589                    [
1590                        "jj",
1591                        "--ignore-working-copy",
1592                        "workspace",
1593                        "root",
1594                        "--name",
1595                        "ws1",
1596                    ],
1597                    Reply::ok("/repo/ws1\n"),
1598                ),
1599        );
1600        let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1601        assert_eq!(worktrees.len(), 2);
1602        assert_eq!(worktrees[0].path, Path::new("/repo"));
1603        assert_eq!(worktrees[0].branch.as_deref(), Some("main"));
1604        assert_eq!(worktrees[1].path, Path::new("/repo/ws1"));
1605        assert_eq!(worktrees[1].branch, None);
1606    }
1607
1608    // A workspace whose `workspace root` lookup errors is skipped (no useful path),
1609    // mirroring the old sequential loop — the batch maps that slot to `Err`.
1610    #[tokio::test]
1611    async fn jj_list_worktrees_skips_unresolvable_root() {
1612        let repo = jj_repo(
1613            ScriptedRunner::new()
1614                .on(
1615                    ["jj", "workspace", "list"],
1616                    Reply::ok("default\tc0ffee\tmain\ngone\tdecaf0\t\n"),
1617                )
1618                .on(
1619                    [
1620                        "jj",
1621                        "--ignore-working-copy",
1622                        "workspace",
1623                        "root",
1624                        "--name",
1625                        "default",
1626                    ],
1627                    Reply::ok("/repo\n"),
1628                )
1629                .on(
1630                    [
1631                        "jj",
1632                        "--ignore-working-copy",
1633                        "workspace",
1634                        "root",
1635                        "--name",
1636                        "gone",
1637                    ],
1638                    Reply::fail(1, "Error: No such workspace"),
1639                ),
1640        );
1641        let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1642        assert_eq!(worktrees.len(), 1, "the unresolvable workspace is skipped");
1643        assert_eq!(worktrees[0].path, Path::new("/repo"));
1644    }
1645
1646    // remove_worktree surfaces a `workspace forget` failure rather than swallowing
1647    // it — name resolution already proved the workspace is registered, so a forget
1648    // error is a real dangling-registration the caller should see.
1649    #[tokio::test]
1650    async fn jj_remove_worktree_surfaces_forget_error() {
1651        let repo = jj_repo(
1652            ScriptedRunner::new()
1653                .on(["jj", "workspace", "list"], Reply::ok("ws1\tc0ffee\t\n"))
1654                .on(
1655                    [
1656                        "jj",
1657                        "--ignore-working-copy",
1658                        "workspace",
1659                        "root",
1660                        "--name",
1661                        "ws1",
1662                    ],
1663                    Reply::ok("/repo/ws1\n"),
1664                )
1665                .on(
1666                    ["jj", "workspace", "forget"],
1667                    Reply::fail(1, "Error: cannot forget workspace"),
1668                ),
1669        );
1670        // `/repo/ws1` does not exist on disk, so the dir-removal step is skipped and
1671        // the forget error is the sole outcome.
1672        let res = repo.remove_worktree(WorktreeRemove::new("/repo/ws1")).await;
1673        assert!(res.is_err(), "a forget failure is surfaced, not swallowed");
1674    }
1675
1676    // C1: the default workspace resolves at the repo root; removing it would wipe
1677    // the whole repository, so it is refused even with force = true and WITHOUT
1678    // running `workspace forget` (no such cassette rule — a miss would also error,
1679    // so we assert the *refusal* message to prove the guard, not a fallthrough).
1680    #[tokio::test]
1681    async fn jj_remove_worktree_refuses_the_main_workspace() {
1682        let repo = jj_repo(
1683            ScriptedRunner::new()
1684                .on(
1685                    ["jj", "workspace", "list"],
1686                    Reply::ok("default\tc0ffee\t\n"),
1687                )
1688                .on(
1689                    [
1690                        "jj",
1691                        "--ignore-working-copy",
1692                        "workspace",
1693                        "root",
1694                        "--name",
1695                        "default",
1696                    ],
1697                    Reply::ok("/repo\n"),
1698                ),
1699        );
1700        let err = repo
1701            .remove_worktree(WorktreeRemove::new("/repo").force())
1702            .await
1703            .expect_err("the main workspace must be refused");
1704        assert!(
1705            err.to_string().contains("main workspace"),
1706            "refusal message, not a cassette miss: {err}"
1707        );
1708    }
1709
1710    // C1: a secondary workspace with un-snapshotted edits (`current_change` reports
1711    // non-empty) is refused under force = false, and its directory is NOT deleted.
1712    #[tokio::test]
1713    async fn jj_remove_worktree_refuses_dirty_workspace_without_force() {
1714        let tmp = TempDir::new("rmw-dirty");
1715        let root = tmp.path().to_string_lossy().into_owned();
1716        let repo = Repo::from_jj(
1717            &root,
1718            &root,
1719            Jj::with_runner(
1720                ScriptedRunner::new()
1721                    .on(["jj", "workspace", "list"], Reply::ok("ws1\tc0ffee\t\n"))
1722                    .on(
1723                        [
1724                            "jj",
1725                            "--ignore-working-copy",
1726                            "workspace",
1727                            "root",
1728                            "--name",
1729                            "ws1",
1730                        ],
1731                        Reply::ok(format!("{root}\n")),
1732                    )
1733                    // `current_change` → 3rd field `false` = not empty = dirty.
1734                    .on(["jj", "log"], Reply::ok("aaa\tbbb\tfalse\twork\n")),
1735            ),
1736        );
1737        let err = repo
1738            .remove_worktree(WorktreeRemove::new(tmp.path()))
1739            .await
1740            .expect_err("a dirty workspace must be refused without force");
1741        assert!(
1742            err.to_string().contains("uncommitted changes"),
1743            "refusal message: {err}"
1744        );
1745        assert!(
1746            tmp.path().exists(),
1747            "the workspace directory must survive a refusal"
1748        );
1749    }
1750
1751    // C1: force = true skips the dirty check and removes the directory (no
1752    // `current_change` rule is scripted, proving the check is bypassed).
1753    #[tokio::test]
1754    async fn jj_remove_worktree_with_force_removes_the_dir() {
1755        let tmp = TempDir::new("rmw-force");
1756        let ws = tmp.path().join("ws1");
1757        std::fs::create_dir_all(&ws).expect("mkdir ws");
1758        let root = tmp.path().to_string_lossy().into_owned();
1759        let ws_str = ws.to_string_lossy().into_owned();
1760        let repo = Repo::from_jj(
1761            &root,
1762            &root,
1763            Jj::with_runner(
1764                ScriptedRunner::new()
1765                    .on(["jj", "workspace", "list"], Reply::ok("ws1\tc0ffee\t\n"))
1766                    .on(
1767                        [
1768                            "jj",
1769                            "--ignore-working-copy",
1770                            "workspace",
1771                            "root",
1772                            "--name",
1773                            "ws1",
1774                        ],
1775                        Reply::ok(format!("{ws_str}\n")),
1776                    )
1777                    .on(["jj", "workspace", "forget"], Reply::ok("")),
1778            ),
1779        );
1780        repo.remove_worktree(WorktreeRemove::new(ws.clone()).force())
1781            .await
1782            .expect("force removes a dirty worktree");
1783        assert!(!ws.exists(), "the worktree directory was removed");
1784    }
1785
1786    // C1: the main-workspace guard's store-directory branch — a workspace whose
1787    // name was changed away from `default` (via `jj workspace rename`) still owns
1788    // the object store (`.jj/repo` is a *directory*, not a secondary's file
1789    // pointer), so removal is refused even with force = true, and the dir survives.
1790    // Exercises the `|| .jj/repo.is_dir()` half of the guard (the name is not
1791    // `default`), which the name-based test can't reach.
1792    #[tokio::test]
1793    async fn jj_remove_worktree_refuses_renamed_store_owning_workspace() {
1794        let tmp = TempDir::new("rmw-store");
1795        std::fs::create_dir_all(tmp.path().join(".jj").join("repo")).expect("mk .jj/repo dir");
1796        let root = tmp.path().to_string_lossy().into_owned();
1797        let repo = Repo::from_jj(
1798            &root,
1799            &root,
1800            Jj::with_runner(
1801                ScriptedRunner::new()
1802                    .on(["jj", "workspace", "list"], Reply::ok("mainws\tc0ffee\t\n"))
1803                    .on(
1804                        [
1805                            "jj",
1806                            "--ignore-working-copy",
1807                            "workspace",
1808                            "root",
1809                            "--name",
1810                            "mainws",
1811                        ],
1812                        Reply::ok(format!("{root}\n")),
1813                    ),
1814            ),
1815        );
1816        let err = repo
1817            .remove_worktree(WorktreeRemove::new(tmp.path()).force())
1818            .await
1819            .expect_err("a renamed store-owning workspace is still refused");
1820        assert!(
1821            err.to_string().contains("main workspace"),
1822            "refusal message: {err}"
1823        );
1824        assert!(
1825            tmp.path().exists(),
1826            "the store-owning directory must not be deleted"
1827        );
1828    }
1829
1830    #[tokio::test]
1831    async fn kind_and_escape_hatches_reflect_backend() {
1832        let repo = git_repo(ScriptedRunner::new());
1833        assert_eq!(repo.kind(), BackendKind::Git);
1834        assert!(repo.git().is_some());
1835        assert!(repo.jj().is_none());
1836    }
1837
1838    // The cwd-bound views mirror the backend, and `at` re-binds them to another
1839    // directory without a separate client.
1840    #[tokio::test]
1841    async fn bound_views_reflect_backend_and_cwd() {
1842        let git = git_repo(ScriptedRunner::new());
1843        assert!(git.git_at().is_some());
1844        assert!(git.jj_at().is_none());
1845        // A sibling handle bound elsewhere yields a view rooted at that dir.
1846        assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
1847
1848        let jj = jj_repo(ScriptedRunner::new());
1849        assert!(jj.jj_at().is_some());
1850        assert!(jj.git_at().is_none());
1851    }
1852
1853    #[tokio::test]
1854    async fn current_branch_maps_detached_head_to_none() {
1855        // git's `current_branch` now runs `symbolic-ref --quiet --short HEAD`:
1856        // exit 0 → the branch name, exit 1 → detached HEAD → None.
1857        let named =
1858            git_repo(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")));
1859        assert_eq!(
1860            named.current_branch().await.unwrap().as_deref(),
1861            Some("main")
1862        );
1863        let detached =
1864            git_repo(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
1865        assert!(detached.current_branch().await.unwrap().is_none());
1866    }
1867
1868    #[tokio::test]
1869    async fn changed_files_maps_git_status() {
1870        let repo = git_repo(ScriptedRunner::new().on(
1871            ["git", "status"],
1872            Reply::ok(" M a.rs\0?? b.rs\0R  new.rs\0old.rs\0"),
1873        ));
1874        let changes = repo.changed_files().await.unwrap();
1875        assert_eq!(changes.len(), 3);
1876        assert_eq!(changes[0].kind, ChangeKind::Modified);
1877        assert_eq!(changes[1].kind, ChangeKind::Added);
1878        assert_eq!(changes[2].kind, ChangeKind::Renamed);
1879        assert_eq!(changes[2].old_path.as_deref(), Some("old.rs"));
1880    }
1881
1882    #[tokio::test]
1883    async fn local_branches_maps_git_branch_output() {
1884        let repo =
1885            git_repo(ScriptedRunner::new().on(["git", "branch"], Reply::ok("* main\n  feat\n")));
1886        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
1887    }
1888
1889    #[tokio::test]
1890    async fn branch_exists_reads_show_ref_exit() {
1891        let yes = git_repo(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
1892        assert!(yes.branch_exists("main").await.unwrap());
1893        let no = git_repo(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
1894        assert!(!no.branch_exists("nope").await.unwrap());
1895    }
1896
1897    #[tokio::test]
1898    async fn has_uncommitted_changes_reflects_status() {
1899        let dirty = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0")));
1900        assert!(dirty.has_uncommitted_changes().await.unwrap());
1901        let clean = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok("")));
1902        assert!(!clean.has_uncommitted_changes().await.unwrap());
1903    }
1904
1905    #[tokio::test]
1906    async fn at_rebinds_cwd_and_shares_backend() {
1907        let repo = git_repo(ScriptedRunner::new());
1908        let moved = repo.at("/repo/sub");
1909        assert_eq!(moved.cwd(), Path::new("/repo/sub"));
1910        assert_eq!(moved.root(), Path::new("/repo"));
1911        assert_eq!(moved.kind(), BackendKind::Git);
1912    }
1913
1914    // --- dispatch: jj backend (hermetic) -----------------------------------
1915
1916    #[tokio::test]
1917    async fn jj_kind_and_escape_hatches_reflect_backend() {
1918        let repo = jj_repo(ScriptedRunner::new());
1919        assert_eq!(repo.kind(), BackendKind::Jj);
1920        assert!(repo.jj().is_some() && repo.git().is_none());
1921    }
1922
1923    #[tokio::test]
1924    async fn jj_current_branch_reads_bookmark() {
1925        // current_branch derives from `reachable_bookmarks`, whose template is
1926        // `<bookmarks space-joined>\t<commit>` — distinct from the strict
1927        // `current_bookmark(@)` comma-joined template.
1928        let repo = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("main\t53e4e879\n")));
1929        assert_eq!(
1930            repo.current_branch().await.unwrap().as_deref(),
1931            Some("main")
1932        );
1933    }
1934
1935    #[tokio::test]
1936    async fn jj_current_branch_persists_across_commit() {
1937        // After a jj commit the new working-copy change carries no bookmark, but
1938        // the described parent does. `reachable_bookmarks` resolves the nearest
1939        // bookmarked ancestor, so the facade still reports it — git-like "I'm
1940        // still on my branch". Under the old strict `current_bookmark(@)` rule
1941        // this returned `None`; feeding the reachable template (`feat\t…`,
1942        // unparseable as a comma-joined bookmark name) pins the new derivation.
1943        let repo = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("feat\tc8d49332\n")));
1944        assert_eq!(
1945            repo.current_branch().await.unwrap().as_deref(),
1946            Some("feat")
1947        );
1948    }
1949
1950    #[tokio::test]
1951    async fn jj_current_branch_tie_break_is_deterministic() {
1952        // `heads(::@ & bookmarks())` can yield several equally-near bookmarks —
1953        // a merge of two bookmarked lines (one row each) or one commit carrying
1954        // several (one row, space-joined). current_branch returns the
1955        // lexicographically-smallest name regardless of jj's row order, so the
1956        // result is stable. Here: rows `zeta` then `alpha beta` ⇒ `alpha`.
1957        let repo = jj_repo(ScriptedRunner::new().on(
1958            ["jj", "log"],
1959            Reply::ok("zeta\tabc1234\nalpha beta\tdef5678\n"),
1960        ));
1961        assert_eq!(
1962            repo.current_branch().await.unwrap().as_deref(),
1963            Some("alpha")
1964        );
1965    }
1966
1967    #[tokio::test]
1968    async fn jj_local_branches_maps_bookmark_list() {
1969        // BOOKMARK_LIST_TEMPLATE rows: `name\t<commit>`.
1970        let repo = jj_repo(ScriptedRunner::new().on(
1971            ["jj", "bookmark", "list"],
1972            Reply::ok("main\tcmt\nfeat\tm2\n"),
1973        ));
1974        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
1975    }
1976
1977    #[tokio::test]
1978    async fn jj_branch_exists_scans_bookmarks() {
1979        let repo =
1980            jj_repo(ScriptedRunner::new().on(["jj", "bookmark", "list"], Reply::ok("main\tcmt\n")));
1981        assert!(repo.branch_exists("main").await.unwrap());
1982        let repo2 =
1983            jj_repo(ScriptedRunner::new().on(["jj", "bookmark", "list"], Reply::ok("main\tcmt\n")));
1984        assert!(!repo2.branch_exists("missing").await.unwrap());
1985    }
1986
1987    #[tokio::test]
1988    async fn jj_has_uncommitted_changes_reads_empty_flag() {
1989        // CHANGE_TEMPLATE row: change_id \t commit_id \t empty \t description
1990        let dirty =
1991            jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\tfalse\twip\n")));
1992        assert!(dirty.has_uncommitted_changes().await.unwrap());
1993        let clean = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\ttrue\t\n")));
1994        assert!(!clean.has_uncommitted_changes().await.unwrap());
1995    }
1996
1997    // M18: a conflicted-but-**empty** `@` is uncommitted state (it needs resolution),
1998    // so `has_uncommitted_changes` returns true — agreeing with `snapshot().dirty`,
1999    // which already treats `conflict ⇒ dirty`. First `jj log` = current_change (empty),
2000    // second = is_conflicted (`"1"`).
2001    #[tokio::test]
2002    async fn jj_has_uncommitted_changes_true_when_conflicted_even_if_empty() {
2003        let repo = jj_repo(ScriptedRunner::new().on_sequence(
2004            ["jj", "log"],
2005            [
2006                Reply::ok("kz\t38\ttrue\t\n"), // current_change: empty = true
2007                Reply::ok("1\n"),              // is_conflicted: conflicted
2008            ],
2009        ));
2010        assert!(
2011            repo.has_uncommitted_changes().await.unwrap(),
2012            "a conflicted empty @ is dirty"
2013        );
2014    }
2015
2016    #[tokio::test]
2017    async fn jj_changed_files_maps_diff_summary() {
2018        let repo = jj_repo(
2019            ScriptedRunner::new().on(["jj", "diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
2020        );
2021        let changes = repo.changed_files().await.unwrap();
2022        assert_eq!(changes.len(), 3);
2023        assert_eq!(changes[0].kind, ChangeKind::Modified);
2024        assert_eq!(changes[1].kind, ChangeKind::Added);
2025        assert_eq!(changes[2].kind, ChangeKind::Deleted);
2026        assert!(changes.iter().all(|c| c.old_path.is_none()));
2027    }
2028
2029    // jj DOES supply the rename's original path (its `{old => new}` summary
2030    // form) — `old_path` is populated on both backends, as the DTO documents.
2031    #[tokio::test]
2032    async fn jj_changed_files_populates_rename_old_path() {
2033        let repo = jj_repo(
2034            ScriptedRunner::new().on(["jj", "diff"], Reply::ok("R src/{old.rs => new.rs}\n")),
2035        );
2036        let changes = repo.changed_files().await.unwrap();
2037        assert_eq!(changes.len(), 1);
2038        assert_eq!(changes[0].kind, ChangeKind::Renamed);
2039        assert_eq!(changes[0].path, "src/new.rs");
2040        assert_eq!(changes[0].old_path.as_deref(), Some("src/old.rs"));
2041    }
2042
2043    // `commit_paths(&[])` is refused up front on BOTH backends: the runners have
2044    // no rules, so reaching the CLI would error differently — the guard must trip
2045    // first (on jj an empty fileset would otherwise commit the whole working
2046    // copy; on git it would exit 128).
2047    #[tokio::test]
2048    async fn commit_paths_refuses_an_empty_path_set() {
2049        for repo in [
2050            git_repo(ScriptedRunner::new()),
2051            jj_repo(ScriptedRunner::new()),
2052        ] {
2053            let err = repo
2054                .commit_paths(&[], "msg")
2055                .await
2056                .expect_err("empty paths must be refused");
2057            assert!(
2058                err.to_string().contains("at least one path"),
2059                "unexpected error: {err}"
2060            );
2061        }
2062    }
2063
2064    #[tokio::test]
2065    async fn jj_rename_branch_builds_bookmark_rename() {
2066        use processkit::testing::RecordingRunner;
2067        let rec = RecordingRunner::replying(Reply::ok(""));
2068        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
2069        repo.rename_branch("old", "new").await.unwrap();
2070        assert_eq!(
2071            rec.only_call().args_str(),
2072            ["bookmark", "rename", "old", "new", "--color", "never"]
2073        );
2074    }
2075
2076    // The widened common surface dispatches `checkout` to each backend's verb:
2077    // git `checkout`, jj `edit`.
2078    #[tokio::test]
2079    async fn checkout_dispatches_per_backend() {
2080        use processkit::testing::RecordingRunner;
2081        let grec = RecordingRunner::replying(Reply::ok(""));
2082        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2083            .checkout("feat")
2084            .await
2085            .unwrap();
2086        // Trailing `--` so a path-like ref can't fall into pathspec mode (C2).
2087        assert_eq!(grec.only_call().args_str(), ["checkout", "feat", "--"]);
2088
2089        let jrec = RecordingRunner::replying(Reply::ok(""));
2090        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2091            .checkout("feat")
2092            .await
2093            .unwrap();
2094        assert_eq!(
2095            jrec.only_call().args_str(),
2096            ["edit", "feat", "--color", "never"]
2097        );
2098    }
2099
2100    // A1: `delete_branch` takes a `BranchDelete` spec; `.force()` threads through to
2101    // git's `-D` (vs `-d`), and jj ignores it (its `bookmark delete` has no force).
2102    #[tokio::test]
2103    async fn delete_branch_spec_threads_force_to_git_only() {
2104        use processkit::testing::RecordingRunner;
2105        let forced = RecordingRunner::replying(Reply::ok(""));
2106        Repo::from_git("/repo", "/repo", Git::with_runner(&forced))
2107            .delete_branch(BranchDelete::new("feat").force())
2108            .await
2109            .unwrap();
2110        assert!(
2111            forced.only_call().args_str().iter().any(|a| a == "-D"),
2112            "force → branch -D"
2113        );
2114
2115        let unforced = RecordingRunner::replying(Reply::ok(""));
2116        Repo::from_git("/repo", "/repo", Git::with_runner(&unforced))
2117            .delete_branch(BranchDelete::new("feat"))
2118            .await
2119            .unwrap();
2120        assert!(
2121            unforced.only_call().args_str().iter().any(|a| a == "-d"),
2122            "no force → branch -d"
2123        );
2124
2125        let jj = RecordingRunner::replying(Reply::ok(""));
2126        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jj))
2127            .delete_branch(BranchDelete::new("feat").force())
2128            .await
2129            .unwrap();
2130        assert!(
2131            !jj.only_call()
2132                .args_str()
2133                .iter()
2134                .any(|a| a == "-D" || a == "--force"),
2135            "jj bookmark delete has no force flag"
2136        );
2137    }
2138
2139    #[tokio::test]
2140    async fn fetch_branch_dispatches_per_backend() {
2141        use processkit::testing::RecordingRunner;
2142        let grec = RecordingRunner::replying(Reply::ok(""));
2143        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2144            .fetch_branch("main")
2145            .await
2146            .unwrap();
2147        assert!(
2148            grec.only_call()
2149                .args_str()
2150                .starts_with(&["fetch".to_string()])
2151        );
2152
2153        let jrec = RecordingRunner::replying(Reply::ok(""));
2154        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2155            .fetch_branch("main")
2156            .await
2157            .unwrap();
2158        let args = jrec.only_call().args_str();
2159        assert_eq!(&args[..2], &["git", "fetch"]);
2160    }
2161
2162    // The facade push is the honest LCD: git pushes the ref with `-u origin`,
2163    // jj pushes the bookmark's state with `-b`. Argv pinned on both backends.
2164    #[tokio::test]
2165    async fn push_dispatches_per_backend() {
2166        use processkit::testing::RecordingRunner;
2167        let grec = RecordingRunner::replying(Reply::ok(""));
2168        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2169            .push("feature")
2170            .await
2171            .unwrap();
2172        assert_eq!(
2173            grec.only_call().args_str(),
2174            ["push", "-u", "origin", "feature"]
2175        );
2176
2177        let jrec = RecordingRunner::replying(Reply::ok(""));
2178        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2179            .push("feature")
2180            .await
2181            .unwrap();
2182        let args = jrec.only_call().args_str();
2183        // `exact:` disables jj's glob matching so a `*` can't push every bookmark (H1).
2184        assert_eq!(&args[..4], &["git", "push", "-b", "exact:feature"]);
2185    }
2186
2187    // The two backends handle a flag-like branch per the documented guard
2188    // convention: git rejects it BEFORE spawning (the branch lands in GitPush's
2189    // bare-positional refspec slot, where `--force` would otherwise be parsed
2190    // as a flag); jj passes it verbatim in the `-b` flag-VALUE slot, where jj
2191    // reads it as a bookmark name and errors itself — no flag injection is
2192    // possible there, so no pre-spawn guard exists (same as rebase/fetch_from).
2193    #[tokio::test]
2194    async fn push_flag_like_branch_follows_guard_convention() {
2195        use processkit::testing::RecordingRunner;
2196        let grec = RecordingRunner::replying(Reply::ok(""));
2197        let err = Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2198            .push("--force")
2199            .await
2200            .unwrap_err();
2201        assert!(
2202            matches!(err, Error::Vcs(processkit::Error::Spawn { .. })),
2203            "got: {err:?}"
2204        );
2205        assert_eq!(grec.calls().len(), 0, "no process must have spawned");
2206
2207        let jrec = RecordingRunner::replying(Reply::ok(""));
2208        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2209            .push("--force")
2210            .await
2211            .expect("jj path spawns; the value rides -b verbatim");
2212        assert_eq!(
2213            &jrec.only_call().args_str()[..4],
2214            &["git", "push", "-b", "exact:--force"],
2215            "the flag-like value must ride the -b flag-VALUE slot, not become argv"
2216        );
2217    }
2218
2219    #[tokio::test]
2220    async fn fetch_from_names_the_remote_on_both_backends() {
2221        use processkit::testing::RecordingRunner;
2222        let grec = RecordingRunner::replying(Reply::ok(""));
2223        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2224            .fetch_from("upstream")
2225            .await
2226            .unwrap();
2227        assert_eq!(
2228            grec.only_call().args_str(),
2229            ["fetch", "--quiet", "upstream"]
2230        );
2231
2232        let jrec = RecordingRunner::replying(Reply::ok(""));
2233        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2234            .fetch_from("upstream")
2235            .await
2236            .unwrap();
2237        let args = jrec.only_call().args_str();
2238        // `exact:` disables jj's glob matching on the remote name (H1).
2239        assert_eq!(&args[..4], &["git", "fetch", "--remote", "exact:upstream"]);
2240    }
2241
2242    // git: untracked files count as uncommitted but not as *tracked* changes.
2243    #[tokio::test]
2244    async fn git_has_tracked_changes_ignores_untracked() {
2245        let dirty = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0")));
2246        assert!(dirty.has_tracked_changes().await.unwrap());
2247        // `--untracked-files=no` means git itself omits `??` entries; an empty
2248        // reply is what a tracked-clean tree returns.
2249        let clean = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok("")));
2250        assert!(!clean.has_tracked_changes().await.unwrap());
2251    }
2252
2253    // jj has no untracked concept — `has_tracked_changes` follows `@`'s emptiness.
2254    #[tokio::test]
2255    async fn jj_has_tracked_changes_follows_working_copy() {
2256        let dirty =
2257            jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\tfalse\twip\n")));
2258        assert!(dirty.has_tracked_changes().await.unwrap());
2259    }
2260
2261    #[tokio::test]
2262    async fn conflicted_files_dispatches_per_backend() {
2263        let git =
2264            git_repo(ScriptedRunner::new().on(["git", "diff"], Reply::ok("a.rs\0b dir/c.rs\0")));
2265        assert_eq!(
2266            git.conflicted_files().await.unwrap(),
2267            ["a.rs", "b dir/c.rs"]
2268        );
2269
2270        let jj = jj_repo(
2271            ScriptedRunner::new().on(["jj", "resolve"], Reply::ok("a.rs    2-sided conflict\n")),
2272        );
2273        assert_eq!(jj.conflicted_files().await.unwrap(), ["a.rs"]);
2274        // The benign "no conflicts" non-zero exit still reads as an empty list.
2275        let clean = jj_repo(ScriptedRunner::new().on(
2276            ["jj", "resolve"],
2277            Reply::fail(2, "Error: No conflicts found at this revision"),
2278        ));
2279        assert!(clean.conflicted_files().await.unwrap().is_empty());
2280    }
2281
2282    #[test]
2283    fn merge_probe_is_clean() {
2284        assert!(MergeProbe::Clean.is_clean());
2285        assert!(!MergeProbe::Conflicts(vec!["a.rs".into()]).is_clean());
2286    }
2287
2288    // git try_merge, clean: probe merge, no MERGE_HEAD afterwards (the scripted
2289    // git-dir doesn't exist) → no abort, `Clean`.
2290    #[tokio::test]
2291    async fn git_try_merge_reports_clean_and_skips_needless_abort() {
2292        use processkit::testing::RecordingRunner;
2293        let rec = RecordingRunner::new(
2294            ScriptedRunner::new()
2295                .on(["git", "merge"], Reply::ok("Already up to date.\n"))
2296                .on(["git", "rev-parse"], Reply::ok("/vcs-core-no-such-git-dir")),
2297        );
2298        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2299        assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
2300        assert!(
2301            rec.calls()
2302                .iter()
2303                .all(|c| !c.args_str().contains(&"--abort".to_string())),
2304            "no merge to abort"
2305        );
2306    }
2307
2308    // git try_merge, conflict: conflicted paths are read BEFORE the abort (abort
2309    // clears the unmerged index), then the merge is aborted.
2310    #[tokio::test]
2311    async fn git_try_merge_collects_conflicts_then_aborts() {
2312        use processkit::testing::RecordingRunner;
2313        let rec = RecordingRunner::new(
2314            ScriptedRunner::new()
2315                // Order matters: ["merge","--abort"] must outrank the ["merge"] rule.
2316                .on(["git", "merge", "--abort"], Reply::ok(""))
2317                .on(
2318                    ["git", "merge"],
2319                    Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
2320                )
2321                .on(["git", "diff"], Reply::ok("a.rs\0")),
2322        );
2323        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2324        assert_eq!(
2325            repo.try_merge("other").await.unwrap(),
2326            MergeProbe::Conflicts(vec!["a.rs".to_string()])
2327        );
2328        let calls = rec.calls();
2329        let diff_pos = calls.iter().position(|c| c.args_str()[0] == "diff");
2330        let abort_pos = calls
2331            .iter()
2332            .position(|c| c.args_str().contains(&"--abort".to_string()));
2333        assert!(diff_pos.unwrap() < abort_pos.unwrap(), "{calls:?}");
2334    }
2335
2336    // git try_merge: a failing rollback must propagate, not be reported as a
2337    // clean/conflicted probe.
2338    #[tokio::test]
2339    async fn git_try_merge_propagates_abort_failure() {
2340        let tmp = TempDir::new("probe-abort");
2341        std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
2342        let repo = git_repo(
2343            ScriptedRunner::new()
2344                .on(
2345                    ["git", "merge", "--abort"],
2346                    Reply::fail(128, "fatal: cannot abort"),
2347                )
2348                .on(["git", "merge"], Reply::ok(""))
2349                .on(
2350                    ["git", "rev-parse"],
2351                    Reply::ok(tmp.path().to_str().unwrap()),
2352                ),
2353        );
2354        assert!(repo.try_merge("other").await.is_err());
2355    }
2356
2357    // jj try_merge: op head captured first, probe runs, op restore always runs.
2358    #[tokio::test]
2359    async fn jj_try_merge_probes_and_restores() {
2360        use processkit::testing::RecordingRunner;
2361        let rec = RecordingRunner::new(
2362            ScriptedRunner::new()
2363                .on(["jj", "op", "log"], Reply::ok("op42\n"))
2364                .on(["jj", "op", "restore"], Reply::ok(""))
2365                .on(["jj", "new"], Reply::ok(""))
2366                .on(["jj", "log"], Reply::ok("1\n")) // is_conflicted → true
2367                .on(["jj", "resolve"], Reply::ok("a.rs    2-sided conflict\n")),
2368        );
2369        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
2370        assert_eq!(
2371            repo.try_merge("feature").await.unwrap(),
2372            MergeProbe::Conflicts(vec!["a.rs".to_string()])
2373        );
2374        let calls = rec.calls();
2375        assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
2376        assert_eq!(calls[1].args_str()[0], "new");
2377        let last = calls.last().unwrap().args_str();
2378        assert_eq!(last[..3], ["op", "restore", "op42"]);
2379    }
2380
2381    #[tokio::test]
2382    async fn jj_try_merge_clean_and_restore_failure() {
2383        // Conflict-free probe → Clean (no resolve call needed).
2384        let clean = jj_repo(
2385            ScriptedRunner::new()
2386                .on(["jj", "op", "log"], Reply::ok("op42\n"))
2387                .on(["jj", "op", "restore"], Reply::ok(""))
2388                .on(["jj", "new"], Reply::ok(""))
2389                .on(["jj", "log"], Reply::ok("0\n")),
2390        );
2391        assert_eq!(clean.try_merge("feature").await.unwrap(), MergeProbe::Clean);
2392
2393        // A failing op restore breaks the rollback guarantee → error, not Clean.
2394        let broken = jj_repo(
2395            ScriptedRunner::new()
2396                .on(["jj", "op", "log"], Reply::ok("op42\n"))
2397                .on(["jj", "op", "restore"], Reply::fail(1, "op not found"))
2398                .on(["jj", "new"], Reply::ok(""))
2399                .on(["jj", "log"], Reply::ok("0\n")),
2400        );
2401        assert!(broken.try_merge("feature").await.is_err());
2402    }
2403
2404    // continue_in_progress with unresolved paths reports `Conflict` and must NOT
2405    // attempt the continue (git would hard-error).
2406    #[tokio::test]
2407    async fn git_continue_blocked_by_conflicts_does_not_act() {
2408        use processkit::testing::RecordingRunner;
2409        let rec =
2410            RecordingRunner::new(ScriptedRunner::new().on(["git", "diff"], Reply::ok("a.rs\0")));
2411        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2412        assert_eq!(
2413            repo.continue_in_progress().await.unwrap(),
2414            OperationState::Conflict
2415        );
2416        assert!(
2417            rec.calls().iter().all(|c| c.args_str()[0] == "diff"),
2418            "only the conflict probe may run: {:?}",
2419            rec.calls()
2420        );
2421    }
2422
2423    // A continued rebase that stops on the NEXT patch's conflict exits non-zero;
2424    // continue_in_progress must report that as `Conflict`, not as an error. The
2425    // first conflict probe must see a clean index (else continue is blocked), the
2426    // post-continue probe must see the new conflict — a stateful predicate
2427    // sequences the two `diff` replies.
2428    #[tokio::test]
2429    async fn git_continue_maps_rebase_re_conflict() {
2430        use std::sync::Arc as StdArc;
2431        use std::sync::atomic::{AtomicBool, Ordering};
2432        let tmp = TempDir::new("rebase-restop");
2433        std::fs::create_dir_all(tmp.path().join("rebase-merge")).unwrap();
2434        let seen_first_diff = StdArc::new(AtomicBool::new(false));
2435        let flag = StdArc::clone(&seen_first_diff);
2436        let repo = git_repo(
2437            ScriptedRunner::new()
2438                .when(
2439                    move |cmd| {
2440                        cmd.arguments().first().and_then(|a| a.to_str()) == Some("diff")
2441                            && flag.swap(true, Ordering::SeqCst)
2442                    },
2443                    Reply::ok("a.rs\0"),
2444                )
2445                .on(["git", "diff"], Reply::ok(""))
2446                .on(
2447                    ["git", "rev-parse"],
2448                    Reply::ok(tmp.path().to_str().unwrap()),
2449                )
2450                .on(
2451                    ["git", "rebase", "--continue"],
2452                    Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
2453                ),
2454        );
2455        assert_eq!(
2456            repo.continue_in_progress().await.unwrap(),
2457            OperationState::Conflict
2458        );
2459    }
2460
2461    // abort_in_progress dispatches to `merge --abort` when MERGE_HEAD is present.
2462    #[tokio::test]
2463    async fn git_abort_dispatches_on_merge_in_progress() {
2464        use processkit::testing::RecordingRunner;
2465        let tmp = TempDir::new("abort");
2466        std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
2467        let rec = RecordingRunner::new(
2468            ScriptedRunner::new()
2469                .on(
2470                    ["git", "rev-parse"],
2471                    Reply::ok(tmp.path().to_str().unwrap()),
2472                )
2473                .on(["git", "merge", "--abort"], Reply::ok("")),
2474        );
2475        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2476        repo.abort_in_progress().await.unwrap();
2477        assert!(
2478            rec.calls()
2479                .iter()
2480                .any(|c| c.args_str() == ["merge", "--abort"]),
2481            "{:?}",
2482            rec.calls()
2483        );
2484    }
2485
2486    // git surfaces an interrupted op as on-disk state: in_progress_state returns
2487    // Merge when MERGE_HEAD is present and Rebase when a rebase dir is — the
2488    // documented asymmetry (git's conflict IS that paused state, never `Conflict`
2489    // from this method).
2490    #[tokio::test]
2491    async fn git_in_progress_state_maps_merge_and_rebase() {
2492        let merging = TempDir::new("inprog-merge");
2493        std::fs::write(merging.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
2494        let merge_repo = Repo::from_git(
2495            "/repo",
2496            "/repo",
2497            Git::with_runner(ScriptedRunner::new().on(
2498                ["git", "rev-parse"],
2499                Reply::ok(merging.path().to_str().unwrap()),
2500            )),
2501        );
2502        assert_eq!(
2503            merge_repo.in_progress_state().await.unwrap(),
2504            OperationState::Merge
2505        );
2506
2507        let rebasing = TempDir::new("inprog-rebase");
2508        std::fs::create_dir_all(rebasing.path().join("rebase-merge")).unwrap();
2509        let rebase_repo = Repo::from_git(
2510            "/repo",
2511            "/repo",
2512            Git::with_runner(ScriptedRunner::new().on(
2513                ["git", "rev-parse"],
2514                Reply::ok(rebasing.path().to_str().unwrap()),
2515            )),
2516        );
2517        assert_eq!(
2518            rebase_repo.in_progress_state().await.unwrap(),
2519            OperationState::Rebase
2520        );
2521    }
2522
2523    // On an unborn git repo (no commits) diff_stat probes is_unborn and stats
2524    // against the empty tree instead of the unresolvable HEAD, so a fresh working
2525    // tree reports its additions rather than erroring.
2526    #[tokio::test]
2527    async fn git_diff_stat_unborn_uses_empty_tree() {
2528        use processkit::testing::RecordingRunner;
2529        let rec = RecordingRunner::new(
2530            ScriptedRunner::new()
2531                .on(["git", "rev-parse"], Reply::fail(1, "")) // HEAD unborn
2532                .on(
2533                    ["git", "diff", "--shortstat"],
2534                    Reply::ok(" 1 file changed, 2 insertions(+)\n"),
2535                ),
2536        );
2537        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2538        let stat = repo.diff_stat().await.unwrap();
2539        assert_eq!(stat.insertions, 2);
2540        assert!(
2541            rec.calls()
2542                .iter()
2543                .any(|c| c.args_str() == ["diff", "--shortstat", vcs_git::EMPTY_TREE, "--"]),
2544            "diff_stat should target the empty tree on an unborn repo: {:?}",
2545            rec.calls()
2546        );
2547    }
2548
2549    // On jj, abort/continue are reporting no-ops (nothing is ever paused).
2550    #[tokio::test]
2551    async fn jj_abort_and_continue_are_reporting_noops() {
2552        let conflicted = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
2553        assert_eq!(
2554            conflicted.abort_in_progress().await.unwrap(),
2555            OperationState::Conflict
2556        );
2557        let clear = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
2558        assert_eq!(
2559            clear.continue_in_progress().await.unwrap(),
2560            OperationState::Clear
2561        );
2562    }
2563
2564    // jj records conflicts on the change; the facade maps that to `Conflict`.
2565    #[tokio::test]
2566    async fn jj_in_progress_state_maps_conflict() {
2567        let conflicted = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
2568        assert_eq!(
2569            conflicted.in_progress_state().await.unwrap(),
2570            OperationState::Conflict
2571        );
2572        let clear = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
2573        assert_eq!(
2574            clear.in_progress_state().await.unwrap(),
2575            OperationState::Clear
2576        );
2577    }
2578
2579    // `&dyn VcsRepo` must dispatch through the real inherent methods (a delegating
2580    // body that recursed would stack-overflow here instead of returning).
2581    #[tokio::test]
2582    async fn vcs_repo_trait_object_dispatches() {
2583        let repo = git_repo(
2584            ScriptedRunner::new()
2585                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
2586                .on(["git", "show-ref"], Reply::ok("")),
2587        );
2588        let dynamic: &dyn VcsRepo = &repo;
2589        assert_eq!(dynamic.kind(), BackendKind::Git);
2590        assert_eq!(
2591            dynamic.current_branch().await.unwrap().as_deref(),
2592            Some("main")
2593        );
2594        // Exercise a reference-argument async method through `&dyn` — pins the
2595        // async_trait lifetime capture the macro relies on (no-arg calls don't).
2596        assert!(dynamic.branch_exists("main").await.unwrap());
2597    }
2598
2599    // When the backend has no native trunk (git `origin/HEAD` unset), the facade
2600    // falls back to a local `main`, then `master`.
2601    #[tokio::test]
2602    async fn trunk_falls_back_to_main() {
2603        let repo = git_repo(
2604            ScriptedRunner::new()
2605                .on(["git", "symbolic-ref"], Reply::fail(1, "")) // origin/HEAD unset → None
2606                .on(["git", "show-ref"], Reply::ok("")), // branch_exists("main") → exit 0
2607        );
2608        assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
2609    }
2610
2611    #[test]
2612    fn error_classifiers_recognise_markers() {
2613        let conflict = Error::Vcs(processkit::Error::Exit {
2614            program: "git".into(),
2615            code: 1,
2616            stdout: "CONFLICT (content): Merge conflict in a.rs".into(),
2617            stderr: String::new(),
2618        });
2619        assert!(conflict.is_merge_conflict());
2620        assert!(!conflict.is_nothing_to_commit());
2621        // A non-Vcs error classifies as none of them.
2622        assert!(!Error::NotARepository("/x".into()).is_merge_conflict());
2623    }
2624}
2625
2626// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
2627#[doc = include_str!("../docs/core.md")]
2628#[allow(rustdoc::broken_intra_doc_links)]
2629pub mod guide {
2630    #[doc = include_str!("../docs/cookbook.md")]
2631    #[allow(rustdoc::broken_intra_doc_links)]
2632    pub mod cookbook {}
2633    #[doc = include_str!("../docs/process-model.md")]
2634    #[allow(rustdoc::broken_intra_doc_links)]
2635    pub mod process_model {}
2636    #[doc = include_str!("../docs/positioning.md")]
2637    #[allow(rustdoc::broken_intra_doc_links)]
2638    pub mod positioning {}
2639    #[doc = include_str!("../docs/stability.md")]
2640    #[allow(rustdoc::broken_intra_doc_links)]
2641    pub mod stability {}
2642}