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, Commit, CreateOutcome, DiffStat, FileChange, MergeProbe,
189    OperationState, RepoSnapshot, UpstreamTracking, WorktreeCreate, WorktreeCreatePartial,
190    WorktreeInfo, WorktreeRemove,
191};
192pub use error::{Error, Result};
193// The shared output-budget knob (from the CLI-support plumbing, via `vcs-git`): a
194// per-client default ([`Repo::from_git`]/[`from_jj`] over a client built with
195// `default_output_budget`) or a per-call override
196// ([`Repo::show_file_within`](Repo::show_file_within)) for the content read this
197// facade exposes. `vcs-git` and `vcs-jj` re-export the same type.
198pub use vcs_git::OutputBudget;
199
200// Re-export the underlying typed clients so a consumer depending only on
201// `vcs-core` can still reach raw, tool-specific operations — and their types
202// (`GitApi`, `JjApi`, `WorktreeAdd`, `JjFileset`, …) — without adding `vcs-git`
203// / `vcs-jj` as separate dependencies. [`Repo::git`] / [`Repo::jj`] hand out
204// borrows of these clients; the consumer decides, per call, whether to go
205// through the facade or straight to the tool.
206pub use vcs_git;
207pub use vcs_jj;
208// Re-export `processkit` itself so a `vcs-core`-only consumer can name the
209// wrapped error directly — `match err { Error::Vcs(vcs_core::processkit::Error::
210// Timeout { .. }) => … }` — and reach `Outcome`/`CancellationToken`/… without
211// adding `processkit` as a separate dependency. (`Error::Vcs` carries a
212// `processkit::Error`; the classifiers below cover the common branches.)
213pub use processkit;
214// Also surfaced at the crate root so the token a `default_cancel_on` client takes
215// (built via `Git`/`Jj`, then passed to `Repo::from_git`/`from_jj`) is one name
216// away. (Cancellation is core in processkit 0.10 — always available, no feature.)
217pub use processkit::CancellationToken;
218
219/// The result of [`discover`]: which backend, and the repository root it was
220/// found at.
221#[derive(Debug, Clone, PartialEq, Eq)]
222#[non_exhaustive]
223pub struct Located {
224    /// The detected backend.
225    pub kind: BackendKind,
226    /// The directory holding `.git`/`.jj` — the worktree root.
227    pub root: PathBuf,
228}
229
230/// Walk up from `start` to the filesystem root looking for a repository. A `.jj`
231/// directory wins over `.git` (colocated repos are driven through jj); `.git` may
232/// be a directory or a gitlink file (a linked worktree/submodule). Pure
233/// filesystem probing — no subprocess.
234///
235/// `start` is walked exactly as given via [`Path::parent`], so pass an **absolute**
236/// path to search ancestors — a relative path like `"."` has no ancestor chain
237/// and only its own directory is checked. ([`Repo::discover`] absolutises for
238/// you.) See [`Repo::open`] for a strict, non-walking check of exactly one
239/// directory.
240pub fn discover(start: &Path) -> Option<Located> {
241    let mut current = Some(start);
242    while let Some(dir) = current {
243        if is_jj_marker(&dir.join(".jj")) {
244            return Some(Located {
245                kind: BackendKind::Jj,
246                root: dir.to_path_buf(),
247            });
248        }
249        if is_git_marker(&dir.join(".git")) {
250            return Some(Located {
251                kind: BackendKind::Git,
252                root: dir.to_path_buf(),
253            });
254        }
255        current = dir.parent();
256    }
257    None
258}
259
260/// Whether `path` (a candidate `.jj`) is a real jj repository marker — a `.jj`
261/// **directory** that contains a **`repo`** entry (the store: a *directory* in a
262/// repo's main workspace / a colocated repo, a *file* pointer in a secondary
263/// workspace). A stray/empty directory merely *named* `.jj` (e.g. a leftover
264/// `mkdir .jj`) has no `repo` entry, so it can't shadow a healthy `.git` repo in the
265/// same or a higher directory (M19). Symmetric with [`is_git_marker`]: both require a
266/// *valid* marker, not mere existence.
267fn is_jj_marker(path: &Path) -> bool {
268    path.is_dir() && path.join("repo").exists()
269}
270
271/// Whether `path` (a candidate `.git`) is a real git repository marker — a `.git`
272/// **directory**, or a **gitlink file** (a linked worktree / submodule) whose
273/// content starts with `gitdir:`. A stray/garbage file merely *named* `.git` is
274/// rejected, so it can't shadow a real repository higher up the tree, and a binary
275/// or unreadable file is rejected too (the read fails → `false`). Symmetric with
276/// [`is_jj_marker`]: both require a *valid* marker, not mere existence.
277fn is_git_marker(path: &Path) -> bool {
278    use std::io::Read;
279    match std::fs::metadata(path) {
280        Ok(meta) if meta.is_dir() => true,
281        Ok(meta) if meta.is_file() => {
282            // A gitlink file is tiny (`gitdir: <path>\n`), so read only a small
283            // prefix: `discover` walks *up to the filesystem root*, so a huge/garbage
284            // file merely named `.git` in an ancestor we don't own must not force an
285            // unbounded read. `read_to_end` loops over short reads (unlike a single
286            // `read`, which the `Read` contract lets return fewer bytes), and
287            // `from_utf8_lossy` tolerates a binary file or a multibyte char split at
288            // the cap — the `gitdir:` marker is ASCII and within the first bytes.
289            let Ok(file) = std::fs::File::open(path) else {
290                return false;
291            };
292            let mut buf = Vec::new();
293            let _ = file.take(32).read_to_end(&mut buf);
294            String::from_utf8_lossy(&buf)
295                .trim_start()
296                .starts_with("gitdir:")
297        }
298        _ => false,
299    }
300}
301
302/// Whether `dir` (the candidate itself, not a `.git` beneath it) is a **bare**
303/// git repository — created with `git init --bare` (or an equivalent bare
304/// clone): `HEAD`/`config`/`objects`/`refs` sit directly in `dir`, with no
305/// `.git` subdirectory. Requires all four markers together (`HEAD` a file,
306/// `config` a file, `objects`/`refs` directories) so a directory that merely
307/// happens to contain one or two similarly-named entries isn't misdetected —
308/// symmetric with [`is_jj_marker`]/[`is_git_marker`]: a *valid* marker, not
309/// mere partial name overlap. Used to give bare repositories their own
310/// [`Error::BareRepository`](crate::Error::BareRepository) instead of the
311/// generic [`Error::NotARepository`](crate::Error::NotARepository) (issue #6).
312fn is_bare_git_repo_marker(dir: &Path) -> bool {
313    dir.join("HEAD").is_file()
314        && dir.join("config").is_file()
315        && dir.join("objects").is_dir()
316        && dir.join("refs").is_dir()
317}
318
319/// Walk up from `start` to the filesystem root looking for a **bare** git
320/// repository marker (see [`is_bare_git_repo_marker`]). Only called after
321/// [`discover`] has already walked the same chain and found no `.jj`/`.git`, so
322/// any hit here is unambiguous — no real (non-bare) repository intervenes
323/// between `start` and the bare repository root.
324fn find_bare_git_repo(start: &Path) -> Option<PathBuf> {
325    let mut current = Some(start);
326    while let Some(dir) = current {
327        if is_bare_git_repo_marker(dir) {
328            return Some(dir.to_path_buf());
329        }
330        current = dir.parent();
331    }
332    None
333}
334
335/// The per-tool client behind a [`Repo`]. Shared via `Arc` so [`Repo::at`] can
336/// re-anchor the cwd cheaply without rebuilding the client.
337enum Backend<R: ProcessRunner> {
338    Git(Arc<Git<R>>),
339    Jj(Arc<Jj<R>>),
340}
341
342impl<R: ProcessRunner> Debug for Backend<R> {
343    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
344        let variant_name = match self {
345            Backend::Git(_) => "Git",
346            Backend::Jj(_) => "Jj",
347        };
348        f.debug_tuple(variant_name).finish_non_exhaustive()
349    }
350}
351
352impl<R: ProcessRunner> Backend<R> {
353    fn shared(&self) -> Self {
354        match self {
355            Backend::Git(g) => Backend::Git(Arc::clone(g)),
356            Backend::Jj(j) => Backend::Jj(Arc::clone(j)),
357        }
358    }
359}
360
361/// A cwd-bound, backend-agnostic VCS handle. Operations run against the bound
362/// directory ([`cwd`](Repo::cwd)); use [`at`](Repo::at) to get a sibling handle
363/// bound elsewhere.
364pub struct Repo<R: ProcessRunner = JobRunner> {
365    root: PathBuf,
366    cwd: PathBuf,
367    backend: Backend<R>,
368}
369// need a manual impl to avoid `R: Debug` bound.
370impl<R: ProcessRunner> Debug for Repo<R> {
371    fn fmt(&self, f: &mut Formatter) -> fmt::Result {
372        let Repo { root, cwd, backend } = self;
373        f.debug_struct("Repo")
374            .field("root", root)
375            .field("cwd", cwd)
376            .field("backend", backend)
377            .finish()
378    }
379}
380
381impl Repo<JobRunner> {
382    /// Discover the repository at or above `dir` and open a handle bound to
383    /// `dir`, using the real job-backed runner. Walks up from `dir` toward the
384    /// filesystem root — see [`discover`] — so it finds a repository whose root
385    /// is `dir` itself or any ancestor. Errors with [`Error::NotARepository`]
386    /// when no `.git`/`.jj` is found, or with [`Error::BareRepository`] when the
387    /// walk instead reaches a **bare** git repository (`git init --bare`) before
388    /// any `.jj`/`.git` — a bare repo has no working tree for this facade to
389    /// drive (issue #6).
390    ///
391    /// For a strict check of exactly `dir` — no walking up — see [`Repo::open`].
392    pub fn discover(dir: impl AsRef<Path>) -> Result<Self> {
393        // Absolutise first: `discover` walks parents, and a relative path like "."
394        // has no real ancestor chain (`Path::new(".").parent()` is `""`, then
395        // `None`), so a relative input would never find a repo above the cwd.
396        let dir = std::path::absolute(dir.as_ref())?;
397        let located = match discover(&dir) {
398            Some(located) => located,
399            None => {
400                // `discover` already walked the full chain and found nothing — a
401                // second, cheap walk tells us whether the reason is "no
402                // repository at all" or "a bare git repository sits in the
403                // way", so the caller gets the more precise error.
404                return Err(match find_bare_git_repo(&dir) {
405                    Some(bare_root) => Error::BareRepository(bare_root),
406                    None => Error::NotARepository(dir),
407                });
408            }
409        };
410        let backend = match located.kind {
411            BackendKind::Git => Backend::Git(Arc::new(Git::new())),
412            BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
413        };
414        Ok(Repo {
415            root: located.root,
416            cwd: dir,
417            backend,
418        })
419    }
420
421    /// Open the repository at **exactly** `dir` — unlike [`Repo::discover`],
422    /// this does **not** walk up through parent directories: `dir` itself must
423    /// hold the `.jj`/`.git` marker (a `.jj` directory with a `repo` entry, or a
424    /// `.git` directory / gitlink file — the same validated markers [`discover`]
425    /// uses), or this errors with
426    /// [`Error::NotARepository(dir)`](Error::NotARepository)
427    /// even if a repository exists somewhere above `dir`. Mirrors the
428    /// discover-vs-open split in gitoxide (`gix::discover` vs `gix::open`) and
429    /// libgit2 (`git_repository_discover` vs `git_repository_open`) — see
430    /// issue #8.
431    pub fn open(dir: impl AsRef<Path>) -> Result<Self> {
432        // Absolutise so the bound `cwd`/`root` are consistent with `discover`'s
433        // and so a relative "." names the actual directory, not an empty path.
434        let dir = std::path::absolute(dir.as_ref())?;
435        let kind = if is_jj_marker(&dir.join(".jj")) {
436            BackendKind::Jj
437        } else if is_git_marker(&dir.join(".git")) {
438            BackendKind::Git
439        } else {
440            return Err(Error::NotARepository(dir));
441        };
442        let backend = match kind {
443            BackendKind::Git => Backend::Git(Arc::new(Git::new())),
444            BackendKind::Jj => Backend::Jj(Arc::new(Jj::new())),
445        };
446        Ok(Repo {
447            root: dir.clone(),
448            cwd: dir,
449            backend,
450        })
451    }
452}
453
454impl<R: ProcessRunner> Repo<R> {
455    /// Build a git-backed handle from an explicit client — for a custom runner
456    /// (e.g. a test seam) or a pre-configured [`Git`].
457    pub fn from_git(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Git<R>) -> Self {
458        Repo {
459            root: root.into(),
460            cwd: cwd.into(),
461            backend: Backend::Git(Arc::new(client)),
462        }
463    }
464
465    /// Build a jj-backed handle from an explicit client.
466    pub fn from_jj(root: impl Into<PathBuf>, cwd: impl Into<PathBuf>, client: Jj<R>) -> Self {
467        Repo {
468            root: root.into(),
469            cwd: cwd.into(),
470            backend: Backend::Jj(Arc::new(client)),
471        }
472    }
473
474    /// Which backend drives this handle.
475    pub fn kind(&self) -> BackendKind {
476        match &self.backend {
477            Backend::Git(_) => BackendKind::Git,
478            Backend::Jj(_) => BackendKind::Jj,
479        }
480    }
481
482    /// The repository root detected at open time.
483    pub fn root(&self) -> &Path {
484        &self.root
485    }
486
487    /// The directory operations run against.
488    pub fn cwd(&self) -> &Path {
489        &self.cwd
490    }
491
492    /// A sibling handle bound to `dir`, sharing this handle's client and root.
493    pub fn at(&self, dir: impl Into<PathBuf>) -> Self {
494        Repo {
495            root: self.root.clone(),
496            cwd: dir.into(),
497            backend: self.backend.shared(),
498        }
499    }
500
501    /// The underlying [`Git`] client, or `None` when jj-backed — an escape hatch
502    /// to git-only operations not on the common surface.
503    pub fn git(&self) -> Option<&Git<R>> {
504        match &self.backend {
505            Backend::Git(g) => Some(g.as_ref()),
506            Backend::Jj(_) => None,
507        }
508    }
509
510    /// The underlying [`Jj`] client, or `None` when git-backed.
511    pub fn jj(&self) -> Option<&Jj<R>> {
512        match &self.backend {
513            Backend::Jj(j) => Some(j.as_ref()),
514            Backend::Git(_) => None,
515        }
516    }
517
518    /// The git client bound to this handle's [`cwd`](Repo::cwd) — a [`GitAt`] whose
519    /// methods omit the `dir` argument — or `None` when jj-backed. The dir-free
520    /// counterpart of [`git`](Repo::git): `repo.git_at()?.merge_continue().await?`.
521    ///
522    /// The returned view borrows `self`. To work in another worktree, **bind the
523    /// re-anchored handle first** (the view can't outlive a temporary
524    /// [`at`](Repo::at)):
525    ///
526    /// ```no_run
527    /// # async fn f(repo: vcs_core::Repo, wt: &std::path::Path) -> vcs_core::Result<()> {
528    /// let wt = repo.at(wt);          // owns the re-anchored handle
529    /// let git = wt.git_at().unwrap();
530    /// git.fetch().await?;
531    /// # Ok(()) }
532    /// ```
533    pub fn git_at(&self) -> Option<GitAt<'_, R>> {
534        match &self.backend {
535            Backend::Git(g) => Some(g.at(&self.cwd)),
536            Backend::Jj(_) => None,
537        }
538    }
539
540    /// The jj client bound to this handle's [`cwd`](Repo::cwd) — a [`JjAt`] whose
541    /// methods omit the `dir` argument — or `None` when git-backed. The dir-free
542    /// counterpart of [`jj`](Repo::jj). For another workspace, bind the re-anchored
543    /// handle first (`let ws = repo.at(path); ws.jj_at()…`) — see [`git_at`](Repo::git_at).
544    pub fn jj_at(&self) -> Option<JjAt<'_, R>> {
545        match &self.backend {
546            Backend::Jj(j) => Some(j.at(&self.cwd)),
547            Backend::Git(_) => None,
548        }
549    }
550
551    /// The current branch (git) or bookmark (jj). On jj this is the nearest
552    /// bookmark reachable from the working copy (`heads(::@ & bookmarks())`),
553    /// so it stays set across a `jj describe`/`jj new`/`jj commit` — which leave
554    /// the bookmark on the described parent while the new change carries none —
555    /// matching git's "still on my branch" reporting. When several bookmarks are
556    /// equally near `@`, the lexicographically-smallest name is returned
557    /// (deterministic). `None` only when detached / no bookmark on or above `@`.
558    pub async fn current_branch(&self) -> Result<Option<String>> {
559        match &self.backend {
560            Backend::Git(g) => git_backend::current_branch(g, &self.cwd).await,
561            Backend::Jj(j) => jj_backend::current_branch(j, &self.cwd).await,
562        }
563    }
564
565    /// The trunk branch/bookmark. Resolution order: the backend's own notion
566    /// (git's `origin/HEAD`, jj's `trunk()` revset), then a fallback to a local
567    /// `main`, then `master`; `None` when none of those resolve.
568    pub async fn trunk(&self) -> Result<Option<String>> {
569        let native = match &self.backend {
570            Backend::Git(g) => git_backend::trunk(g, &self.cwd).await?,
571            Backend::Jj(j) => jj_backend::trunk(j, &self.cwd).await?,
572        };
573        if native.is_some() {
574            return Ok(native);
575        }
576        for candidate in ["main", "master"] {
577            if self.branch_exists(candidate).await? {
578                return Ok(Some(candidate.to_string()));
579            }
580        }
581        Ok(None)
582    }
583
584    /// Local branch (git) / bookmark (jj) names.
585    ///
586    /// Backend divergence: on **jj**, a bookmark deleted locally but still **tracked**
587    /// on a remote lingers as a *tombstone* row (jj keeps it so the deletion can be
588    /// propagated) until the deletion is pushed — so this can list a name a
589    /// `delete_branch` just removed, unlike git. (The tombstone is not filtered here
590    /// because jj renders it and a *conflicted* bookmark identically — filtering would
591    /// also hide a real, conflicted bookmark; M21.)
592    pub async fn local_branches(&self) -> Result<Vec<String>> {
593        match &self.backend {
594            Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
595            Backend::Jj(j) => jj_backend::local_branches(j, &self.cwd).await,
596        }
597    }
598
599    /// A **read-only** [`local_branches`](Repo::local_branches): the same result,
600    /// but on **jj** it passes `--ignore-working-copy`, so listing the bookmarks
601    /// records no jj operation and never moves `@`. On **git** it is exactly
602    /// [`local_branches`](Repo::local_branches) — git's branch listing records no
603    /// operation and moves no ref, so there is nothing to make read-only.
604    ///
605    /// Use it (with [`snapshot_readonly`](Repo::snapshot_readonly)) from an
606    /// *observer* — a watcher or a prompt refresh — that must not perturb the
607    /// state it reads. See [`snapshot_readonly`](Repo::snapshot_readonly) for the
608    /// jj working-copy trade-off this shares.
609    pub async fn local_branches_readonly(&self) -> Result<Vec<String>> {
610        match &self.backend {
611            Backend::Git(g) => git_backend::local_branches(g, &self.cwd).await,
612            Backend::Jj(j) => jj_backend::local_branches_readonly(j, &self.cwd).await,
613        }
614    }
615
616    /// Whether a local branch/bookmark named `name` exists. See
617    /// [`local_branches`](Repo::local_branches) for the jj deleted-but-tracked
618    /// *tombstone* divergence (a just-deleted tracked bookmark can still read as
619    /// existing until the deletion is pushed).
620    pub async fn branch_exists(&self, name: &str) -> Result<bool> {
621        match &self.backend {
622            Backend::Git(g) => git_backend::branch_exists(g, &self.cwd, name).await,
623            Backend::Jj(j) => jj_backend::branch_exists(j, &self.cwd, name).await,
624        }
625    }
626
627    /// Whether the working copy has uncommitted changes (git: a non-empty
628    /// `status`; jj: a non-empty working-copy change `@`).
629    pub async fn has_uncommitted_changes(&self) -> Result<bool> {
630        match &self.backend {
631            Backend::Git(g) => git_backend::has_uncommitted_changes(g, &self.cwd).await,
632            Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
633        }
634    }
635
636    /// Whether the working copy has uncommitted changes to *tracked* files.
637    ///
638    /// Backend nuance: git ignores untracked files here
639    /// (`status --untracked-files=no`); jj auto-tracks new files, so there is no
640    /// untracked concept and this equals
641    /// [`has_uncommitted_changes`](Self::has_uncommitted_changes).
642    pub async fn has_tracked_changes(&self) -> Result<bool> {
643        match &self.backend {
644            Backend::Git(g) => git_backend::has_tracked_changes(g, &self.cwd).await,
645            Backend::Jj(j) => jj_backend::has_uncommitted_changes(j, &self.cwd).await,
646        }
647    }
648
649    /// Paths with unresolved merge conflicts in the working copy, repo-relative
650    /// with `/` separators (git `diff --diff-filter=U` / jj `resolve --list -r @`).
651    /// Empty when there are none. Each path is a [`PathBuf`] carried losslessly from
652    /// the backend, so a non-UTF-8 conflicted filename (legal on Unix) is not
653    /// corrupted to `U+FFFD`.
654    pub async fn conflicted_files(&self) -> Result<Vec<PathBuf>> {
655        match &self.backend {
656            Backend::Git(g) => git_backend::conflicted_files(g, &self.cwd).await,
657            Backend::Jj(j) => jj_backend::conflicted_files(j, &self.cwd).await,
658        }
659    }
660
661    /// Delete a local branch (git) / bookmark (jj). The [`BranchDelete`] spec's
662    /// [`force`](BranchDelete::force) applies to git only (`branch -D` vs `-d`); jj
663    /// has no force and ignores it.
664    pub async fn delete_branch(&self, spec: BranchDelete) -> Result<()> {
665        match &self.backend {
666            Backend::Git(g) => {
667                git_backend::delete_branch(g, &self.cwd, &spec.name, spec.force).await
668            }
669            Backend::Jj(j) => jj_backend::delete_branch(j, &self.cwd, &spec.name).await,
670        }
671    }
672
673    /// Rename a local branch (git) / bookmark (jj).
674    pub async fn rename_branch(&self, old: &str, new: &str) -> Result<()> {
675        match &self.backend {
676            Backend::Git(g) => git_backend::rename_branch(g, &self.cwd, old, new).await,
677            Backend::Jj(j) => jj_backend::rename_branch(j, &self.cwd, old, new).await,
678        }
679    }
680
681    /// The working-copy changes (git `status` / jj `diff -r @ --summary`).
682    pub async fn changed_files(&self) -> Result<Vec<FileChange>> {
683        match &self.backend {
684            Backend::Git(g) => git_backend::changed_files(g, &self.cwd).await,
685            Backend::Jj(j) => jj_backend::changed_files(j, &self.cwd).await,
686        }
687    }
688
689    /// Aggregate insertion/deletion counts for the working copy.
690    ///
691    /// Backend nuance: git counts the working tree against `HEAD` (`git diff`,
692    /// which **excludes untracked files**), while jj counts the `@` change against
693    /// its parent (which **includes** newly-added files). So on git a brand-new
694    /// file shows in [`changed_files`](Self::changed_files) but not here, whereas
695    /// on jj it shows in both. On an unborn git repo (no commits yet) the count is
696    /// taken against the empty tree, so a pre-first-commit working tree stats
697    /// instead of erroring.
698    pub async fn diff_stat(&self) -> Result<DiffStat> {
699        match &self.backend {
700            Backend::Git(g) => git_backend::diff_stat(g, &self.cwd).await,
701            Backend::Jj(j) => jj_backend::diff_stat(j, &self.cwd).await,
702        }
703    }
704
705    /// Recent history: up to `max` commits reachable from `revspec_or_revset`
706    /// (git revspec / jj revset), most-recent-first (git `log`'s default order /
707    /// jj `log`'s topological order).
708    ///
709    /// Backend nuance: [`Commit::author`]/[`Commit::date`] are `Some` only on
710    /// git — jj's typed log doesn't currently surface authorship or a
711    /// timestamp, so they're `None` there rather than guessed (see the
712    /// [`Commit`] type docs).
713    pub async fn log(&self, revspec_or_revset: &str, max: usize) -> Result<Vec<Commit>> {
714        match &self.backend {
715            Backend::Git(g) => git_backend::log(g, &self.cwd, revspec_or_revset, max).await,
716            Backend::Jj(j) => jj_backend::log(j, &self.cwd, revspec_or_revset, max).await,
717        }
718    }
719
720    /// The content of `path` as it exists at `rev` (git revspec / jj revset), e.g.
721    /// `HEAD:src/lib.rs` on git or `@-` + a fileset on jj — both normalise
722    /// backslash path separators and return the file's bytes verbatim (including
723    /// any trailing newline).
724    pub async fn show_file(&self, rev: &str, path: &str) -> Result<String> {
725        match &self.backend {
726            Backend::Git(g) => git_backend::show_file(g, &self.cwd, rev, path).await,
727            Backend::Jj(j) => jj_backend::show_file(j, &self.cwd, rev, path).await,
728        }
729    }
730
731    /// [`show_file`](Repo::show_file) with an explicit per-call [`OutputBudget`],
732    /// instead of the budget the backend client was built with
733    /// ([`default_output_budget`](vcs_git::Git::default_output_budget), inherited
734    /// through [`from_git`](Repo::from_git)/[`from_jj`](Repo::from_jj)). Reads the
735    /// blob under `budget`: past the ceiling it errors with an
736    /// [`OutputTooLarge`](processkit::Error::OutputTooLarge)-carrying
737    /// [`Error::Vcs`] (actual and allowed sizes) rather than buffering an unbounded
738    /// file — use it to read a legitimately large file
739    /// ([`OutputBudget::unlimited`], or a higher cap) or to tighten the cap for one
740    /// call. A truncated blob is never returned as if complete.
741    pub async fn show_file_within(
742        &self,
743        rev: &str,
744        path: &str,
745        budget: OutputBudget,
746    ) -> Result<String> {
747        match &self.backend {
748            Backend::Git(g) => git_backend::show_file_within(g, &self.cwd, rev, path, budget).await,
749            Backend::Jj(j) => jj_backend::show_file_within(j, &self.cwd, rev, path, budget).await,
750        }
751    }
752
753    /// A batched [`RepoSnapshot`] of the common repo state — branch, upstream,
754    /// ahead/behind, dirtiness, change count, and operation state — in a **small
755    /// fixed** number of spawns instead of a call per field (git: `status
756    /// --porcelain=v2 --branch` + the in-progress probe; jj: a `log -r @`
757    /// template for head/empty/conflict, a `reachable_bookmarks` query for
758    /// `branch`, and a change count only when dirty). Built for prompt/status-bar/
759    /// TUI refreshes. Note the asymmetry: [`tracking`](RepoSnapshot::tracking)
760    /// (the upstream ref + ahead/behind) is always `None` on jj, which has no
761    /// git-style upstream tracking.
762    pub async fn snapshot(&self) -> Result<RepoSnapshot> {
763        match &self.backend {
764            Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
765            Backend::Jj(j) => jj_backend::snapshot(j, &self.cwd).await,
766        }
767    }
768
769    /// A **read-only** [`snapshot`](Repo::snapshot): the same [`RepoSnapshot`],
770    /// but on **jj** it never snapshots the working copy — every underlying query
771    /// passes `--ignore-working-copy`, so the batched read records **no** jj
772    /// operation and never moves `@`. On **git** it is exactly
773    /// [`snapshot`](Repo::snapshot) (git's status query records no operation and
774    /// moves no ref).
775    ///
776    /// Use it for an *observer* — a repository watcher, a prompt/status-bar
777    /// refresh — that must not perturb the state it reports: an ordinary jj query
778    /// snapshots the working copy as a side effect (taking the lock, recording an
779    /// operation, possibly moving `@`), so the observer would otherwise *mutate*
780    /// the repo it merely means to read.
781    ///
782    /// **jj trade-off:** because the working copy isn't snapshotted, a bare
783    /// working-tree edit that no jj command has recorded yet is **not** reflected
784    /// — [`dirty`](RepoSnapshot::dirty)/[`head`](RepoSnapshot::head) are as of the
785    /// last recorded operation. To observe such unsnapshotted edits, accept the
786    /// mutation and call [`snapshot`](Repo::snapshot).
787    pub async fn snapshot_readonly(&self) -> Result<RepoSnapshot> {
788        match &self.backend {
789            Backend::Git(g) => git_backend::snapshot(g, &self.cwd).await,
790            Backend::Jj(j) => jj_backend::snapshot_readonly(j, &self.cwd).await,
791        }
792    }
793
794    /// Commit exactly `paths` with `message` (git `commit --only`, jj
795    /// `commit <filesets>`). Paths are repo-relative. `paths` must be non-empty:
796    /// an empty set is refused up front, because the backends would diverge
797    /// dangerously — git errors out, while jj's `commit` with no filesets would
798    /// silently commit the **entire** working copy.
799    ///
800    /// Takes [`PathBuf`]s so a path obtained from [`changed_files`](Self::changed_files)
801    /// / [`conflicted_files`](Self::conflicted_files) round-trips **losslessly** — on
802    /// git a non-UTF-8 path (legal on Unix) reaches the commit unchanged via the
803    /// NUL-safe pathspec transport; on jj the fileset language is text, so jj's own
804    /// (non-UTF-8-incapable) fileset handling applies.
805    pub async fn commit_paths(&self, paths: &[PathBuf], message: &str) -> Result<()> {
806        if paths.is_empty() {
807            return Err(Error::Io(std::io::Error::new(
808                std::io::ErrorKind::InvalidInput,
809                "commit_paths requires at least one path: an empty set would error \
810                 on git but commit the entire working copy on jj",
811            )));
812        }
813        match &self.backend {
814            Backend::Git(g) => git_backend::commit_paths(g, &self.cwd, paths, message).await,
815            Backend::Jj(j) => jj_backend::commit_paths(j, &self.cwd, paths, message).await,
816        }
817    }
818
819    /// Fetch from the default remote (git `fetch` / jj `git fetch`).
820    pub async fn fetch(&self) -> Result<()> {
821        match &self.backend {
822            Backend::Git(g) => git_backend::fetch(g, &self.cwd).await,
823            Backend::Jj(j) => jj_backend::fetch(j, &self.cwd).await,
824        }
825    }
826
827    /// Fetch from a *named* remote (git `fetch <remote>` / jj
828    /// `git fetch --remote <remote>`). Transient network failures are retried by
829    /// the underlying client.
830    pub async fn fetch_from(&self, remote: &str) -> Result<()> {
831        match &self.backend {
832            Backend::Git(g) => git_backend::fetch_from(g, &self.cwd, remote).await,
833            Backend::Jj(j) => jj_backend::fetch_from(j, &self.cwd, remote).await,
834        }
835    }
836
837    /// Fetch a single branch/bookmark from `origin` into its remote-tracking ref
838    /// (git `fetch_branch` / jj `git fetch -b`). Transient network failures
839    /// are retried by the underlying client.
840    pub async fn fetch_branch(&self, branch: &str) -> Result<()> {
841        match &self.backend {
842            Backend::Git(g) => git_backend::fetch_branch(g, &self.cwd, branch).await,
843            Backend::Jj(j) => jj_backend::fetch_branch(j, &self.cwd, branch).await,
844        }
845    }
846
847    /// Push `branch` to `origin` (git `push -u origin <branch>` / jj
848    /// `git push -b <branch>`).
849    ///
850    /// The branch (jj: bookmark) must already exist locally. The two backends
851    /// honestly differ in what "push" means: git pushes the *ref* and records
852    /// the upstream (`-u`; idempotent on repeat pushes), while jj pushes the
853    /// *bookmark's state* — including deleting the remote branch if the
854    /// bookmark was deleted locally. Renamed refspecs (`local:remote`) and
855    /// non-`origin` remotes are git-only concepts; use the
856    /// [`git()`](Repo::git) escape hatch ([`vcs_git::GitPush`]) for those.
857    pub async fn push(&self, branch: &str) -> Result<()> {
858        match &self.backend {
859            Backend::Git(g) => git_backend::push(g, &self.cwd, branch).await,
860            Backend::Jj(j) => jj_backend::push(j, &self.cwd, branch).await,
861        }
862    }
863
864    /// Switch the working copy to `reference` (git `checkout` / jj `edit`).
865    ///
866    /// ⚠ **Backend divergence — this is not "detach and build on top" on jj.** On
867    /// **git**, a subsequent commit *appends* on top of `reference` (its tip is
868    /// untouched). On **jj**, `checkout` maps to `jj edit`, which makes `reference`'s
869    /// commit *itself* the working-copy change — so a following
870    /// [`commit_paths`](Repo::commit_paths) (or any edit) **rewrites that commit in
871    /// place** (a new change-id, a replaced
872    /// description), silently amending a possibly-already-pushed commit rather than
873    /// adding a new one.
874    ///
875    /// So backend-agnostic "start fresh work on top of `main`" code must **not** rely
876    /// on `checkout` alone. If you want git-like append-on-top semantics on both
877    /// backends, use [`new_child`](Repo::new_child), which maps to `jj new
878    /// <reference>` on jj and to `checkout <reference>` on git.
879    pub async fn checkout(&self, reference: &str) -> Result<()> {
880        match &self.backend {
881            Backend::Git(g) => git_backend::checkout(g, &self.cwd, reference).await,
882            Backend::Jj(j) => jj_backend::checkout(j, &self.cwd, reference).await,
883        }
884    }
885
886    /// Start new work on top of `reference` without modifying it.
887    ///
888    /// On git this checks out `reference`; the next commit naturally appends on top.
889    /// On jj this runs `jj new <reference>`, creating an undescribed child change.
890    pub async fn new_child(&self, reference: &str) -> Result<()> {
891        match &self.backend {
892            Backend::Git(g) => git_backend::new_child(g, &self.cwd, reference).await,
893            Backend::Jj(j) => jj_backend::new_child(j, &self.cwd, reference).await,
894        }
895    }
896
897    /// Rebase the current line onto `onto`. The two backends **diverge** on
898    /// non-linear layouts, so this is a documented least-common-denominator:
899    /// - **git** (`rebase <onto>` = `merge-base(HEAD,onto)..HEAD`) moves only
900    ///   `HEAD`'s own ancestor line; commits stacked on `HEAD` stay put.
901    /// - **jj** (`rebase -d <onto>` = the default `-b @` = `(onto..@)::`) moves
902    ///   that line *and its whole descendant closure* — anything stacked on `@`,
903    ///   and any sibling off an *intermediate* commit of the line, move too.
904    ///
905    /// They agree on a linear `HEAD`/`@`; on a **stacked or intermediate-fork**
906    /// layout jj moves strictly more. A sibling that shares only the fork point is
907    /// moved by neither. `onto` is a branch/bookmark name or revision the backend
908    /// understands.
909    pub async fn rebase(&self, onto: &str) -> Result<()> {
910        match &self.backend {
911            Backend::Git(g) => git_backend::rebase(g, &self.cwd, onto).await,
912            Backend::Jj(j) => jj_backend::rebase(j, &self.cwd, onto).await,
913        }
914    }
915
916    /// Probe whether merging `source` into the current work would conflict,
917    /// **without leaving any trace**: the probe is rolled back before returning
918    /// (git: `merge --no-commit --no-ff` then `merge --abort`; jj: a merge
919    /// change probed and undone via `op restore`).
920    ///
921    /// Preconditions/behaviour:
922    /// - git: requires a clean-enough working tree — a dirty-tree refusal
923    ///   propagates as a plain error, not as [`MergeProbe::Conflicts`].
924    /// - A failing rollback **propagates as an error** rather than returning a
925    ///   result that misdescribes the on-disk state.
926    /// - **Cancellation caveat:** the rollback runs on the same client, so if the
927    ///   client carries a `default_cancel_on` token (the `cancellation` feature)
928    ///   that fires during the probe, the rollback command is cancelled too and the
929    ///   probe change may be left behind (`Error::Cancelled` surfaces). Re-probe and
930    ///   reset with an un-cancelled client if you need a clean tree.
931    pub async fn try_merge(&self, source: &str) -> Result<MergeProbe> {
932        match &self.backend {
933            Backend::Git(g) => git_backend::try_merge(g, &self.cwd, source).await,
934            Backend::Jj(j) => jj_backend::try_merge(j, &self.cwd, source).await,
935        }
936    }
937
938    /// Abort the in-progress operation, if any (git: `merge --abort` /
939    /// `rebase --abort`; jj: a no-op — there are no paused operations, roll back
940    /// explicitly via `Jj::transaction` / `op_restore`). Returns the fresh
941    /// *post-call* [`OperationState`]; `Clear` when nothing was (or remains) in
942    /// progress.
943    pub async fn abort_in_progress(&self) -> Result<OperationState> {
944        match &self.backend {
945            Backend::Git(g) => git_backend::abort_in_progress(g, &self.cwd).await,
946            Backend::Jj(j) => jj_backend::abort_in_progress(j, &self.cwd).await,
947        }
948    }
949
950    /// Continue the in-progress operation after conflict resolution (git:
951    /// `commit --no-edit` for a merge / `rebase --continue`; jj: a no-op —
952    /// resolving the files *is* the continuation). Returns the fresh *post-call*
953    /// [`OperationState`]:
954    /// - `Conflict` when unresolved paths still block continuing (also on git —
955    ///   unlike [`in_progress_state`](Self::in_progress_state), this method
956    ///   *does* report `Conflict` for git), or when a continued rebase stops on
957    ///   the next patch's conflict.
958    /// - `Clear` when the operation finished.
959    pub async fn continue_in_progress(&self) -> Result<OperationState> {
960        match &self.backend {
961            Backend::Git(g) => git_backend::continue_in_progress(g, &self.cwd).await,
962            Backend::Jj(j) => jj_backend::continue_in_progress(j, &self.cwd).await,
963        }
964    }
965
966    /// Whether the working copy is mid-operation or conflicted — see
967    /// [`OperationState`]. Lets a caller decide between abort/continue without
968    /// knowing the backend's model. Note the asymmetry: *this method* reports
969    /// `Merge`/`Rebase` (never `Conflict`) on git — a git conflict *is* that
970    /// paused state, and the conflict itself surfaces on the failed op via
971    /// [`Error::is_merge_conflict`] (or as `Conflict` from
972    /// [`continue_in_progress`](Self::continue_in_progress)) — while jj has no
973    /// paused op and reports `Conflict` directly.
974    pub async fn in_progress_state(&self) -> Result<OperationState> {
975        match &self.backend {
976            Backend::Git(g) => git_backend::in_progress_state(g, &self.cwd).await,
977            Backend::Jj(j) => jj_backend::in_progress_state(j, &self.cwd).await,
978        }
979    }
980
981    /// List attached worktrees (git) / workspaces (jj).
982    pub async fn list_worktrees(&self) -> Result<Vec<WorktreeInfo>> {
983        match &self.backend {
984            Backend::Git(g) => git_backend::list_worktrees(g, &self.cwd).await,
985            Backend::Jj(j) => jj_backend::list_worktrees(j, &self.cwd).await,
986        }
987    }
988
989    /// Create a worktree/workspace at `path` on a **new** `branch` based on
990    /// `base`. Always [`CreateOutcome::Plain`]; a copy-on-write strategy stays in
991    /// the consumer.
992    ///
993    /// `branch` must not already exist. The jj path is two steps (`workspace add`
994    /// then `bookmark create`) and is not atomic, but a failed bookmark step
995    /// **rolls back**: the workspace directory is removed only when `workspace add`
996    /// created it (a pre-existing directory the caller already had is left intact),
997    /// then the workspace is forgotten. Residue is no longer swallowed: if the
998    /// rollback can't remove that directory or can't `forget` the workspace, the call
999    /// fails with a composite [`Error::Io`] naming what still needs cleaning up (and
1000    /// is safe to re-run); a clean rollback instead surfaces the original
1001    /// bookmark-step error unchanged (its [`Error::Vcs`] classification) — so a failed
1002    /// call never silently leaks a half-made worktree.
1003    pub async fn create_worktree(&self, spec: WorktreeCreate) -> Result<CreateOutcome> {
1004        let WorktreeCreate { path, branch, base } = &spec;
1005        match &self.backend {
1006            Backend::Git(g) => git_backend::create_worktree(g, &self.cwd, path, branch, base).await,
1007            Backend::Jj(j) => jj_backend::create_worktree(j, &self.cwd, path, branch, base).await,
1008        }
1009    }
1010
1011    /// Remove the worktree/workspace at `path`. For jj this resolves the
1012    /// workspace name by matching `path`, deletes the directory, then forgets it;
1013    /// a `path` that matches none of the **resolvable** jj workspaces returns
1014    /// [`Error::WorktreeNotFound`], but when some registered workspace can't be
1015    /// resolved via `jj workspace root --name` the path's absence is unprovable, so a
1016    /// distinct diagnosable [`Error::Io`] (naming the unresolved workspaces;
1017    /// [`is_resource_not_found`](Error::is_resource_not_found) stays `false`) is
1018    /// returned instead. A directory that can't be deleted is likewise surfaced (an
1019    /// [`Error::Io`] naming the still-registered workspace, with the `forget` left for
1020    /// the retry). (For the short-lived, blocking `Drop`-path variant, see
1021    /// [`cleanup_worktree_blocking`](Self::cleanup_worktree_blocking).)
1022    ///
1023    /// The [`WorktreeRemove`] spec's [`force`](WorktreeRemove::force) mirrors git's
1024    /// `worktree remove`: without it a worktree that still has **uncommitted changes**
1025    /// is refused (`Err`) rather than deleted, so a stray edit isn't silently lost —
1026    /// build `WorktreeRemove::new(path).force()` to remove it anyway. On **jj** the
1027    /// changes are snapshotted into the op log before the check, so a refusal keeps
1028    /// them recoverable; note that checking spawns a jj command in the target
1029    /// workspace, so a genuinely stale working copy can surface an error without
1030    /// `force` (use `.force()` there). The repository's **main** workspace is always
1031    /// refused (it can't be removed without destroying the repo), regardless of `force`.
1032    pub async fn remove_worktree(&self, spec: WorktreeRemove) -> Result<()> {
1033        match &self.backend {
1034            Backend::Git(g) => {
1035                git_backend::remove_worktree(g, &self.cwd, &spec.path, spec.force).await
1036            }
1037            Backend::Jj(j) => {
1038                jj_backend::remove_worktree(j, &self.cwd, &spec.path, spec.force).await
1039            }
1040        }
1041    }
1042
1043    /// **Synchronous** worktree cleanup for a context that cannot `.await` —
1044    /// chiefly a `Drop` guard. Force-removes the worktree at `path` (git:
1045    /// `worktree remove --force`; jj: resolve the workspace name by `path`, delete
1046    /// the directory, then `workspace forget`). Short-lived and shells out directly
1047    /// (no job-containment), but not error-swallowing: a jj `path` that genuinely
1048    /// matches no workspace is an `Ok` no-op, yet a probe failure (the `workspace
1049    /// list`, or a registered workspace that won't resolve) and a `remove_dir_all`
1050    /// failure are surfaced as `Err` (the `forget` is skipped on a failed removal, so
1051    /// a surviving directory isn't orphaned). Like the async
1052    /// [`remove_worktree`](Self::remove_worktree), it **refuses the repository's
1053    /// main workspace** (whose directory is the main working copy) — deleting it
1054    /// would wipe the repo — even on this force-by-contract path.
1055    pub fn cleanup_worktree_blocking(&self, path: &Path) -> Result<()> {
1056        match &self.backend {
1057            Backend::Git(_) => vcs_git::blocking::worktree_remove(
1058                &self.cwd,
1059                vcs_git::WorktreeRemove::new(path).force(),
1060            )
1061            .map_err(Error::Io),
1062            Backend::Jj(_) => {
1063                // jj resolves a relative worktree path against the repo dir (its
1064                // cwd), so resolve it the same way here — the lookup and the dir
1065                // removal must target the location jj used, not one under the process
1066                // cwd (which may differ from `self.cwd`).
1067                let abs_path = self.cwd.join(path);
1068                // Tell a genuine "no such workspace" (`Ok(None)` → nothing to clean
1069                // up, a no-op) apart from a probe failure (`Err` → surfaced, not
1070                // silently treated as a no-op): the blocking resolver no longer folds
1071                // both into `None`.
1072                match vcs_jj::blocking::workspace_name_for_path(&self.cwd, &abs_path)
1073                    .map_err(Error::Io)?
1074                {
1075                    Some(name) => {
1076                        // Same main-workspace guard as the async `remove_worktree`
1077                        // (jj_backend.rs): never `remove_dir_all` the repository's
1078                        // main working copy — its directory owns the object store, so
1079                        // deleting it wipes the whole repo. The `default` name and the
1080                        // store-owning `.jj/repo` *directory* (a secondary's is a file
1081                        // pointer) both flag it, so a `jj workspace rename` can't
1082                        // bypass it. Force is implied on this Drop path, but this guard
1083                        // is unconditional — a repo-wipe is never the intent.
1084                        if name == "default" || abs_path.join(".jj").join("repo").is_dir() {
1085                            return Err(Error::Io(std::io::Error::new(
1086                                std::io::ErrorKind::InvalidInput,
1087                                "refusing to remove the repository's main workspace",
1088                            )));
1089                        }
1090                        // Delete the on-disk dir first (jj `forget` leaves it), then
1091                        // drop jj's record of the workspace. A removal failure is
1092                        // SURFACED (not swallowed with `let _ =`) and the forget is
1093                        // skipped: forgetting a workspace whose directory survived
1094                        // would orphan that dir — worse than a still-attached workspace
1095                        // — and the reported error names what is still registered so
1096                        // the cleanup can be safely re-run once the directory is free.
1097                        if abs_path.exists() {
1098                            std::fs::remove_dir_all(&abs_path).map_err(|e| {
1099                                Error::Io(std::io::Error::new(
1100                                    e.kind(),
1101                                    format!(
1102                                        "failed to remove the worktree directory {} ({e}); the jj \
1103                                         workspace `{name}` is still registered — free the \
1104                                         directory and retry the cleanup",
1105                                        abs_path.display()
1106                                    ),
1107                                ))
1108                            })?;
1109                        }
1110                        vcs_jj::blocking::workspace_forget(&self.cwd, &name).map_err(Error::Io)
1111                    }
1112                    None => Ok(()),
1113                }
1114            }
1115        }
1116    }
1117}
1118
1119/// Generate a facade trait from one signature table: the `#[async_trait]` trait
1120/// declaration *and* the delegating `impl … for $Ty<R>`, so the two can never drift
1121/// out of sync (a hazard when each is hand-maintained). Every generated body is a
1122/// trivial delegation to the like-named inherent method — which method resolution
1123/// prefers, so this never recurses; the real backend-`match` dispatch stays
1124/// hand-written on the inherent `impl`. `async` methods doc-link to their inherent
1125/// twin; `sync` methods carry an explicit doc string (their docs aren't uniform).
1126///
1127/// `vcs-forge` used to carry a near-identical copy of this macro, kept
1128/// deliberately unshared (separate crates, ~40-line macro — duplication beats a
1129/// new dependency); it was removed there in v0.1.1 when new trait methods needed
1130/// default bodies the macro couldn't express, so `vcs-forge`'s facade trait and
1131/// impl are now hand-maintained (see the removal note in `vcs-forge`'s
1132/// `src/lib.rs`). This crate is still v0.x and doesn't need that, so the
1133/// original signature-table macro remains the right shape here.
1134///
1135/// Signatures only: each entry is a bare `&self` (or sync) method — no method-level
1136/// generics, no `&mut self`, no default bodies (a new method shaped that way needs a
1137/// grammar tweak, not just a table row).
1138///
1139/// No `mockall::automock`: a Wave-S spike proved it can't process a trait whose
1140/// signatures come from `macro_rules!`. Captured `$_:ty` fragments reach `automock`
1141/// as opaque nonterminal token groups; its `syn` parser rejects them ("unsupported
1142/// type in this position"), whereas `#[async_trait]` tolerates them. So the facade
1143/// traits stay test-seam-tested (build a handle over a fake runner — see the trait
1144/// docs), which is also what their docs already recommend over mocking.
1145macro_rules! facade_trait {
1146    (
1147        $(#[doc = $tdoc:expr])*
1148        trait $Trait:ident for $Ty:ident;
1149        sync {
1150            $( #[doc = $sdoc:expr] fn $sn:ident( $($sa:ident: $sat:ty),* $(,)? ) -> $sr:ty; )*
1151        }
1152        async {
1153            $( fn $an:ident( $($aa:ident: $aat:ty),* $(,)? ) -> $ar:ty; )*
1154        }
1155    ) => {
1156        $(#[doc = $tdoc])*
1157        #[async_trait::async_trait]
1158        pub trait $Trait: Send + Sync {
1159            $(
1160                #[doc = $sdoc]
1161                fn $sn(&self, $($sa: $sat),*) -> $sr;
1162            )*
1163            $(
1164                #[doc = concat!("See [`", stringify!($Ty), "::", stringify!($an), "`].")]
1165                async fn $an(&self, $($aa: $aat),*) -> $ar;
1166            )*
1167        }
1168
1169        // Delegates to the inherent methods, which method resolution prefers — so
1170        // these bodies dispatch through the concrete type's real implementations,
1171        // not back into the trait.
1172        #[async_trait::async_trait]
1173        impl<R: ProcessRunner> $Trait for $Ty<R> {
1174            $(
1175                fn $sn(&self, $($sa: $sat),*) -> $sr {
1176                    self.$sn($($sa),*)
1177                }
1178            )*
1179            $(
1180                async fn $an(&self, $($aa: $aat),*) -> $ar {
1181                    self.$an($($aa),*).await
1182                }
1183            )*
1184        }
1185    };
1186}
1187
1188facade_trait! {
1189    /// The backend-agnostic common surface of [`Repo`], as a trait — so a consumer can
1190    /// hold a `Box<dyn VcsRepo>` / `&dyn VcsRepo` and code against the operations
1191    /// without naming the [`ProcessRunner`] generic or wrapping `Repo` themselves.
1192    ///
1193    /// Every method mirrors the like-named inherent method on [`Repo`]; the trait adds
1194    /// nothing but the abstraction boundary. Tool-specific operations stay off it (see
1195    /// the crate docs) — reach those through the concrete [`Repo`] and its bound
1196    /// handles. For hermetic tests, build a `Repo` over a fake runner with
1197    /// [`Repo::from_git`] / [`Repo::from_jj`] rather than mocking this trait.
1198    trait VcsRepo for Repo;
1199    sync {
1200        #[doc = "Which backend drives this handle."]
1201        fn kind() -> BackendKind;
1202        #[doc = "The repository root detected at open time."]
1203        fn root() -> &Path;
1204        #[doc = "The directory operations run against."]
1205        fn cwd() -> &Path;
1206        #[doc = "See [`Repo::cleanup_worktree_blocking`]."]
1207        fn cleanup_worktree_blocking(path: &Path) -> Result<()>;
1208    }
1209    async {
1210        fn current_branch() -> Result<Option<String>>;
1211        fn trunk() -> Result<Option<String>>;
1212        fn local_branches() -> Result<Vec<String>>;
1213        fn local_branches_readonly() -> Result<Vec<String>>;
1214        fn branch_exists(name: &str) -> Result<bool>;
1215        fn has_uncommitted_changes() -> Result<bool>;
1216        fn has_tracked_changes() -> Result<bool>;
1217        fn conflicted_files() -> Result<Vec<PathBuf>>;
1218        fn delete_branch(spec: BranchDelete) -> Result<()>;
1219        fn rename_branch(old: &str, new: &str) -> Result<()>;
1220        fn changed_files() -> Result<Vec<FileChange>>;
1221        fn diff_stat() -> Result<DiffStat>;
1222        fn log(revspec_or_revset: &str, max: usize) -> Result<Vec<Commit>>;
1223        fn show_file(rev: &str, path: &str) -> Result<String>;
1224        fn show_file_within(rev: &str, path: &str, budget: OutputBudget) -> Result<String>;
1225        fn snapshot() -> Result<RepoSnapshot>;
1226        fn snapshot_readonly() -> Result<RepoSnapshot>;
1227        fn commit_paths(paths: &[PathBuf], message: &str) -> Result<()>;
1228        fn fetch() -> Result<()>;
1229        fn fetch_from(remote: &str) -> Result<()>;
1230        fn fetch_branch(branch: &str) -> Result<()>;
1231        fn push(branch: &str) -> Result<()>;
1232        fn checkout(reference: &str) -> Result<()>;
1233        fn new_child(reference: &str) -> Result<()>;
1234        fn rebase(onto: &str) -> Result<()>;
1235        fn try_merge(source: &str) -> Result<MergeProbe>;
1236        fn abort_in_progress() -> Result<OperationState>;
1237        fn continue_in_progress() -> Result<OperationState>;
1238        fn in_progress_state() -> Result<OperationState>;
1239        fn list_worktrees() -> Result<Vec<WorktreeInfo>>;
1240        fn create_worktree(spec: WorktreeCreate) -> Result<CreateOutcome>;
1241        fn remove_worktree(spec: WorktreeRemove) -> Result<()>;
1242    }
1243}
1244
1245#[cfg(test)]
1246mod tests {
1247    use super::*;
1248    use processkit::testing::{Reply, ScriptedRunner};
1249    // The shared sandbox fixture — a unique temp dir removed on drop. Using the
1250    // testkit's one impl instead of a private copy means the wrappers/facades
1251    // don't each carry a fixture that could drift.
1252    use vcs_testkit::TempDir;
1253
1254    // --- discover ------------------------------------------------------------
1255
1256    #[test]
1257    fn discover_finds_git_and_jj_and_prefers_jj() {
1258        let tmp = TempDir::new("discover");
1259        let root = tmp.path();
1260
1261        // Plain git repo.
1262        std::fs::create_dir_all(root.join(".git")).unwrap();
1263        let located = discover(root).expect("git detected");
1264        assert_eq!(located.kind, BackendKind::Git);
1265        assert_eq!(located.root, root);
1266
1267        // Colocated: adding a *valid* .jj (with its `repo` store) makes jj win.
1268        std::fs::create_dir_all(root.join(".jj").join("repo")).unwrap();
1269        assert_eq!(discover(root).unwrap().kind, BackendKind::Jj);
1270    }
1271
1272    // M19: a stray/empty `.jj` directory (no `repo` store — e.g. a leftover
1273    // `mkdir .jj`) is NOT a jj marker and must not shadow a healthy `.git` repo in the
1274    // same directory. A valid `.jj` (with `repo`, dir or file) still wins.
1275    #[test]
1276    fn discover_ignores_a_dotjj_without_a_repo_store() {
1277        let tmp = TempDir::new("stray-jj");
1278        let root = tmp.path();
1279        std::fs::create_dir_all(root.join(".git")).unwrap();
1280        std::fs::create_dir_all(root.join(".jj")).unwrap(); // empty — no `repo`
1281        assert_eq!(
1282            discover(root).expect("git still detected").kind,
1283            BackendKind::Git,
1284            "an empty .jj must not shadow a real .git"
1285        );
1286
1287        // A secondary workspace's `.jj/repo` is a *file* pointer — still valid.
1288        let sec = TempDir::new("jj-secondary");
1289        std::fs::create_dir_all(sec.path().join(".jj")).unwrap();
1290        std::fs::write(sec.path().join(".jj").join("repo"), b"/path/to/store\n").unwrap();
1291        assert_eq!(discover(sec.path()).unwrap().kind, BackendKind::Jj);
1292    }
1293
1294    #[test]
1295    fn discover_walks_up_to_ancestor() {
1296        let tmp = TempDir::new("walkup");
1297        let root = tmp.path();
1298        std::fs::create_dir_all(root.join(".git")).unwrap();
1299        let nested = root.join("a").join("b");
1300        std::fs::create_dir_all(&nested).unwrap();
1301        let located = discover(&nested).expect("found via ancestor walk");
1302        assert_eq!(located.kind, BackendKind::Git);
1303        assert_eq!(located.root, root);
1304    }
1305
1306    #[test]
1307    fn discover_returns_none_outside_repo() {
1308        let tmp = TempDir::new("norepo");
1309        assert!(discover(tmp.path()).is_none());
1310    }
1311
1312    // A gitlink `.git` *file* (a linked worktree / submodule) is a valid git marker;
1313    // a stray file merely named `.git` is NOT — so it can't shadow a real repo above.
1314    #[test]
1315    fn discover_validates_dotgit_file_is_a_gitlink() {
1316        let tmp = TempDir::new("gitlink");
1317        let root = tmp.path();
1318
1319        // A gitlink file → detected as a git repo at this dir.
1320        std::fs::write(root.join(".git"), "gitdir: /somewhere/.git/worktrees/wt\n").unwrap();
1321        assert_eq!(
1322            discover(root).expect("gitlink detected").kind,
1323            BackendKind::Git
1324        );
1325
1326        // A garbage file named `.git` (not a gitlink) is rejected — and must NOT
1327        // shadow a real `.git` directory in the parent.
1328        let parent = TempDir::new("gitlink-parent");
1329        std::fs::create_dir_all(parent.path().join(".git")).unwrap();
1330        let child = parent.path().join("sub");
1331        std::fs::create_dir_all(&child).unwrap();
1332        std::fs::write(child.join(".git"), "not a gitlink, just noise\n").unwrap();
1333        let located = discover(&child).expect("walks up past the bogus .git file");
1334        assert_eq!(located.root, parent.path(), "the real repo is the parent");
1335
1336        // An empty `.git` file is not a marker.
1337        let empty = TempDir::new("gitlink-empty");
1338        std::fs::write(empty.path().join(".git"), "").unwrap();
1339        assert!(discover(empty.path()).is_none(), "empty .git is not a repo");
1340
1341        // Leading whitespace before `gitdir:` is tolerated (the `trim_start`).
1342        let spaced = TempDir::new("gitlink-spaced");
1343        std::fs::write(
1344            spaced.path().join(".git"),
1345            "  gitdir: /x/.git/worktrees/w\n",
1346        )
1347        .unwrap();
1348        assert_eq!(
1349            discover(spaced.path())
1350                .expect("spaced gitlink detected")
1351                .kind,
1352            BackendKind::Git
1353        );
1354    }
1355
1356    // --- bare git repository (issue #6) -------------------------------------
1357
1358    // The issue #6 repro: a `git init --bare` directory (no `.git` subdir, just
1359    // `HEAD`/`config`/`objects`/`refs` in the root) must open as
1360    // `Error::BareRepository`, not the generic `Error::NotARepository` — matched
1361    // by variant, not by message substring, so the distinction can't silently
1362    // regress into the old generic error.
1363    #[test]
1364    fn discover_reports_bare_repository_not_generic_not_a_repository() {
1365        let tmp = TempDir::new("bare-repo");
1366        let root = tmp.path();
1367        std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1368        std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1369        std::fs::create_dir_all(root.join("objects")).unwrap();
1370        std::fs::create_dir_all(root.join("refs")).unwrap();
1371
1372        match Repo::discover(root) {
1373            Err(Error::BareRepository(p)) => assert_eq!(p, root),
1374            other => panic!("expected Error::BareRepository, got {other:?}"),
1375        }
1376
1377        // The strict, non-walking `open` doesn't special-case bare repos — a
1378        // bare repo has no `.git`/`.jj` marker in itself, so it's just
1379        // `NotARepository` there (only `discover`'s walk-then-classify path
1380        // distinguishes it).
1381        match Repo::open(root) {
1382            Err(Error::NotARepository(p)) => assert_eq!(p, root),
1383            other => panic!("expected Error::NotARepository, got {other:?}"),
1384        }
1385    }
1386
1387    // A bare repository nested a few levels below `dir` is still found by
1388    // walking up — mirrors `discover_walks_up_to_ancestor` for the bare case.
1389    #[test]
1390    fn discover_finds_bare_repository_via_ancestor_walk() {
1391        let tmp = TempDir::new("bare-walkup");
1392        let root = tmp.path();
1393        std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1394        std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1395        std::fs::create_dir_all(root.join("objects")).unwrap();
1396        std::fs::create_dir_all(root.join("refs")).unwrap();
1397        let nested = root.join("a").join("b");
1398        std::fs::create_dir_all(&nested).unwrap();
1399
1400        match Repo::discover(&nested) {
1401            Err(Error::BareRepository(p)) => assert_eq!(p, root),
1402            other => panic!("expected Error::BareRepository, got {other:?}"),
1403        }
1404
1405        // The strict `open` never walks up, so it reports `NotARepository` on
1406        // the nested dir regardless of what sits above it.
1407        match Repo::open(&nested) {
1408            Err(Error::NotARepository(p)) => assert_eq!(p, nested),
1409            other => panic!("expected Error::NotARepository, got {other:?}"),
1410        }
1411    }
1412
1413    // A directory that merely happens to hold some, but not all four, of the
1414    // bare-repo marker entries must NOT be misdetected as a bare repository —
1415    // it's just an ordinary non-repository directory.
1416    #[test]
1417    fn discover_does_not_misdetect_partial_bare_markers_as_bare_repository() {
1418        let tmp = TempDir::new("bare-partial");
1419        let root = tmp.path();
1420        // Only `HEAD` and `config` — no `objects`/`refs` directories.
1421        std::fs::write(root.join("HEAD"), "ref: refs/heads/main\n").unwrap();
1422        std::fs::write(root.join("config"), "[core]\n\tbare = true\n").unwrap();
1423
1424        match Repo::discover(root) {
1425            Err(Error::NotARepository(p)) => assert_eq!(p, root),
1426            other => panic!("expected Error::NotARepository, got {other:?}"),
1427        }
1428    }
1429
1430    // A real (non-bare) git repository — `.git` subdirectory present — must
1431    // keep opening as before, not get swept up by the new bare-detection path.
1432    #[test]
1433    fn open_still_opens_a_normal_git_repository() {
1434        let tmp = TempDir::new("normal-git");
1435        let root = tmp.path();
1436        std::fs::create_dir_all(root.join(".git")).unwrap();
1437
1438        let repo = Repo::open(root).expect("normal git repo still opens");
1439        assert_eq!(repo.kind(), BackendKind::Git);
1440        assert_eq!(repo.root(), root);
1441    }
1442
1443    // A real jj repository must also keep opening as before.
1444    #[test]
1445    fn open_still_opens_a_normal_jj_repository() {
1446        let tmp = TempDir::new("normal-jj");
1447        let root = tmp.path();
1448        std::fs::create_dir_all(root.join(".jj").join("repo")).unwrap();
1449
1450        let repo = Repo::open(root).expect("normal jj repo still opens");
1451        assert_eq!(repo.kind(), BackendKind::Jj);
1452        assert_eq!(repo.root(), root);
1453    }
1454
1455    // A directory that is neither a repo nor a bare repo still reports the
1456    // generic `NotARepository`.
1457    #[test]
1458    fn open_reports_not_a_repository_when_nothing_found() {
1459        let tmp = TempDir::new("norepo-open");
1460        match Repo::open(tmp.path()) {
1461            Err(Error::NotARepository(p)) => assert_eq!(p, tmp.path()),
1462            other => panic!("expected Error::NotARepository, got {other:?}"),
1463        }
1464    }
1465
1466    // Unlike `discover`, the strict `open` never walks up — a repository at an
1467    // ancestor of `dir` must NOT make `open(dir)` succeed, even though
1468    // `discover(dir)` would find it.
1469    #[test]
1470    fn open_does_not_walk_up_even_though_discover_would() {
1471        let tmp = TempDir::new("open-no-walkup");
1472        let root = tmp.path();
1473        std::fs::create_dir_all(root.join(".git")).unwrap();
1474        let nested = root.join("a").join("b");
1475        std::fs::create_dir_all(&nested).unwrap();
1476
1477        match Repo::open(&nested) {
1478            Err(Error::NotARepository(p)) => assert_eq!(p, nested),
1479            other => panic!("expected Error::NotARepository, got {other:?}"),
1480        }
1481        // `discover` from the same nested dir finds the repo at `root`.
1482        assert_eq!(
1483            Repo::discover(&nested).expect("discover walks up").root(),
1484            root
1485        );
1486    }
1487
1488    // --- dispatch (hermetic, ScriptedRunner-backed) ------------------------
1489
1490    fn git_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
1491        Repo::from_git("/repo", "/repo", Git::with_runner(runner))
1492    }
1493
1494    fn jj_repo(runner: ScriptedRunner) -> Repo<ScriptedRunner> {
1495        Repo::from_jj("/repo", "/repo", Jj::with_runner(runner))
1496    }
1497
1498    // --- Debug -------------------------------------------------------------
1499    //
1500    // Regression tests for the `Repo`/`Backend` `Debug` impl (PR #7): formatting
1501    // the facade must show the elided shape (`Repo { .. }` with a `Git(..)`/
1502    // `Jj(..)` backend) but never expose the wrapped CLI client — and therefore
1503    // never a credential token that client might hold. These exist to catch a
1504    // future refactor that accidentally starts formatting the client (e.g.
1505    // deriving `Debug` on `Backend` directly, or dropping `finish_non_exhaustive`).
1506
1507    // A git-backed `Repo` built over a `Git` client holding a token via
1508    // `with_token` must format to the expected elided shape and must NOT leak
1509    // the token (or any other inner-client internal) through `{:?}`.
1510    #[test]
1511    fn debug_output_shows_elided_git_backend_and_never_leaks_the_token() {
1512        let repo = Repo::from_git(
1513            "/repo",
1514            "/repo",
1515            Git::with_runner(ScriptedRunner::new()).with_token("ghp_super_secret_token"),
1516        );
1517        let out = format!("{repo:?}");
1518        assert!(out.contains("Repo {"), "{out}");
1519        assert!(out.contains("root"), "{out}");
1520        assert!(out.contains("cwd"), "{out}");
1521        assert!(out.contains("Git(.."), "{out}");
1522        assert!(
1523            !out.contains("ghp_super_secret_token"),
1524            "token must not leak through Debug: {out}"
1525        );
1526        // Nothing from the inner `Git`/`ManagedClient`/`CliClient` internals
1527        // (e.g. its env-var bookkeeping) should surface either — the backend
1528        // must render as a bare, elided discriminant.
1529        assert!(!out.contains("ManagedClient"), "{out}");
1530        assert!(!out.contains("CliClient"), "{out}");
1531    }
1532
1533    // A jj-backed `Repo` (jj is ambient-auth-only — no `with_token`) must format
1534    // to the analogous elided shape, with the `Jj(..)` discriminant and no inner
1535    // client internals.
1536    #[test]
1537    fn debug_output_shows_elided_jj_backend() {
1538        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(ScriptedRunner::new()));
1539        let out = format!("{repo:?}");
1540        assert!(out.contains("Repo {"), "{out}");
1541        assert!(out.contains("Jj(.."), "{out}");
1542        assert!(!out.contains("ManagedClient"), "{out}");
1543        assert!(!out.contains("CliClient"), "{out}");
1544    }
1545
1546    // --- snapshot ----------------------------------------------------------
1547
1548    // git: one porcelain-v2 call + a git-dir probe → a combined RepoSnapshot.
1549    #[tokio::test]
1550    async fn git_snapshot_combines_v2_status_and_op_state() {
1551        let v2 = concat!(
1552            "# branch.oid abc123\0",
1553            "# branch.head main\0",
1554            "# branch.upstream origin/main\0",
1555            "# branch.ab +2 -0\0",
1556            "1 .M N... 100644 100644 100644 1 2 a.rs\0",
1557            "? new.txt\0",
1558        );
1559        // An empty git dir → no MERGE_HEAD / rebase dir → Clear.
1560        let gitdir = TempDir::new("snap-git");
1561        let repo = git_repo(
1562            ScriptedRunner::new()
1563                .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1564                .on(
1565                    ["git", "rev-parse", "--git-dir"],
1566                    Reply::ok(gitdir.path().to_str().unwrap()),
1567                ),
1568        );
1569        let s = repo.snapshot().await.unwrap();
1570        assert_eq!(s.branch.as_deref(), Some("main"));
1571        let tracking = s.tracking.as_ref().expect("upstream tracking");
1572        assert_eq!(tracking.branch, "origin/main");
1573        assert_eq!((tracking.ahead, tracking.behind), (Some(2), Some(0)));
1574        assert!(s.dirty);
1575        assert_eq!(s.change_count, 2, "1 tracked + 1 untracked");
1576        assert!(!s.conflicted);
1577        assert_eq!(s.operation, OperationState::Clear);
1578    }
1579
1580    // M20 (whole-solution): `snapshot()` has its OWN operation probe (separate from
1581    // `in_progress_state`); it too must report a `git am` as `ApplyMailbox`, not
1582    // `Rebase` — otherwise the new variant is dead on the snapshot → watch → mcp path.
1583    #[tokio::test]
1584    async fn git_snapshot_reports_git_am_as_apply_mailbox() {
1585        let v2 = concat!("# branch.oid abc\0", "# branch.head main\0");
1586        let gitdir = TempDir::new("snap-git-am");
1587        // A `git am` in progress: `rebase-apply/` WITH the `applying` marker.
1588        let apply = gitdir.path().join("rebase-apply");
1589        std::fs::create_dir_all(&apply).unwrap();
1590        std::fs::write(apply.join("applying"), b"").unwrap();
1591        let repo = git_repo(
1592            ScriptedRunner::new()
1593                .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1594                .on(
1595                    ["git", "rev-parse", "--git-dir"],
1596                    Reply::ok(gitdir.path().to_str().unwrap()),
1597                ),
1598        );
1599        let s = repo.snapshot().await.unwrap();
1600        assert_eq!(
1601            s.operation,
1602            OperationState::ApplyMailbox,
1603            "a git am must not read as Rebase in snapshot()"
1604        );
1605    }
1606
1607    // git with NO upstream configured: porcelain v2 omits the `# branch.upstream`
1608    // and `# branch.ab` lines, so `tracking` is None (the all-or-nothing invariant —
1609    // git is the only backend that can produce either) — mirrors the jj None case.
1610    #[tokio::test]
1611    async fn git_snapshot_without_upstream_has_no_tracking() {
1612        let v2 = concat!("# branch.oid abc123\0", "# branch.head main\0");
1613        let gitdir = TempDir::new("snap-git-noup");
1614        let repo = git_repo(
1615            ScriptedRunner::new()
1616                .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1617                .on(
1618                    ["git", "rev-parse", "--git-dir"],
1619                    Reply::ok(gitdir.path().to_str().unwrap()),
1620                ),
1621        );
1622        let s = repo.snapshot().await.unwrap();
1623        assert_eq!(s.branch.as_deref(), Some("main"));
1624        assert!(s.tracking.is_none(), "no upstream → no tracking");
1625    }
1626
1627    // M17: an upstream that is SET but GONE (deleted on the remote, or not yet
1628    // fetched) — porcelain v2 emits `# branch.upstream` but OMITS `# branch.ab`, so the
1629    // counts are uncountable. `tracking` must be `Some { branch, ahead: None, behind:
1630    // None }` (tracking configured but uncountable), NOT a fabricated in-sync `0`/`0`.
1631    #[tokio::test]
1632    async fn git_snapshot_upstream_set_but_gone_is_uncountable() {
1633        let v2 = concat!(
1634            "# branch.oid abc123\0",
1635            "# branch.head main\0",
1636            "# branch.upstream origin/main\0", // upstream named…
1637                                               // …but no `# branch.ab` line — it doesn't resolve.
1638        );
1639        let gitdir = TempDir::new("snap-git-gone");
1640        let repo = git_repo(
1641            ScriptedRunner::new()
1642                .on(["git", "status", "--porcelain=v2"], Reply::ok(v2))
1643                .on(
1644                    ["git", "rev-parse", "--git-dir"],
1645                    Reply::ok(gitdir.path().to_str().unwrap()),
1646                ),
1647        );
1648        let s = repo.snapshot().await.unwrap();
1649        let tracking = s.tracking.as_ref().expect("upstream is set");
1650        assert_eq!(tracking.branch, "origin/main");
1651        assert_eq!(
1652            (tracking.ahead, tracking.behind),
1653            (None, None),
1654            "a gone upstream is uncountable, not in-sync 0/0"
1655        );
1656    }
1657
1658    // jj: one template row + a status count; a conflicted @ maps to Conflict; no
1659    // git-style upstream/ahead/behind.
1660    #[tokio::test]
1661    async fn jj_snapshot_dirty_with_change_count() {
1662        let repo = jj_repo(
1663            ScriptedRunner::new()
1664                // snapshot template (`jj log -r @`): commit_id \t empty \t conflict
1665                .on(["jj", "log", "-r", "@"], Reply::ok("deadbeef\t0\t1\n")) // empty=0 dirty, conflict=1
1666                // `branch` via `current_branch` → `reachable_bookmarks`
1667                // (`jj log -r heads(::@ & bookmarks())`): bookmarks \t commit
1668                .on(
1669                    ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1670                    Reply::ok("\"main\"\tdeadbeef\n"),
1671                )
1672                .on(["jj", "root"], Reply::ok("/repo\n"))
1673                .on(["jj", "diff"], Reply::ok("M a.rs\nA b.rs\n")), // status -r @ --summary → 2
1674        );
1675        let s = repo.snapshot().await.unwrap();
1676        assert_eq!(s.head.as_deref(), Some("deadbeef"));
1677        assert_eq!(s.branch.as_deref(), Some("main"));
1678        assert!(s.dirty);
1679        assert_eq!(s.change_count, 2);
1680        assert!(s.conflicted);
1681        assert_eq!(s.operation, OperationState::Conflict);
1682        assert!(s.tracking.is_none(), "jj has no upstream tracking");
1683    }
1684
1685    // jj: a clean `@` (empty=1) skips the change-count spawn entirely — the test
1686    // scripts NO `diff` rule, so calling `status` would error.
1687    #[tokio::test]
1688    async fn jj_snapshot_clean_skips_change_count() {
1689        let repo = jj_repo(
1690            ScriptedRunner::new()
1691                .on(["jj", "log", "-r", "@"], Reply::ok("c0ffee\t1\t0\n"))
1692                .on(
1693                    ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1694                    Reply::ok(""),
1695                ),
1696        );
1697        let s = repo.snapshot().await.unwrap();
1698        assert_eq!(s.head.as_deref(), Some("c0ffee"));
1699        assert_eq!(s.branch, None, "no bookmark");
1700        assert!(!s.dirty);
1701        assert_eq!(s.change_count, 0);
1702        assert!(!s.conflicted);
1703        assert_eq!(s.operation, OperationState::Clear);
1704    }
1705
1706    // jj: a conflicted `@` that jj marks `empty` (conflict but no net content change)
1707    // is still reported `dirty` — the conflict is uncommitted state needing
1708    // resolution — so the count runs and the snapshot is coherent (no
1709    // `conflicted: true` next to `dirty: false`), mirroring git's conflict handling.
1710    #[tokio::test]
1711    async fn jj_snapshot_conflicted_empty_change_is_dirty() {
1712        let repo = jj_repo(
1713            ScriptedRunner::new()
1714                .on(["jj", "log", "-r", "@"], Reply::ok("c0ffee\t1\t1\n")) // empty=1, conflict=1
1715                .on(
1716                    ["jj", "log", "-r", "heads(::@ & bookmarks())"],
1717                    Reply::ok(""),
1718                ) // no bookmark
1719                .on(["jj", "root"], Reply::ok("/repo\n"))
1720                .on(["jj", "diff"], Reply::ok("M conflicted.rs\n")), // status → 1
1721        );
1722        let s = repo.snapshot().await.unwrap();
1723        assert!(s.conflicted);
1724        assert!(s.dirty, "a conflicted change is a dirty working copy");
1725        assert_eq!(s.change_count, 1);
1726        assert_eq!(s.operation, OperationState::Conflict);
1727    }
1728
1729    // jj `list_worktrees` resolves each workspace's root via the batched
1730    // `workspace_roots` fan-out (one `workspace root --name <n>` per `workspace
1731    // list` row), then builds a `WorktreeInfo` per workspace. Hermetic: scripts the
1732    // template rows + the per-name root replies — the backend glue that the
1733    // `#[ignore]` integration tests otherwise cover only with a real `jj`.
1734    #[tokio::test]
1735    async fn jj_list_worktrees_batches_root_lookups() {
1736        let repo = jj_repo(
1737            ScriptedRunner::new()
1738                .on(
1739                    ["jj", "workspace", "list"],
1740                    Reply::ok("\"default\"\tc0ffee\t\"main\"\n\"ws1\"\tdecaf0\t\n"),
1741                )
1742                .on(
1743                    [
1744                        "jj",
1745                        "--ignore-working-copy",
1746                        "workspace",
1747                        "root",
1748                        "--name",
1749                        "default",
1750                    ],
1751                    Reply::ok("/repo\n"),
1752                )
1753                .on(
1754                    [
1755                        "jj",
1756                        "--ignore-working-copy",
1757                        "workspace",
1758                        "root",
1759                        "--name",
1760                        "ws1",
1761                    ],
1762                    Reply::ok("/repo/ws1\n"),
1763                ),
1764        );
1765        let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1766        assert_eq!(worktrees.len(), 2);
1767        assert_eq!(worktrees[0].path, Path::new("/repo"));
1768        assert_eq!(worktrees[0].branch.as_deref(), Some("main"));
1769        assert_eq!(worktrees[1].path, Path::new("/repo/ws1"));
1770        assert_eq!(worktrees[1].branch, None);
1771    }
1772
1773    // A workspace whose `workspace root` lookup errors is skipped (no useful path),
1774    // mirroring the old sequential loop — the batch maps that slot to `Err`.
1775    #[tokio::test]
1776    async fn jj_list_worktrees_skips_unresolvable_root() {
1777        let repo = jj_repo(
1778            ScriptedRunner::new()
1779                .on(
1780                    ["jj", "workspace", "list"],
1781                    Reply::ok("\"default\"\tc0ffee\t\"main\"\n\"gone\"\tdecaf0\t\n"),
1782                )
1783                .on(
1784                    [
1785                        "jj",
1786                        "--ignore-working-copy",
1787                        "workspace",
1788                        "root",
1789                        "--name",
1790                        "default",
1791                    ],
1792                    Reply::ok("/repo\n"),
1793                )
1794                .on(
1795                    [
1796                        "jj",
1797                        "--ignore-working-copy",
1798                        "workspace",
1799                        "root",
1800                        "--name",
1801                        "gone",
1802                    ],
1803                    Reply::fail(1, "Error: No such workspace"),
1804                ),
1805        );
1806        let worktrees = repo.list_worktrees().await.expect("list_worktrees");
1807        assert_eq!(worktrees.len(), 1, "the unresolvable workspace is skipped");
1808        assert_eq!(worktrees[0].path, Path::new("/repo"));
1809    }
1810
1811    // remove_worktree surfaces a `workspace forget` failure rather than swallowing
1812    // it — name resolution already proved the workspace is registered, so a forget
1813    // error is a real dangling-registration the caller should see.
1814    #[tokio::test]
1815    async fn jj_remove_worktree_surfaces_forget_error() {
1816        let repo = jj_repo(
1817            ScriptedRunner::new()
1818                .on(
1819                    ["jj", "workspace", "list"],
1820                    Reply::ok("\"ws1\"\tc0ffee\t\n"),
1821                )
1822                .on(
1823                    [
1824                        "jj",
1825                        "--ignore-working-copy",
1826                        "workspace",
1827                        "root",
1828                        "--name",
1829                        "ws1",
1830                    ],
1831                    Reply::ok("/repo/ws1\n"),
1832                )
1833                .on(
1834                    ["jj", "workspace", "forget"],
1835                    Reply::fail(1, "Error: cannot forget workspace"),
1836                ),
1837        );
1838        // `/repo/ws1` does not exist on disk, so the dir-removal step is skipped and
1839        // the forget error is the sole outcome.
1840        let res = repo.remove_worktree(WorktreeRemove::new("/repo/ws1")).await;
1841        assert!(res.is_err(), "a forget failure is surfaced, not swallowed");
1842    }
1843
1844    // Windows-like removal failure: `remove_worktree` surfaces a `remove_dir_all`
1845    // failure and names what remains (the still-registered workspace) rather than
1846    // swallowing it. A *file* sits where the workspace dir should be, so
1847    // `remove_dir_all` errors deterministically on every platform.
1848    #[tokio::test]
1849    async fn jj_remove_worktree_surfaces_dir_removal_failure() {
1850        let tmp = TempDir::new("rmw-rmdir-fail");
1851        let ws = tmp.path().join("ws1");
1852        std::fs::write(&ws, b"not a dir").expect("write file where the dir should be");
1853        let root = tmp.path().to_string_lossy().into_owned();
1854        let ws_str = ws.to_string_lossy().into_owned();
1855        let repo = Repo::from_jj(
1856            &root,
1857            &root,
1858            Jj::with_runner(
1859                ScriptedRunner::new()
1860                    .on(
1861                        ["jj", "workspace", "list"],
1862                        Reply::ok("\"ws1\"\tc0ffee\t\n"),
1863                    )
1864                    .on(
1865                        [
1866                            "jj",
1867                            "--ignore-working-copy",
1868                            "workspace",
1869                            "root",
1870                            "--name",
1871                            "ws1",
1872                        ],
1873                        Reply::ok(format!("{ws_str}\n")),
1874                    ),
1875            ),
1876        );
1877        // force skips the dirty check, so the removal step is reached directly.
1878        let err = repo
1879            .remove_worktree(WorktreeRemove::new(ws.clone()).force())
1880            .await
1881            .expect_err("a dir-removal failure must be surfaced");
1882        let msg = err.to_string();
1883        assert!(
1884            msg.contains("still registered") && msg.contains("ws1"),
1885            "the failure must name what remains to clean up: {msg}"
1886        );
1887        assert!(
1888            ws.exists(),
1889            "the undeletable path must survive the failed removal"
1890        );
1891    }
1892
1893    // Compatible fallback / diagnosable error: when a registered workspace's root
1894    // can't be resolved via `workspace root --name`, a path matching none of the
1895    // resolvable ones is NOT reported as a clean `WorktreeNotFound` — absence can't be
1896    // proven, so a distinct diagnosable error naming the unresolved workspace is
1897    // raised instead (so a real-but-unresolvable workspace isn't misreported).
1898    #[tokio::test]
1899    async fn jj_remove_worktree_reports_unresolvable_workspaces() {
1900        let repo = jj_repo(
1901            ScriptedRunner::new()
1902                .on(
1903                    ["jj", "workspace", "list"],
1904                    Reply::ok("\"ws1\"\tc0ffee\t\n\"gone\"\tdecaf0\t\n"),
1905                )
1906                .on(
1907                    [
1908                        "jj",
1909                        "--ignore-working-copy",
1910                        "workspace",
1911                        "root",
1912                        "--name",
1913                        "ws1",
1914                    ],
1915                    Reply::ok("/repo/ws1\n"),
1916                )
1917                .on(
1918                    [
1919                        "jj",
1920                        "--ignore-working-copy",
1921                        "workspace",
1922                        "root",
1923                        "--name",
1924                        "gone",
1925                    ],
1926                    Reply::fail(1, "Error: No such workspace"),
1927                ),
1928        );
1929        let err = repo
1930            .remove_worktree(WorktreeRemove::new("/repo/missing"))
1931            .await
1932            .expect_err("an unresolvable workspace must not be reported as a clean not-found");
1933        assert!(
1934            !err.is_resource_not_found(),
1935            "a partial resolution is not a clean WorktreeNotFound: {err}"
1936        );
1937        let msg = err.to_string();
1938        assert!(
1939            msg.contains("could not resolve") && msg.contains("gone"),
1940            "the diagnosable error must name the unresolved workspace: {msg}"
1941        );
1942    }
1943
1944    // Repeated cleanup is idempotent: after a first pass removed the directory but its
1945    // `workspace forget` failed, a retry finds the dir already gone, re-resolves the
1946    // still-registered workspace by name, and completes the forget — no error.
1947    #[tokio::test]
1948    async fn jj_remove_worktree_retry_after_dir_gone_forgets_cleanly() {
1949        let repo = jj_repo(
1950            ScriptedRunner::new()
1951                .on(
1952                    ["jj", "workspace", "list"],
1953                    Reply::ok("\"ws1\"\tc0ffee\t\n"),
1954                )
1955                .on(
1956                    [
1957                        "jj",
1958                        "--ignore-working-copy",
1959                        "workspace",
1960                        "root",
1961                        "--name",
1962                        "ws1",
1963                    ],
1964                    Reply::ok("/repo/ws1\n"),
1965                )
1966                .on(["jj", "workspace", "forget"], Reply::ok("")),
1967        );
1968        // `/repo/ws1` does not exist on disk (a prior pass removed it), so the removal
1969        // step is skipped and the forget clears the dangling registration.
1970        repo.remove_worktree(WorktreeRemove::new("/repo/ws1"))
1971            .await
1972            .expect("a retry with the dir already gone completes the forget");
1973    }
1974
1975    // C1: the default workspace resolves at the repo root; removing it would wipe
1976    // the whole repository, so it is refused even with force = true and WITHOUT
1977    // running `workspace forget` (no such cassette rule — a miss would also error,
1978    // so we assert the *refusal* message to prove the guard, not a fallthrough).
1979    #[tokio::test]
1980    async fn jj_remove_worktree_refuses_the_main_workspace() {
1981        let repo = jj_repo(
1982            ScriptedRunner::new()
1983                .on(
1984                    ["jj", "workspace", "list"],
1985                    Reply::ok("\"default\"\tc0ffee\t\n"),
1986                )
1987                .on(
1988                    [
1989                        "jj",
1990                        "--ignore-working-copy",
1991                        "workspace",
1992                        "root",
1993                        "--name",
1994                        "default",
1995                    ],
1996                    Reply::ok("/repo\n"),
1997                ),
1998        );
1999        let err = repo
2000            .remove_worktree(WorktreeRemove::new("/repo").force())
2001            .await
2002            .expect_err("the main workspace must be refused");
2003        assert!(
2004            err.to_string().contains("main workspace"),
2005            "refusal message, not a cassette miss: {err}"
2006        );
2007    }
2008
2009    // C1: a secondary workspace with un-snapshotted edits (`current_change` reports
2010    // non-empty) is refused under force = false, and its directory is NOT deleted.
2011    #[tokio::test]
2012    async fn jj_remove_worktree_refuses_dirty_workspace_without_force() {
2013        let tmp = TempDir::new("rmw-dirty");
2014        let root = tmp.path().to_string_lossy().into_owned();
2015        let repo = Repo::from_jj(
2016            &root,
2017            &root,
2018            Jj::with_runner(
2019                ScriptedRunner::new()
2020                    .on(
2021                        ["jj", "workspace", "list"],
2022                        Reply::ok("\"ws1\"\tc0ffee\t\n"),
2023                    )
2024                    .on(
2025                        [
2026                            "jj",
2027                            "--ignore-working-copy",
2028                            "workspace",
2029                            "root",
2030                            "--name",
2031                            "ws1",
2032                        ],
2033                        Reply::ok(format!("{root}\n")),
2034                    )
2035                    // `current_change` → 3rd field `false` = not empty = dirty.
2036                    .on(["jj", "log"], Reply::ok("aaa\tbbb\tfalse\t\"work\"\n")),
2037            ),
2038        );
2039        let err = repo
2040            .remove_worktree(WorktreeRemove::new(tmp.path()))
2041            .await
2042            .expect_err("a dirty workspace must be refused without force");
2043        assert!(
2044            err.to_string().contains("uncommitted changes"),
2045            "refusal message: {err}"
2046        );
2047        assert!(
2048            tmp.path().exists(),
2049            "the workspace directory must survive a refusal"
2050        );
2051    }
2052
2053    // C1: force = true skips the dirty check and removes the directory (no
2054    // `current_change` rule is scripted, proving the check is bypassed).
2055    #[tokio::test]
2056    async fn jj_remove_worktree_with_force_removes_the_dir() {
2057        let tmp = TempDir::new("rmw-force");
2058        let ws = tmp.path().join("ws1");
2059        std::fs::create_dir_all(&ws).expect("mkdir ws");
2060        let root = tmp.path().to_string_lossy().into_owned();
2061        let ws_str = ws.to_string_lossy().into_owned();
2062        let repo = Repo::from_jj(
2063            &root,
2064            &root,
2065            Jj::with_runner(
2066                ScriptedRunner::new()
2067                    .on(
2068                        ["jj", "workspace", "list"],
2069                        Reply::ok("\"ws1\"\tc0ffee\t\n"),
2070                    )
2071                    .on(
2072                        [
2073                            "jj",
2074                            "--ignore-working-copy",
2075                            "workspace",
2076                            "root",
2077                            "--name",
2078                            "ws1",
2079                        ],
2080                        Reply::ok(format!("{ws_str}\n")),
2081                    )
2082                    .on(["jj", "workspace", "forget"], Reply::ok("")),
2083            ),
2084        );
2085        repo.remove_worktree(WorktreeRemove::new(ws.clone()).force())
2086            .await
2087            .expect("force removes a dirty worktree");
2088        assert!(!ws.exists(), "the worktree directory was removed");
2089    }
2090
2091    // C1: the main-workspace guard's store-directory branch — a workspace whose
2092    // name was changed away from `default` (via `jj workspace rename`) still owns
2093    // the object store (`.jj/repo` is a *directory*, not a secondary's file
2094    // pointer), so removal is refused even with force = true, and the dir survives.
2095    // Exercises the `|| .jj/repo.is_dir()` half of the guard (the name is not
2096    // `default`), which the name-based test can't reach.
2097    #[tokio::test]
2098    async fn jj_remove_worktree_refuses_renamed_store_owning_workspace() {
2099        let tmp = TempDir::new("rmw-store");
2100        std::fs::create_dir_all(tmp.path().join(".jj").join("repo")).expect("mk .jj/repo dir");
2101        let root = tmp.path().to_string_lossy().into_owned();
2102        let repo = Repo::from_jj(
2103            &root,
2104            &root,
2105            Jj::with_runner(
2106                ScriptedRunner::new()
2107                    .on(
2108                        ["jj", "workspace", "list"],
2109                        Reply::ok("\"mainws\"\tc0ffee\t\n"),
2110                    )
2111                    .on(
2112                        [
2113                            "jj",
2114                            "--ignore-working-copy",
2115                            "workspace",
2116                            "root",
2117                            "--name",
2118                            "mainws",
2119                        ],
2120                        Reply::ok(format!("{root}\n")),
2121                    ),
2122            ),
2123        );
2124        let err = repo
2125            .remove_worktree(WorktreeRemove::new(tmp.path()).force())
2126            .await
2127            .expect_err("a renamed store-owning workspace is still refused");
2128        assert!(
2129            err.to_string().contains("main workspace"),
2130            "refusal message: {err}"
2131        );
2132        assert!(
2133            tmp.path().exists(),
2134            "the store-owning directory must not be deleted"
2135        );
2136    }
2137
2138    #[tokio::test]
2139    async fn kind_and_escape_hatches_reflect_backend() {
2140        let repo = git_repo(ScriptedRunner::new());
2141        assert_eq!(repo.kind(), BackendKind::Git);
2142        assert!(repo.git().is_some());
2143        assert!(repo.jj().is_none());
2144    }
2145
2146    // The cwd-bound views mirror the backend, and `at` re-binds them to another
2147    // directory without a separate client.
2148    #[tokio::test]
2149    async fn bound_views_reflect_backend_and_cwd() {
2150        let git = git_repo(ScriptedRunner::new());
2151        assert!(git.git_at().is_some());
2152        assert!(git.jj_at().is_none());
2153        // A sibling handle bound elsewhere yields a view rooted at that dir.
2154        assert_eq!(git.at("/repo/wt").cwd(), Path::new("/repo/wt"));
2155
2156        let jj = jj_repo(ScriptedRunner::new());
2157        assert!(jj.jj_at().is_some());
2158        assert!(jj.git_at().is_none());
2159    }
2160
2161    #[tokio::test]
2162    async fn current_branch_maps_detached_head_to_none() {
2163        // git's `current_branch` now runs `symbolic-ref --quiet --short HEAD`:
2164        // exit 0 → the branch name, exit 1 → detached HEAD → None.
2165        let named =
2166            git_repo(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::ok("main\n")));
2167        assert_eq!(
2168            named.current_branch().await.unwrap().as_deref(),
2169            Some("main")
2170        );
2171        let detached =
2172            git_repo(ScriptedRunner::new().on(["git", "symbolic-ref"], Reply::fail(1, "")));
2173        assert!(detached.current_branch().await.unwrap().is_none());
2174    }
2175
2176    #[tokio::test]
2177    async fn changed_files_maps_git_status() {
2178        let repo = git_repo(ScriptedRunner::new().on(
2179            ["git", "status"],
2180            Reply::ok(" M a.rs\0?? b.rs\0R  new.rs\0old.rs\0"),
2181        ));
2182        let changes = repo.changed_files().await.unwrap();
2183        assert_eq!(changes.len(), 3);
2184        assert_eq!(changes[0].kind, ChangeKind::Modified);
2185        assert_eq!(changes[1].kind, ChangeKind::Added);
2186        assert_eq!(changes[2].kind, ChangeKind::Renamed);
2187        assert_eq!(changes[2].old_path.as_deref(), Some(Path::new("old.rs")));
2188    }
2189
2190    #[tokio::test]
2191    async fn local_branches_maps_git_branch_output() {
2192        let repo =
2193            git_repo(ScriptedRunner::new().on(["git", "branch"], Reply::ok("* main\n  feat\n")));
2194        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
2195    }
2196
2197    #[tokio::test]
2198    async fn branch_exists_reads_show_ref_exit() {
2199        let yes = git_repo(ScriptedRunner::new().on(["git", "show-ref"], Reply::ok("")));
2200        assert!(yes.branch_exists("main").await.unwrap());
2201        let no = git_repo(ScriptedRunner::new().on(["git", "show-ref"], Reply::fail(1, "")));
2202        assert!(!no.branch_exists("nope").await.unwrap());
2203    }
2204
2205    #[tokio::test]
2206    async fn has_uncommitted_changes_reflects_status() {
2207        let dirty = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0")));
2208        assert!(dirty.has_uncommitted_changes().await.unwrap());
2209        let clean = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok("")));
2210        assert!(!clean.has_uncommitted_changes().await.unwrap());
2211    }
2212
2213    #[tokio::test]
2214    async fn at_rebinds_cwd_and_shares_backend() {
2215        let repo = git_repo(ScriptedRunner::new());
2216        let moved = repo.at("/repo/sub");
2217        assert_eq!(moved.cwd(), Path::new("/repo/sub"));
2218        assert_eq!(moved.root(), Path::new("/repo"));
2219        assert_eq!(moved.kind(), BackendKind::Git);
2220    }
2221
2222    // --- dispatch: jj backend (hermetic) -----------------------------------
2223
2224    #[tokio::test]
2225    async fn jj_kind_and_escape_hatches_reflect_backend() {
2226        let repo = jj_repo(ScriptedRunner::new());
2227        assert_eq!(repo.kind(), BackendKind::Jj);
2228        assert!(repo.jj().is_some() && repo.git().is_none());
2229    }
2230
2231    #[tokio::test]
2232    async fn jj_current_branch_reads_bookmark() {
2233        // current_branch derives from `reachable_bookmarks`, whose template is
2234        // `<bookmarks space-joined>\t<commit>` — distinct from the strict
2235        // `current_bookmark(@)` comma-joined template.
2236        let repo =
2237            jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("\"main\"\t53e4e879\n")));
2238        assert_eq!(
2239            repo.current_branch().await.unwrap().as_deref(),
2240            Some("main")
2241        );
2242    }
2243
2244    #[tokio::test]
2245    async fn jj_current_branch_persists_across_commit() {
2246        // After a jj commit the new working-copy change carries no bookmark, but
2247        // the described parent does. `reachable_bookmarks` resolves the nearest
2248        // bookmarked ancestor, so the facade still reports it — git-like "I'm
2249        // still on my branch". Under the old strict `current_bookmark(@)` rule
2250        // this returned `None`; feeding the reachable template (`feat\t…`,
2251        // unparseable as a comma-joined bookmark name) pins the new derivation.
2252        let repo =
2253            jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("\"feat\"\tc8d49332\n")));
2254        assert_eq!(
2255            repo.current_branch().await.unwrap().as_deref(),
2256            Some("feat")
2257        );
2258    }
2259
2260    #[tokio::test]
2261    async fn jj_current_branch_tie_break_is_deterministic() {
2262        // `heads(::@ & bookmarks())` can yield several equally-near bookmarks —
2263        // a merge of two bookmarked lines (one row each) or one commit carrying
2264        // several (one row, space-joined). current_branch returns the
2265        // lexicographically-smallest name regardless of jj's row order, so the
2266        // result is stable. Here: rows `zeta` then `alpha beta` ⇒ `alpha`.
2267        let repo = jj_repo(ScriptedRunner::new().on(
2268            ["jj", "log"],
2269            Reply::ok("\"zeta\"\tabc1234\n\"alpha\" \"beta\"\tdef5678\n"),
2270        ));
2271        assert_eq!(
2272            repo.current_branch().await.unwrap().as_deref(),
2273            Some("alpha")
2274        );
2275    }
2276
2277    #[tokio::test]
2278    async fn jj_local_branches_maps_bookmark_list() {
2279        // BOOKMARK_LIST_TEMPLATE rows: `<present>\t<remote>\t"<name>"\t<commit>`.
2280        let repo = jj_repo(ScriptedRunner::new().on(
2281            ["jj", "bookmark", "list"],
2282            Reply::ok("1\t\t\"main\"\tcmt\n1\t\t\"feat\"\tm2\n"),
2283        ));
2284        assert_eq!(repo.local_branches().await.unwrap(), ["main", "feat"]);
2285    }
2286
2287    #[tokio::test]
2288    async fn jj_branch_exists_scans_bookmarks() {
2289        let repo = jj_repo(ScriptedRunner::new().on(
2290            ["jj", "bookmark", "list"],
2291            Reply::ok("1\t\t\"main\"\tcmt\n"),
2292        ));
2293        assert!(repo.branch_exists("main").await.unwrap());
2294        let repo2 = jj_repo(ScriptedRunner::new().on(
2295            ["jj", "bookmark", "list"],
2296            Reply::ok("1\t\t\"main\"\tcmt\n"),
2297        ));
2298        assert!(!repo2.branch_exists("missing").await.unwrap());
2299    }
2300
2301    #[tokio::test]
2302    async fn jj_has_uncommitted_changes_reads_empty_flag() {
2303        // CHANGE_TEMPLATE row: change_id \t commit_id \t empty \t description
2304        let dirty =
2305            jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\tfalse\t\"wip\"\n")));
2306        assert!(dirty.has_uncommitted_changes().await.unwrap());
2307        let clean =
2308            jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\ttrue\t\"\"\n")));
2309        assert!(!clean.has_uncommitted_changes().await.unwrap());
2310    }
2311
2312    // M18: a conflicted-but-**empty** `@` is uncommitted state (it needs resolution),
2313    // so `has_uncommitted_changes` returns true — agreeing with `snapshot().dirty`,
2314    // which already treats `conflict ⇒ dirty`. First `jj log` = current_change (empty),
2315    // second = is_conflicted (`"1"`).
2316    #[tokio::test]
2317    async fn jj_has_uncommitted_changes_true_when_conflicted_even_if_empty() {
2318        let repo = jj_repo(ScriptedRunner::new().on_sequence(
2319            ["jj", "log"],
2320            [
2321                Reply::ok("kz\t38\ttrue\t\"\"\n"), // current_change: empty = true
2322                Reply::ok("1\n"),                  // is_conflicted: conflicted
2323            ],
2324        ));
2325        assert!(
2326            repo.has_uncommitted_changes().await.unwrap(),
2327            "a conflicted empty @ is dirty"
2328        );
2329    }
2330
2331    #[tokio::test]
2332    async fn jj_changed_files_maps_diff_summary() {
2333        let repo = jj_repo(
2334            ScriptedRunner::new()
2335                .on(["jj", "root"], Reply::ok("/repo\n"))
2336                .on(["jj", "diff"], Reply::ok("M src/a.rs\nA b.rs\nD gone.rs\n")),
2337        );
2338        let changes = repo.changed_files().await.unwrap();
2339        assert_eq!(changes.len(), 3);
2340        assert_eq!(changes[0].kind, ChangeKind::Modified);
2341        assert_eq!(changes[1].kind, ChangeKind::Added);
2342        assert_eq!(changes[2].kind, ChangeKind::Deleted);
2343        assert!(changes.iter().all(|c| c.old_path.is_none()));
2344    }
2345
2346    // jj DOES supply the rename's original path (its `{old => new}` summary
2347    // form) — `old_path` is populated on both backends, as the DTO documents.
2348    #[tokio::test]
2349    async fn jj_changed_files_populates_rename_old_path() {
2350        let repo = jj_repo(
2351            ScriptedRunner::new()
2352                .on(["jj", "root"], Reply::ok("/repo\n"))
2353                .on(["jj", "diff"], Reply::ok("R src/{old.rs => new.rs}\n")),
2354        );
2355        let changes = repo.changed_files().await.unwrap();
2356        assert_eq!(changes.len(), 1);
2357        assert_eq!(changes[0].kind, ChangeKind::Renamed);
2358        assert_eq!(changes[0].path, Path::new("src/new.rs"));
2359        assert_eq!(
2360            changes[0].old_path.as_deref(),
2361            Some(Path::new("src/old.rs"))
2362        );
2363    }
2364
2365    // `commit_paths(&[])` is refused up front on BOTH backends: the runners have
2366    // no rules, so reaching the CLI would error differently — the guard must trip
2367    // first (on jj an empty fileset would otherwise commit the whole working
2368    // copy; on git it would exit 128).
2369    #[tokio::test]
2370    async fn commit_paths_refuses_an_empty_path_set() {
2371        for repo in [
2372            git_repo(ScriptedRunner::new()),
2373            jj_repo(ScriptedRunner::new()),
2374        ] {
2375            let err = repo
2376                .commit_paths(&[], "msg")
2377                .await
2378                .expect_err("empty paths must be refused");
2379            assert!(
2380                err.to_string().contains("at least one path"),
2381                "unexpected error: {err}"
2382            );
2383        }
2384    }
2385
2386    #[tokio::test]
2387    async fn jj_rename_branch_builds_bookmark_rename() {
2388        use processkit::testing::RecordingRunner;
2389        let rec = RecordingRunner::replying(Reply::ok(""));
2390        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
2391        repo.rename_branch("old", "new").await.unwrap();
2392        assert_eq!(
2393            rec.only_call().args_str(),
2394            ["bookmark", "rename", "old", "new", "--color", "never"]
2395        );
2396    }
2397
2398    // The widened common surface dispatches `checkout` to each backend's verb:
2399    // git `checkout`, jj `edit`.
2400    #[tokio::test]
2401    async fn checkout_dispatches_per_backend() {
2402        use processkit::testing::RecordingRunner;
2403        let grec = RecordingRunner::replying(Reply::ok(""));
2404        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2405            .checkout("feat")
2406            .await
2407            .unwrap();
2408        // Trailing `--` so a path-like ref can't fall into pathspec mode (C2).
2409        assert_eq!(grec.only_call().args_str(), ["checkout", "feat", "--"]);
2410
2411        let jrec = RecordingRunner::replying(Reply::ok(""));
2412        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2413            .checkout("feat")
2414            .await
2415            .unwrap();
2416        assert_eq!(
2417            jrec.only_call().args_str(),
2418            ["edit", "feat", "--color", "never"]
2419        );
2420    }
2421
2422    #[tokio::test]
2423    async fn new_child_dispatches_per_backend() {
2424        use processkit::testing::RecordingRunner;
2425        let grec = RecordingRunner::replying(Reply::ok(""));
2426        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2427            .new_child("feat")
2428            .await
2429            .unwrap();
2430        assert_eq!(grec.only_call().args_str(), ["checkout", "feat", "--"]);
2431
2432        let jrec = RecordingRunner::replying(Reply::ok(""));
2433        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2434            .new_child("feat")
2435            .await
2436            .unwrap();
2437        assert_eq!(
2438            jrec.only_call().args_str(),
2439            ["new", "feat", "--color", "never"]
2440        );
2441    }
2442
2443    // A1: `delete_branch` takes a `BranchDelete` spec; `.force()` threads through to
2444    // git's `-D` (vs `-d`), and jj ignores it (its `bookmark delete` has no force).
2445    #[tokio::test]
2446    async fn delete_branch_spec_threads_force_to_git_only() {
2447        use processkit::testing::RecordingRunner;
2448        let forced = RecordingRunner::replying(Reply::ok(""));
2449        Repo::from_git("/repo", "/repo", Git::with_runner(&forced))
2450            .delete_branch(BranchDelete::new("feat").force())
2451            .await
2452            .unwrap();
2453        assert!(
2454            forced.only_call().args_str().iter().any(|a| a == "-D"),
2455            "force → branch -D"
2456        );
2457
2458        let unforced = RecordingRunner::replying(Reply::ok(""));
2459        Repo::from_git("/repo", "/repo", Git::with_runner(&unforced))
2460            .delete_branch(BranchDelete::new("feat"))
2461            .await
2462            .unwrap();
2463        assert!(
2464            unforced.only_call().args_str().iter().any(|a| a == "-d"),
2465            "no force → branch -d"
2466        );
2467
2468        let jj = RecordingRunner::replying(Reply::ok(""));
2469        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jj))
2470            .delete_branch(BranchDelete::new("feat").force())
2471            .await
2472            .unwrap();
2473        assert!(
2474            !jj.only_call()
2475                .args_str()
2476                .iter()
2477                .any(|a| a == "-D" || a == "--force"),
2478            "jj bookmark delete has no force flag"
2479        );
2480    }
2481
2482    #[tokio::test]
2483    async fn fetch_branch_dispatches_per_backend() {
2484        use processkit::testing::RecordingRunner;
2485        let grec = RecordingRunner::replying(Reply::ok(""));
2486        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2487            .fetch_branch("main")
2488            .await
2489            .unwrap();
2490        assert!(
2491            grec.only_call()
2492                .args_str()
2493                .starts_with(&["fetch".to_string()])
2494        );
2495
2496        let jrec = RecordingRunner::replying(Reply::ok(""));
2497        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2498            .fetch_branch("main")
2499            .await
2500            .unwrap();
2501        let args = jrec.only_call().args_str();
2502        assert_eq!(&args[..2], &["git", "fetch"]);
2503    }
2504
2505    // The facade push is the honest LCD: git pushes the ref with `-u origin`,
2506    // jj pushes the bookmark's state with `-b`. Argv pinned on both backends.
2507    #[tokio::test]
2508    async fn push_dispatches_per_backend() {
2509        use processkit::testing::RecordingRunner;
2510        let grec = RecordingRunner::replying(Reply::ok(""));
2511        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2512            .push("feature")
2513            .await
2514            .unwrap();
2515        assert_eq!(
2516            grec.only_call().args_str(),
2517            ["push", "-u", "origin", "feature"]
2518        );
2519
2520        let jrec = RecordingRunner::replying(Reply::ok(""));
2521        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2522            .push("feature")
2523            .await
2524            .unwrap();
2525        let args = jrec.only_call().args_str();
2526        // `exact:` disables jj's glob matching so a `*` can't push every bookmark (H1).
2527        assert_eq!(&args[..4], &["git", "push", "-b", "exact:feature"]);
2528    }
2529
2530    // A flag-like branch is now rejected the same way on BOTH backends: the
2531    // facade converts the branch string into the validated newtype at the
2532    // boundary (`vcs_git::RefName` / `vcs_jj::BookmarkName`), so `--force` is
2533    // refused with a classifiable input-validation error BEFORE any process
2534    // spawns — no longer a per-backend difference.
2535    #[tokio::test]
2536    async fn push_flag_like_branch_rejected_before_spawn_on_both_backends() {
2537        use processkit::testing::RecordingRunner;
2538        let grec = RecordingRunner::replying(Reply::ok(""));
2539        let err = Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2540            .push("--force")
2541            .await
2542            .unwrap_err();
2543        assert!(err.is_invalid_input(), "git: got {err:?}");
2544        assert_eq!(grec.calls().len(), 0, "git: no process must have spawned");
2545
2546        let jrec = RecordingRunner::replying(Reply::ok(""));
2547        let err = Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2548            .push("--force")
2549            .await
2550            .unwrap_err();
2551        assert!(err.is_invalid_input(), "jj: got {err:?}");
2552        assert_eq!(jrec.calls().len(), 0, "jj: no process must have spawned");
2553    }
2554
2555    #[tokio::test]
2556    async fn fetch_from_names_the_remote_on_both_backends() {
2557        use processkit::testing::RecordingRunner;
2558        let grec = RecordingRunner::replying(Reply::ok(""));
2559        Repo::from_git("/repo", "/repo", Git::with_runner(&grec))
2560            .fetch_from("upstream")
2561            .await
2562            .unwrap();
2563        assert_eq!(
2564            grec.only_call().args_str(),
2565            ["fetch", "--quiet", "upstream"]
2566        );
2567
2568        let jrec = RecordingRunner::replying(Reply::ok(""));
2569        Repo::from_jj("/repo", "/repo", Jj::with_runner(&jrec))
2570            .fetch_from("upstream")
2571            .await
2572            .unwrap();
2573        let args = jrec.only_call().args_str();
2574        // `exact:` disables jj's glob matching on the remote name (H1).
2575        assert_eq!(&args[..4], &["git", "fetch", "--remote", "exact:upstream"]);
2576    }
2577
2578    // git: untracked files count as uncommitted but not as *tracked* changes.
2579    #[tokio::test]
2580    async fn git_has_tracked_changes_ignores_untracked() {
2581        let dirty = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok(" M a.rs\0")));
2582        assert!(dirty.has_tracked_changes().await.unwrap());
2583        // `--untracked-files=no` means git itself omits `??` entries; an empty
2584        // reply is what a tracked-clean tree returns.
2585        let clean = git_repo(ScriptedRunner::new().on(["git", "status"], Reply::ok("")));
2586        assert!(!clean.has_tracked_changes().await.unwrap());
2587    }
2588
2589    // jj has no untracked concept — `has_tracked_changes` follows `@`'s emptiness.
2590    #[tokio::test]
2591    async fn jj_has_tracked_changes_follows_working_copy() {
2592        let dirty =
2593            jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("kz\t38\tfalse\t\"wip\"\n")));
2594        assert!(dirty.has_tracked_changes().await.unwrap());
2595    }
2596
2597    #[tokio::test]
2598    async fn conflicted_files_dispatches_per_backend() {
2599        let git =
2600            git_repo(ScriptedRunner::new().on(["git", "diff"], Reply::ok("a.rs\0b dir/c.rs\0")));
2601        assert_eq!(
2602            git.conflicted_files().await.unwrap(),
2603            [PathBuf::from("a.rs"), PathBuf::from("b dir/c.rs")]
2604        );
2605
2606        let jj = jj_repo(
2607            ScriptedRunner::new().on(["jj", "resolve"], Reply::ok("a.rs    2-sided conflict\n")),
2608        );
2609        assert_eq!(
2610            jj.conflicted_files().await.unwrap(),
2611            [PathBuf::from("a.rs")]
2612        );
2613        // The benign "no conflicts" non-zero exit still reads as an empty list.
2614        let clean = jj_repo(ScriptedRunner::new().on(
2615            ["jj", "resolve"],
2616            Reply::fail(2, "Error: No conflicts found at this revision"),
2617        ));
2618        assert!(clean.conflicted_files().await.unwrap().is_empty());
2619    }
2620
2621    #[test]
2622    fn merge_probe_is_clean() {
2623        assert!(MergeProbe::Clean.is_clean());
2624        assert!(!MergeProbe::Conflicts(vec!["a.rs".into()]).is_clean());
2625    }
2626
2627    // git try_merge, clean: probe merge, no MERGE_HEAD afterwards (the scripted
2628    // git-dir doesn't exist) → no abort, `Clean`.
2629    #[tokio::test]
2630    async fn git_try_merge_reports_clean_and_skips_needless_abort() {
2631        use processkit::testing::RecordingRunner;
2632        let rec = RecordingRunner::new(
2633            ScriptedRunner::new()
2634                .on(["git", "merge"], Reply::ok("Already up to date.\n"))
2635                .on(["git", "rev-parse"], Reply::ok("/vcs-core-no-such-git-dir")),
2636        );
2637        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2638        assert_eq!(repo.try_merge("other").await.unwrap(), MergeProbe::Clean);
2639        assert!(
2640            rec.calls()
2641                .iter()
2642                .all(|c| !c.args_str().contains(&"--abort".to_string())),
2643            "no merge to abort"
2644        );
2645    }
2646
2647    // git try_merge, conflict: conflicted paths are read BEFORE the abort (abort
2648    // clears the unmerged index), then the merge is aborted.
2649    #[tokio::test]
2650    async fn git_try_merge_collects_conflicts_then_aborts() {
2651        use processkit::testing::RecordingRunner;
2652        let rec = RecordingRunner::new(
2653            ScriptedRunner::new()
2654                // Order matters: ["merge","--abort"] must outrank the ["merge"] rule.
2655                .on(["git", "merge", "--abort"], Reply::ok(""))
2656                .on(
2657                    ["git", "merge"],
2658                    Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
2659                )
2660                .on(["git", "diff"], Reply::ok("a.rs\0")),
2661        );
2662        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2663        assert_eq!(
2664            repo.try_merge("other").await.unwrap(),
2665            MergeProbe::Conflicts(vec![PathBuf::from("a.rs")])
2666        );
2667        let calls = rec.calls();
2668        let diff_pos = calls.iter().position(|c| c.args_str()[0] == "diff");
2669        let abort_pos = calls
2670            .iter()
2671            .position(|c| c.args_str().contains(&"--abort".to_string()));
2672        assert!(diff_pos.unwrap() < abort_pos.unwrap(), "{calls:?}");
2673    }
2674
2675    // git try_merge: a failing rollback must propagate, not be reported as a
2676    // clean/conflicted probe.
2677    #[tokio::test]
2678    async fn git_try_merge_propagates_abort_failure() {
2679        let tmp = TempDir::new("probe-abort");
2680        std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
2681        let repo = git_repo(
2682            ScriptedRunner::new()
2683                .on(
2684                    ["git", "merge", "--abort"],
2685                    Reply::fail(128, "fatal: cannot abort"),
2686                )
2687                .on(["git", "merge"], Reply::ok(""))
2688                .on(
2689                    ["git", "rev-parse"],
2690                    Reply::ok(tmp.path().to_str().unwrap()),
2691                ),
2692        );
2693        assert!(repo.try_merge("other").await.is_err());
2694    }
2695
2696    // jj try_merge: op head captured first, probe runs, op restore always runs.
2697    #[tokio::test]
2698    async fn jj_try_merge_probes_and_restores() {
2699        use processkit::testing::RecordingRunner;
2700        let rec = RecordingRunner::new(
2701            ScriptedRunner::new()
2702                .on(["jj", "op", "log"], Reply::ok("op42\n"))
2703                .on(["jj", "op", "restore"], Reply::ok(""))
2704                .on(["jj", "new"], Reply::ok(""))
2705                .on(["jj", "log"], Reply::ok("1\n")) // is_conflicted → true
2706                .on(["jj", "resolve"], Reply::ok("a.rs    2-sided conflict\n")),
2707        );
2708        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
2709        assert_eq!(
2710            repo.try_merge("feature").await.unwrap(),
2711            MergeProbe::Conflicts(vec![PathBuf::from("a.rs")])
2712        );
2713        let calls = rec.calls();
2714        assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
2715        assert_eq!(calls[1].args_str()[0], "new");
2716        let last = calls.last().unwrap().args_str();
2717        assert_eq!(last[..3], ["op", "restore", "op42"]);
2718    }
2719
2720    #[tokio::test]
2721    async fn jj_try_merge_clean_and_restore_failure() {
2722        // Conflict-free probe → Clean (no resolve call needed).
2723        let clean = jj_repo(
2724            ScriptedRunner::new()
2725                .on(["jj", "op", "log"], Reply::ok("op42\n"))
2726                .on(["jj", "op", "restore"], Reply::ok(""))
2727                .on(["jj", "new"], Reply::ok(""))
2728                .on(["jj", "log"], Reply::ok("0\n")),
2729        );
2730        assert_eq!(clean.try_merge("feature").await.unwrap(), MergeProbe::Clean);
2731
2732        // A failing op restore breaks the rollback guarantee → error, not Clean.
2733        let broken = jj_repo(
2734            ScriptedRunner::new()
2735                .on(["jj", "op", "log"], Reply::ok("op42\n"))
2736                .on(["jj", "op", "restore"], Reply::fail(1, "op not found"))
2737                .on(["jj", "new"], Reply::ok(""))
2738                .on(["jj", "log"], Reply::ok("0\n")),
2739        );
2740        assert!(broken.try_merge("feature").await.is_err());
2741    }
2742
2743    // jj try_merge shares `Jj::rollback_to`'s concurrency guard: if a concurrent jj
2744    // process advances the op log during the trial merge (jj records a `>= 2`-parent
2745    // "reconcile divergent operations" merge), the rollback is REFUSED rather than
2746    // clobbering that work — try_merge surfaces `Error::Rollback` instead of a stale,
2747    // untrustworthy `Clean`, and issues no `op restore`.
2748    #[tokio::test]
2749    async fn jj_try_merge_refuses_rollback_on_op_log_divergence() {
2750        use processkit::testing::RecordingRunner;
2751        let rec = RecordingRunner::new(
2752            ScriptedRunner::new()
2753                .on_sequence(
2754                    ["jj", "op", "log"],
2755                    [
2756                        Reply::ok("op42\n"),              // capture → pre
2757                        Reply::ok("merge\t2\nop42\t1\n"), // probe → foreign reconcile merge
2758                    ],
2759                )
2760                .on(["jj", "op", "restore"], Reply::ok(""))
2761                .on(["jj", "new"], Reply::ok(""))
2762                .on(["jj", "log"], Reply::ok("0\n")),
2763        );
2764        let repo = Repo::from_jj("/repo", "/repo", Jj::with_runner(&rec));
2765        let err = repo
2766            .try_merge("feature")
2767            .await
2768            .expect_err("a divergence must error, not report a stale Clean");
2769        assert!(
2770            matches!(err, Error::Rollback(vcs_jj::Rollback::SkippedDiverged)),
2771            "expected Error::Rollback(SkippedDiverged), got {err:?}"
2772        );
2773        assert!(
2774            rec.calls()
2775                .iter()
2776                .all(|c| c.args_str()[..2] != ["op", "restore"]),
2777            "the concurrent op must not be clobbered by a restore: {:?}",
2778            rec.calls()
2779        );
2780    }
2781
2782    // continue_in_progress with unresolved paths reports `Conflict` and must NOT
2783    // attempt the continue (git would hard-error).
2784    #[tokio::test]
2785    async fn git_continue_blocked_by_conflicts_does_not_act() {
2786        use processkit::testing::RecordingRunner;
2787        let rec =
2788            RecordingRunner::new(ScriptedRunner::new().on(["git", "diff"], Reply::ok("a.rs\0")));
2789        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2790        assert_eq!(
2791            repo.continue_in_progress().await.unwrap(),
2792            OperationState::Conflict
2793        );
2794        assert!(
2795            rec.calls().iter().all(|c| c.args_str()[0] == "diff"),
2796            "only the conflict probe may run: {:?}",
2797            rec.calls()
2798        );
2799    }
2800
2801    // A continued rebase that stops on the NEXT patch's conflict exits non-zero;
2802    // continue_in_progress must report that as `Conflict`, not as an error. The
2803    // first conflict probe must see a clean index (else continue is blocked), the
2804    // post-continue probe must see the new conflict — a stateful predicate
2805    // sequences the two `diff` replies.
2806    #[tokio::test]
2807    async fn git_continue_maps_rebase_re_conflict() {
2808        use std::sync::Arc as StdArc;
2809        use std::sync::atomic::{AtomicBool, Ordering};
2810        let tmp = TempDir::new("rebase-restop");
2811        std::fs::create_dir_all(tmp.path().join("rebase-merge")).unwrap();
2812        let seen_first_diff = StdArc::new(AtomicBool::new(false));
2813        let flag = StdArc::clone(&seen_first_diff);
2814        let repo = git_repo(
2815            ScriptedRunner::new()
2816                .when(
2817                    move |cmd| {
2818                        cmd.arguments().first().and_then(|a| a.to_str()) == Some("diff")
2819                            && flag.swap(true, Ordering::SeqCst)
2820                    },
2821                    Reply::ok("a.rs\0"),
2822                )
2823                .on(["git", "diff"], Reply::ok(""))
2824                .on(
2825                    ["git", "rev-parse"],
2826                    Reply::ok(tmp.path().to_str().unwrap()),
2827                )
2828                .on(
2829                    ["git", "rebase", "--continue"],
2830                    Reply::fail(1, "CONFLICT (content): Merge conflict in a.rs"),
2831                ),
2832        );
2833        assert_eq!(
2834            repo.continue_in_progress().await.unwrap(),
2835            OperationState::Conflict
2836        );
2837    }
2838
2839    // abort_in_progress dispatches to `merge --abort` when MERGE_HEAD is present.
2840    #[tokio::test]
2841    async fn git_abort_dispatches_on_merge_in_progress() {
2842        use processkit::testing::RecordingRunner;
2843        let tmp = TempDir::new("abort");
2844        std::fs::write(tmp.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
2845        let rec = RecordingRunner::new(
2846            ScriptedRunner::new()
2847                .on(
2848                    ["git", "rev-parse"],
2849                    Reply::ok(tmp.path().to_str().unwrap()),
2850                )
2851                .on(["git", "merge", "--abort"], Reply::ok("")),
2852        );
2853        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2854        repo.abort_in_progress().await.unwrap();
2855        assert!(
2856            rec.calls()
2857                .iter()
2858                .any(|c| c.args_str() == ["merge", "--abort"]),
2859            "{:?}",
2860            rec.calls()
2861        );
2862    }
2863
2864    // git surfaces an interrupted op as on-disk state: in_progress_state returns
2865    // Merge when MERGE_HEAD is present and Rebase when a rebase dir is — the
2866    // documented asymmetry (git's conflict IS that paused state, never `Conflict`
2867    // from this method).
2868    #[tokio::test]
2869    async fn git_in_progress_state_maps_merge_and_rebase() {
2870        let merging = TempDir::new("inprog-merge");
2871        std::fs::write(merging.path().join("MERGE_HEAD"), "deadbeef\n").unwrap();
2872        let merge_repo = Repo::from_git(
2873            "/repo",
2874            "/repo",
2875            Git::with_runner(ScriptedRunner::new().on(
2876                ["git", "rev-parse"],
2877                Reply::ok(merging.path().to_str().unwrap()),
2878            )),
2879        );
2880        assert_eq!(
2881            merge_repo.in_progress_state().await.unwrap(),
2882            OperationState::Merge
2883        );
2884
2885        let rebasing = TempDir::new("inprog-rebase");
2886        std::fs::create_dir_all(rebasing.path().join("rebase-merge")).unwrap();
2887        let rebase_repo = Repo::from_git(
2888            "/repo",
2889            "/repo",
2890            Git::with_runner(ScriptedRunner::new().on(
2891                ["git", "rev-parse"],
2892                Reply::ok(rebasing.path().to_str().unwrap()),
2893            )),
2894        );
2895        assert_eq!(
2896            rebase_repo.in_progress_state().await.unwrap(),
2897            OperationState::Rebase
2898        );
2899    }
2900
2901    // T-044: the sequencer states are read from their own git-dir markers and,
2902    // crucially, a cherry-pick/revert marker is NOT mistaken for a merge (which
2903    // would then dispatch `merge --abort`). `snapshot().operation` must agree with
2904    // `in_progress_state`, since the watcher diffs the snapshot.
2905    #[tokio::test]
2906    async fn git_in_progress_state_maps_cherry_pick_revert_and_bisect() {
2907        for (marker, expected) in [
2908            ("CHERRY_PICK_HEAD", OperationState::CherryPick),
2909            ("REVERT_HEAD", OperationState::Revert),
2910            ("BISECT_LOG", OperationState::Bisect),
2911        ] {
2912            let gd = TempDir::new("inprog-seq");
2913            std::fs::write(gd.path().join(marker), "deadbeef\n").unwrap();
2914            let repo = Repo::from_git(
2915                "/repo",
2916                "/repo",
2917                // `snapshot` also runs `status --porcelain=v2 --branch`; a clean reply
2918                // lets it reach the operation probe. Both methods resolve the git dir
2919                // via `rev-parse`.
2920                Git::with_runner(
2921                    ScriptedRunner::new()
2922                        .on(["git", "status"], Reply::ok(""))
2923                        .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap())),
2924                ),
2925            );
2926            assert_eq!(
2927                repo.in_progress_state().await.unwrap(),
2928                expected,
2929                "{marker} must read as {expected:?}"
2930            );
2931            assert_eq!(
2932                repo.snapshot().await.unwrap().operation,
2933                expected,
2934                "snapshot().operation must agree for {marker}"
2935            );
2936        }
2937    }
2938
2939    // T-044: abort dispatches the state's OWN git command — the whole point of
2940    // keeping the states distinct. A cherry-pick must abort with `cherry-pick
2941    // --abort`, never `merge --abort`.
2942    #[tokio::test]
2943    async fn git_abort_dispatches_each_sequencer_command() {
2944        use processkit::testing::RecordingRunner;
2945        for (marker, argv) in [
2946            ("CHERRY_PICK_HEAD", vec!["cherry-pick", "--abort"]),
2947            ("REVERT_HEAD", vec!["revert", "--abort"]),
2948            ("BISECT_LOG", vec!["bisect", "reset"]),
2949        ] {
2950            let gd = TempDir::new("abort-seq");
2951            let marker_path = gd.path().join(marker);
2952            std::fs::write(&marker_path, "x\n").unwrap();
2953            // The abort command's ScriptedRunner side-effect: remove the marker so the
2954            // *post-call* `in_progress_state` re-probe reads `Clear`.
2955            let mp = marker_path.clone();
2956            let rec = RecordingRunner::new(
2957                ScriptedRunner::new()
2958                    .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap()))
2959                    .when(
2960                        move |cmd| {
2961                            let a0 = cmd.arguments().first().and_then(|a| a.to_str());
2962                            let is_abort = matches!(a0, Some("cherry-pick" | "revert" | "bisect"));
2963                            if is_abort {
2964                                let _ = std::fs::remove_file(&mp);
2965                            }
2966                            is_abort
2967                        },
2968                        Reply::ok(""),
2969                    ),
2970            );
2971            let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2972            assert_eq!(
2973                repo.abort_in_progress().await.unwrap(),
2974                OperationState::Clear,
2975                "{marker} abort must leave the repo Clear"
2976            );
2977            assert!(
2978                rec.calls().iter().any(|c| c.args_str() == argv),
2979                "{marker} must dispatch {argv:?}, got {:?}",
2980                rec.calls()
2981            );
2982        }
2983    }
2984
2985    // T-044: a bisect has no continue step — `continue_in_progress` must refuse it
2986    // with `Error::Unsupported`, not silently report it still in progress. And no
2987    // git mutation may run (only the conflict probe + git-dir resolution).
2988    #[tokio::test]
2989    async fn git_continue_on_bisect_is_unsupported_and_inert() {
2990        use processkit::testing::RecordingRunner;
2991        let gd = TempDir::new("continue-bisect");
2992        std::fs::write(gd.path().join("BISECT_LOG"), "x\n").unwrap();
2993        let rec = RecordingRunner::new(
2994            ScriptedRunner::new()
2995                .on(["git", "diff"], Reply::ok("")) // no conflicted paths
2996                .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap())),
2997        );
2998        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
2999        let err = repo
3000            .continue_in_progress()
3001            .await
3002            .expect_err("bisect continue must be refused");
3003        assert!(err.is_unsupported(), "expected Unsupported, got {err:?}");
3004        assert!(
3005            rec.calls()
3006                .iter()
3007                .all(|c| matches!(c.args_str()[0].as_str(), "diff" | "rev-parse")),
3008            "no git mutation may run for an unsupported continue: {:?}",
3009            rec.calls()
3010        );
3011    }
3012
3013    // T-044: a cherry-pick that continues cleanly commits and reports the post-call
3014    // state; the routing calls `cherry-pick --continue`, not a merge/rebase continue.
3015    #[tokio::test]
3016    async fn git_continue_dispatches_cherry_pick_continue() {
3017        use processkit::testing::RecordingRunner;
3018        let gd = TempDir::new("continue-cp");
3019        let marker = gd.path().join("CHERRY_PICK_HEAD");
3020        std::fs::write(&marker, "x\n").unwrap();
3021        let mp = marker.clone();
3022        let rec = RecordingRunner::new(
3023            ScriptedRunner::new()
3024                .on(["git", "diff"], Reply::ok("")) // nothing conflicted → not blocked
3025                .on(["git", "rev-parse"], Reply::ok(gd.path().to_str().unwrap()))
3026                .when(
3027                    move |cmd| {
3028                        let is_cont =
3029                            cmd.arguments().first().and_then(|a| a.to_str()) == Some("cherry-pick");
3030                        if is_cont {
3031                            let _ = std::fs::remove_file(&mp); // completes the pick
3032                        }
3033                        is_cont
3034                    },
3035                    Reply::ok(""),
3036                ),
3037        );
3038        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3039        assert_eq!(
3040            repo.continue_in_progress().await.unwrap(),
3041            OperationState::Clear
3042        );
3043        assert!(
3044            rec.calls()
3045                .iter()
3046                .any(|c| c.args_str() == ["cherry-pick", "--continue"]),
3047            "must dispatch cherry-pick --continue: {:?}",
3048            rec.calls()
3049        );
3050    }
3051
3052    // On an unborn git repo (no commits) diff_stat probes is_unborn and stats
3053    // against the empty tree instead of the unresolvable HEAD, so a fresh working
3054    // tree reports its additions rather than erroring. The empty-tree id is
3055    // resolved from git (`hash-object`), so it tracks the repo's object format
3056    // rather than being a hard-coded SHA-1 value.
3057    #[tokio::test]
3058    async fn git_diff_stat_unborn_uses_empty_tree() {
3059        use processkit::testing::RecordingRunner;
3060        // A SHA-256 repo's empty-tree id (64 hex): the value `hash-object` returns,
3061        // which `diff_stat` must then target verbatim.
3062        let oid = "6ef19b41225c5369f1c104d45d8d85efa9b057b53b14b4b9b939dd74decc5321";
3063        let rec = RecordingRunner::new(
3064            ScriptedRunner::new()
3065                .on(["git", "rev-parse"], Reply::fail(1, "")) // HEAD unborn
3066                .on(["git", "hash-object"], Reply::ok(format!("{oid}\n")))
3067                .on(
3068                    ["git", "diff", "--shortstat"],
3069                    Reply::ok(" 1 file changed, 2 insertions(+)\n"),
3070                ),
3071        );
3072        let repo = Repo::from_git("/repo", "/repo", Git::with_runner(&rec));
3073        let stat = repo.diff_stat().await.unwrap();
3074        assert_eq!(stat.insertions, 2);
3075        assert!(
3076            rec.calls()
3077                .iter()
3078                .any(|c| c.args_str() == ["diff", "--shortstat", oid, "--"]),
3079            "diff_stat should target the resolved empty tree on an unborn repo: {:?}",
3080            rec.calls()
3081        );
3082    }
3083
3084    // `Repo::log` on git maps `GitApi::log`'s typed `Commit` (hash/author/date/
3085    // subject) onto the facade `Commit`, with author/date populated.
3086    #[tokio::test]
3087    async fn git_log_maps_commit_fields() {
3088        let repo = git_repo(ScriptedRunner::new().on(
3089            ["git", "log"],
3090            Reply::ok("deadbeef\u{1f}dead\u{1f}Jane\u{1f}2026-05-31T10:00:00+00:00\u{1f}Fix bug\0"),
3091        ));
3092        let commits = repo.log("HEAD", 10).await.unwrap();
3093        assert_eq!(commits.len(), 1);
3094        assert_eq!(commits[0].id, "deadbeef");
3095        assert_eq!(commits[0].description, "Fix bug");
3096        assert_eq!(commits[0].author.as_deref(), Some("Jane"));
3097        assert_eq!(
3098            commits[0].date.as_deref(),
3099            Some("2026-05-31T10:00:00+00:00")
3100        );
3101    }
3102
3103    // `Repo::log` on jj maps `JjApi::log`'s typed `Change` (change-id/commit-id/
3104    // empty/description) onto the facade `Commit` — author/date stay `None`, since
3105    // jj's typed log doesn't surface them.
3106    #[tokio::test]
3107    async fn jj_log_maps_change_with_no_author_or_date() {
3108        let repo = jj_repo(ScriptedRunner::new().on(
3109            ["jj", "log"],
3110            Reply::ok("kztuxlro\t38e00654\tfalse\t\"wip\"\n"),
3111        ));
3112        let commits = repo.log("@", 10).await.unwrap();
3113        assert_eq!(commits.len(), 1);
3114        assert_eq!(commits[0].id, "38e00654");
3115        assert_eq!(commits[0].description, "wip");
3116        assert_eq!(commits[0].author, None);
3117        assert_eq!(commits[0].date, None);
3118    }
3119
3120    // `Repo::show_file` on git dispatches to `GitApi::show_file` and forwards its
3121    // content verbatim.
3122    #[tokio::test]
3123    async fn git_show_file_dispatches_to_git_backend() {
3124        let repo = git_repo(ScriptedRunner::new().on(["git", "show"], Reply::ok("fn main() {}\n")));
3125        let content = repo.show_file("HEAD", "src/main.rs").await.unwrap();
3126        assert_eq!(content, "fn main() {}\n");
3127    }
3128
3129    // `Repo::show_file` on jj dispatches to `JjApi::file_show` and forwards its
3130    // content verbatim.
3131    #[tokio::test]
3132    async fn jj_show_file_dispatches_to_jj_backend() {
3133        let repo =
3134            jj_repo(ScriptedRunner::new().on(["jj", "file", "show"], Reply::ok("fn main() {}\n")));
3135        let content = repo.show_file("@-", "src/main.rs").await.unwrap();
3136        assert_eq!(content, "fn main() {}\n");
3137    }
3138
3139    // On jj, abort/continue are reporting no-ops (nothing is ever paused).
3140    #[tokio::test]
3141    async fn jj_abort_and_continue_are_reporting_noops() {
3142        let conflicted = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
3143        assert_eq!(
3144            conflicted.abort_in_progress().await.unwrap(),
3145            OperationState::Conflict
3146        );
3147        let clear = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
3148        assert_eq!(
3149            clear.continue_in_progress().await.unwrap(),
3150            OperationState::Clear
3151        );
3152    }
3153
3154    // jj records conflicts on the change; the facade maps that to `Conflict`.
3155    #[tokio::test]
3156    async fn jj_in_progress_state_maps_conflict() {
3157        let conflicted = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
3158        assert_eq!(
3159            conflicted.in_progress_state().await.unwrap(),
3160            OperationState::Conflict
3161        );
3162        let clear = jj_repo(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
3163        assert_eq!(
3164            clear.in_progress_state().await.unwrap(),
3165            OperationState::Clear
3166        );
3167    }
3168
3169    // `&dyn VcsRepo` must dispatch through the real inherent methods (a delegating
3170    // body that recursed would stack-overflow here instead of returning).
3171    #[tokio::test]
3172    async fn vcs_repo_trait_object_dispatches() {
3173        let repo = git_repo(
3174            ScriptedRunner::new()
3175                .on(["git", "symbolic-ref"], Reply::ok("main\n"))
3176                .on(["git", "show-ref"], Reply::ok("")),
3177        );
3178        let dynamic: &dyn VcsRepo = &repo;
3179        assert_eq!(dynamic.kind(), BackendKind::Git);
3180        assert_eq!(
3181            dynamic.current_branch().await.unwrap().as_deref(),
3182            Some("main")
3183        );
3184        // Exercise a reference-argument async method through `&dyn` — pins the
3185        // async_trait lifetime capture the macro relies on (no-arg calls don't).
3186        assert!(dynamic.branch_exists("main").await.unwrap());
3187    }
3188
3189    // When the backend has no native trunk (git `origin/HEAD` unset), the facade
3190    // falls back to a local `main`, then `master`.
3191    #[tokio::test]
3192    async fn trunk_falls_back_to_main() {
3193        let repo = git_repo(
3194            ScriptedRunner::new()
3195                .on(["git", "symbolic-ref"], Reply::fail(1, "")) // origin/HEAD unset → None
3196                .on(["git", "show-ref"], Reply::ok("")), // branch_exists("main") → exit 0
3197        );
3198        assert_eq!(repo.trunk().await.unwrap().as_deref(), Some("main"));
3199    }
3200
3201    #[test]
3202    fn error_classifiers_recognise_markers() {
3203        let conflict = Error::Vcs(processkit::Error::exit(
3204            "git",
3205            1,
3206            "CONFLICT (content): Merge conflict in a.rs",
3207            "",
3208        ));
3209        assert!(conflict.is_merge_conflict());
3210        assert!(!conflict.is_nothing_to_commit());
3211        // A non-Vcs error classifies as none of them.
3212        assert!(!Error::NotARepository("/x".into()).is_merge_conflict());
3213    }
3214}
3215
3216// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
3217#[doc = include_str!("../docs/core.md")]
3218#[allow(rustdoc::broken_intra_doc_links)]
3219pub mod guide {
3220    #[doc = include_str!("../docs/cookbook.md")]
3221    #[allow(rustdoc::broken_intra_doc_links)]
3222    pub mod cookbook {}
3223    #[doc = include_str!("../docs/process-model.md")]
3224    #[allow(rustdoc::broken_intra_doc_links)]
3225    pub mod process_model {}
3226    #[doc = include_str!("../docs/positioning.md")]
3227    #[allow(rustdoc::broken_intra_doc_links)]
3228    pub mod positioning {}
3229    #[doc = include_str!("../docs/stability.md")]
3230    #[allow(rustdoc::broken_intra_doc_links)]
3231    pub mod stability {}
3232}