Skip to main content

vcs_core/
lib.rs

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