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