Skip to main content

vcs_jj/
lib.rs

1#![cfg_attr(docsrs, feature(doc_cfg))]
2#![deny(rustdoc::broken_intra_doc_links)]
3//! `vcs-jj` — automate Jujutsu (`jj`) from Rust by driving the `jj` CLI.
4//!
5//! It shells out to the installed `jj` binary and parses its templated output
6//! into typed values — so you get *jj's own* behaviour and config, not a
7//! reimplementation of the operation log or backend. Async throughout,
8//! structured errors, and mockable. Every command runs inside an OS **job** (via
9//! [`processkit`]) so a `jj` subprocess tree can never be orphaned, and honours
10//! an optional per-client [timeout](Jj::default_timeout).
11//!
12//! # The surface
13//!
14//! - **[`JjApi`]** — the object-safe trait every operation lives on. Depend on
15//!   `&dyn JjApi` (or generically on `impl JjApi`) so a test can swap the real
16//!   client for a double. Most methods take the working directory as the first
17//!   argument and return typed results ([`Change`], [`Bookmark`],
18//!   [`BookmarkRef`], [`Operation`], [`Workspace`], [`ChangedPath`],
19//!   [`FileDiff`], [`AnnotationLine`], …) or a structured [`Error`]. The groups:
20//!   changes ([`status`](JjApi::status), [`log`](JjApi::log),
21//!   [`describe`](JjApi::describe), [`new_change`](JjApi::new_change)),
22//!   bookmarks ([`bookmarks`](JjApi::bookmarks),
23//!   [`bookmark_create`](JjApi::bookmark_create),
24//!   [`bookmark_move`](JjApi::bookmark_move), …), the operation log
25//!   ([`op_log`](JjApi::op_log), [`op_head`](JjApi::op_head),
26//!   [`op_restore`](JjApi::op_restore), [`op_undo`](JjApi::op_undo)),
27//!   diff/query ([`diff`](JjApi::diff), [`diff_stat`](JjApi::diff_stat),
28//!   [`evolog`](JjApi::evolog), [`file_annotate`](JjApi::file_annotate),
29//!   [`template_query`](JjApi::template_query)), mutations
30//!   ([`rebase`](JjApi::rebase), [`squash_paths`](JjApi::squash_paths),
31//!   [`split_paths`](JjApi::split_paths), [`absorb`](JjApi::absorb),
32//!   [`abandon`](JjApi::abandon)), git sync
33//!   ([`git_fetch`](JjApi::git_fetch), [`git_push`](JjApi::git_push),
34//!   [`git_clone`](JjApi::git_clone), [`git_import`](JjApi::git_import)), and
35//!   workspaces ([`workspace_list`](JjApi::workspace_list),
36//!   [`workspace_root`](JjApi::workspace_root),
37//!   [`workspace_add`](JjApi::workspace_add)).
38//! - **[`Jj`]** — the real client. [`Jj::new`] uses the job-backed runner;
39//!   [`Jj::with_runner`] injects a fake one for tests. It is generic over the
40//!   [`ProcessRunner`] seam, defaulting to the production runner.
41//! - **[`JjAt`]** — a cwd-bound view ([`Jj::at`]) whose methods drop the leading
42//!   `dir`, so `jj.at(dir).status()` reads as `jj.status(dir)` — handy when one
43//!   client drives one checkout.
44//! - **[`Jj::transaction`]** — run a mutation sequence with op-log rollback:
45//!   capture the current operation, run a closure, and on `Err` restore the repo
46//!   to it. The op log is jj's safety net; this wraps it as a scope.
47//!   [`Jj::workspace_roots`] is a sibling inherent method — a bounded fan-out
48//!   resolving many workspace roots at once.
49//! - **Builder specs** for the multi-option commands — [`WorkspaceAdd`],
50//!   [`SquashPaths`] — each `#[non_exhaustive]`, built with a constructor +
51//!   chained setters, named after the flags they emit. [`JjFileset`] wraps a
52//!   repo-relative path as an exact-path `file:"…"` fileset; [`RevsetExpr`] is an
53//!   optional up-front-validated revset newtype for untrusted input.
54//! - **[`conflict`]** — a typed model of jj's *native* conflict markers (the
55//!   `diff`/`snapshot` styles): parse a materialized file into structured
56//!   regions, re-render byte-exact, and resolve to a chosen side. (Files
57//!   materialized in the `git` style are parsed by `vcs_git::conflict` instead.)
58//! - **[`capabilities`](JjApi::capabilities)** — probe the installed binary's
59//!   version against this crate's validated floor (jj ≥ 0.38); see
60//!   [`JjCapabilities`].
61//!
62//! There is deliberately **no `Jj::hardened()`** counterpart to vcs-git's
63//! untrusted-repo profile: jj has no repo-local hooks, and its config comes from
64//! the user/repo TOML files jj itself trusts. In a *colocated* repo the risk
65//! lives on the git side — git hooks fire when **git** commands run there, so
66//! harden the `Git` client you point at it.
67//!
68//! # Recipes
69//!
70//! Read state — depend on the trait so the same code takes a real client or a mock:
71//!
72//! ```no_run
73//! use std::path::Path;
74//! use vcs_jj::{Jj, JjApi};
75//! # async fn demo() -> Result<(), processkit::Error> {
76//! let jj = Jj::new();
77//! let dir = Path::new(".");
78//! let current = jj.current_change(dir).await?;       // the working-copy change `@`
79//! let dirty = !jj.status(dir).await?.is_empty();     // any working-copy edit?
80//! # let _ = (current, dirty); Ok(()) }
81//! ```
82//!
83//! Mutate inside a [`transaction`](Jj::transaction) — an `Err` rolls the op log back:
84//!
85//! ```no_run
86//! use std::path::Path;
87//! use vcs_jj::Jj;
88//! # async fn demo(jj: &Jj) -> Result<(), processkit::Error> {
89//! let dir = Path::new(".");
90//! jj.transaction(dir, |tx| async move {
91//!     tx.describe("wip").await?;
92//!     tx.new_change("next").await        // an Err here undoes the describe
93//! })
94//! .await?;
95//! # Ok(()) }
96//! ```
97//!
98//! # Testing
99//!
100//! Two seams: enable the **`mock`** feature for a `mockall`-generated
101//! `MockJjApi` (stub whole methods), or inject a
102//! [`ScriptedRunner`](processkit::ScriptedRunner) with [`Jj::with_runner`] to
103//! exercise the *real* argv-building and parsing against canned output. The
104//! cross-cutting testing patterns live in
105//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
106//!
107//! # Safety
108//!
109//! Every caller value placed in a bare positional argv slot (bookmark name,
110//! revset, operation id, merge parent, …) is refused before spawning if it is
111//! empty or starts with `-` (jj would parse it as a flag); flag-value slots
112//! (`-r <revset>`, `-m <msg>`) and the `run`/`run_raw` escape hatches are not
113//! guarded. For eager validation at an input boundary, [`RevsetExpr`] validates
114//! up front. Paths go through the exact-path [`JjFileset`] form.
115//!
116//! # In-depth guide
117//!
118//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
119//! from `docs/`. See the [`guide`] module. The conflict model is covered by
120//! [vcs-git's conflicts guide](https://docs.rs/vcs-git/latest/vcs_git/guide/conflicts/),
121//! which spans both backends.
122
123use std::future::Future;
124use std::path::{Path, PathBuf};
125use std::time::Duration;
126
127use processkit::ProcessRunner;
128// Re-export the processkit types in this crate's public API (also brings
129// `Error`/`Result`/`ProcessResult` into scope here).
130pub use processkit::{Error, ProcessResult, Result};
131// Re-exported under the `cancellation` feature so a consumer can name the token
132// for `default_cancel_on` without taking a direct `processkit` dependency.
133#[cfg(feature = "cancellation")]
134#[cfg_attr(docsrs, doc(cfg(feature = "cancellation")))]
135pub use processkit::CancellationToken;
136
137pub mod conflict;
138mod parse;
139pub use parse::{AnnotationLine, Bookmark, BookmarkRef, Change, ChangedPath, Operation, Workspace};
140// The git-format diff model + parser and the version type are shared with
141// `vcs-git` (identical output) — re-exported so `vcs_jj::FileDiff`,
142// `vcs_jj::parse_diff`, `vcs_jj::JjVersion`, … still resolve.
143pub use vcs_diff::{
144    ChangeKind, DiffLine, DiffStat, FileDiff, Hunk, Version as JjVersion, parse_diff,
145};
146// The transient-fetch classifier lives in the shared plumbing crate — re-exported
147// so `vcs_jj::is_transient_fetch_error` still resolves.
148pub use vcs_cli_support::is_transient_fetch_error;
149
150/// Name of the underlying CLI binary this crate drives.
151pub const BINARY: &str = "jj";
152
153/// What a [`JjApi::diff`] / [`JjApi::diff_text`] call compares.
154///
155/// `#[non_exhaustive]` so more comparison shapes can be added later.
156#[derive(Debug, Clone)]
157#[non_exhaustive]
158pub enum DiffSpec {
159    /// The working-copy change's diff (`jj diff -r @`).
160    WorkingTree,
161    /// A specific revset, e.g. `@-` or `main..@` (`jj diff -r <revset>`).
162    Rev(String),
163}
164
165/// How a new workspace inherits sparse patterns (`jj workspace add
166/// --sparse-patterns <mode>`).
167#[derive(Debug, Clone, Copy, PartialEq, Eq)]
168#[non_exhaustive]
169pub enum SparseMode {
170    /// Copy all sparse patterns from the current workspace (jj's default).
171    Copy,
172    /// Include every file in the new workspace.
173    Full,
174    /// Start with no files — the caller sets patterns afterwards (CoW flow).
175    Empty,
176}
177
178impl SparseMode {
179    /// The `--sparse-patterns` value jj expects.
180    fn as_arg(self) -> &'static str {
181        match self {
182            SparseMode::Copy => "copy",
183            SparseMode::Full => "full",
184            SparseMode::Empty => "empty",
185        }
186    }
187}
188
189/// An exact-path jj fileset (`file:"<path>"`), so path metacharacters like `(`,
190/// `)`, `|`, `*` are treated literally rather than as fileset operators.
191///
192/// Build it with [`JjFileset::path`]; the path is repo-root-relative.
193#[derive(Debug, Clone, PartialEq, Eq)]
194pub struct JjFileset(String);
195
196impl JjFileset {
197    /// Wrap a repo-relative `path` as an exact-path fileset. Backslash separators
198    /// are normalised to `/` first — jj filesets are forward-slash and
199    /// repo-root-relative, so a Windows caller's `src\a.rs` would otherwise become
200    /// a literal-backslash filename that matches nothing — then `"` is escaped for
201    /// the `file:"…"` string literal.
202    pub fn path(path: impl AsRef<str>) -> Self {
203        let escaped = path.as_ref().replace('\\', "/").replace('"', "\\\"");
204        JjFileset(format!("file:\"{escaped}\""))
205    }
206
207    /// The rendered `file:"…"` expression.
208    pub fn as_str(&self) -> &str {
209        &self.0
210    }
211}
212
213/// Options for [`JjApi::workspace_add`] (`jj workspace add`).
214///
215/// `#[non_exhaustive]`, so build it through [`WorkspaceAdd::new`].
216#[derive(Debug, Clone)]
217#[non_exhaustive]
218pub struct WorkspaceAdd {
219    /// Name for the new workspace.
220    pub name: String,
221    /// Revision the workspace's working copy starts at (`-r <base>`).
222    pub base: String,
223    /// Filesystem path for the new workspace.
224    pub path: PathBuf,
225    /// How to seed the new workspace's sparse patterns (`--sparse-patterns`);
226    /// `None` leaves jj's default (inherit from the current workspace).
227    pub sparse_patterns: Option<SparseMode>,
228}
229
230impl WorkspaceAdd {
231    /// A workspace named `name`, based at `base`, materialised at `path`.
232    pub fn new(name: impl Into<String>, base: impl Into<String>, path: impl Into<PathBuf>) -> Self {
233        Self {
234            name: name.into(),
235            base: base.into(),
236            path: path.into(),
237            sparse_patterns: None,
238        }
239    }
240
241    /// Seed the new workspace's sparse patterns with `mode` (`--sparse-patterns`).
242    pub fn sparse(mut self, mode: SparseMode) -> Self {
243        self.sparse_patterns = Some(mode);
244        self
245    }
246}
247
248/// Options for [`JjApi::squash_paths`] (`jj squash --from <from> --into <into>
249/// [--use-destination-message] <filesets>`).
250///
251/// `#[non_exhaustive]`, so build it through [`SquashPaths::new`] and the chained
252/// setters rather than a struct literal.
253#[derive(Debug, Clone)]
254#[non_exhaustive]
255pub struct SquashPaths {
256    /// Source revision the filesets are squashed out of (`--from`).
257    pub from: String,
258    /// Destination revision the filesets are squashed into (`--into`).
259    pub into: String,
260    /// The exact filesets to move; empty squashes the whole `from` change.
261    pub filesets: Vec<JjFileset>,
262    /// Keep the destination's description rather than combining the two
263    /// (`--use-destination-message`).
264    pub use_destination_message: bool,
265}
266
267impl SquashPaths {
268    /// Squash from `from` into `into`, with no filesets selected yet.
269    pub fn new(from: impl Into<String>, into: impl Into<String>) -> Self {
270        Self {
271            from: from.into(),
272            into: into.into(),
273            filesets: Vec::new(),
274            use_destination_message: false,
275        }
276    }
277
278    /// Set the filesets to move (replacing any already added).
279    pub fn filesets(mut self, filesets: impl IntoIterator<Item = JjFileset>) -> Self {
280        self.filesets = filesets.into_iter().collect();
281        self
282    }
283
284    /// Keep the destination's description (`--use-destination-message`) instead
285    /// of combining the two.
286    pub fn use_destination_message(mut self) -> Self {
287        self.use_destination_message = true;
288        self
289    }
290}
291
292/// The first bookmark name from a comma-joined [`BOOKMARKS_TEMPLATE`](parse::BOOKMARKS_TEMPLATE)
293/// render; `None` when the commit carries no local bookmark.
294fn first_bookmark(rendered: &str) -> Option<String> {
295    let rendered = rendered.trim();
296    (!rendered.is_empty()).then(|| rendered.split(',').next().unwrap_or(rendered).to_string())
297}
298
299/// Injection guard for bare positional argv slots: a caller-supplied value
300/// with a leading `-` is parsed by jj's CLI as a *flag* (verified: `jj edit
301/// -evil` → "unexpected argument"), and an empty value changes a command's
302/// meaning. Refuse both before anything spawns. Flag-VALUE positions
303/// (`-r <revset>`, `-m <msg>`) need no guard — jj itself rejects dash-values
304/// there with a clear error rather than misparsing them.
305fn reject_flag_like(what: &str, value: &str) -> Result<()> {
306    vcs_cli_support::reject_flag_like(BINARY, what, value)
307}
308
309/// A pre-validated revset expression, for callers that accept revsets from
310/// untrusted input (UIs, bots, agents) and want to fail early. Deliberately
311/// *minimal* — jj's revset grammar is too rich to validate here — it only
312/// guarantees the expression is non-empty and cannot be parsed as a flag
313/// (no leading `-`), matching the internal guard the positional-revset
314/// methods apply anyway. The dir-taking methods stay `&str`; this type is
315/// **optional** up-front validation, not a required wrapper.
316#[derive(Debug, Clone, PartialEq, Eq, Hash)]
317pub struct RevsetExpr(String);
318
319impl RevsetExpr {
320    /// Validate `revset` (non-empty, no leading `-`).
321    pub fn new(revset: impl Into<String>) -> Result<Self> {
322        let revset = revset.into();
323        reject_flag_like("revset", &revset)?;
324        Ok(RevsetExpr(revset))
325    }
326
327    /// The validated expression.
328    pub fn as_str(&self) -> &str {
329        &self.0
330    }
331}
332
333impl std::fmt::Display for RevsetExpr {
334    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
335        f.write_str(&self.0)
336    }
337}
338
339/// What the installed `jj` binary supports, probed via
340/// [`JjApi::capabilities`]. A value type — the client holds no state, so probe
341/// once and keep the result (callers cache it).
342#[derive(Debug, Clone, Copy, PartialEq, Eq)]
343#[non_exhaustive]
344pub struct JjCapabilities {
345    /// The binary's parsed version.
346    pub version: JjVersion,
347}
348
349/// The validated jj floor: every parser and flag in this crate was verified
350/// empirically against this release. jj's CLI moves fast, so unlike vcs-git's
351/// major-only gate the jj floor is precise.
352const MIN_SUPPORTED: JjVersion = JjVersion {
353    major: 0,
354    minor: 38,
355    patch: 0,
356};
357
358impl JjCapabilities {
359    /// Whether the binary meets the validated floor (jj ≥ 0.38).
360    pub fn is_supported(&self) -> bool {
361        self.version >= MIN_SUPPORTED
362    }
363
364    /// Error unless [`is_supported`](Self::is_supported) — a clear "needs jj
365    /// ≥ 0.38, found 0.35.0" instead of a cryptic argv/template failure later.
366    pub fn ensure_supported(&self) -> Result<()> {
367        if self.is_supported() {
368            return Ok(());
369        }
370        Err(Error::Spawn {
371            program: BINARY.to_string(),
372            source: std::io::Error::new(
373                std::io::ErrorKind::Unsupported,
374                format!(
375                    "vcs-jj requires jj >= {MIN_SUPPORTED} (the validated floor), found {}",
376                    self.version
377                ),
378            ),
379        })
380    }
381}
382
383/// The jj operations this crate exposes — the interface consumers code against
384/// and mock in tests.
385///
386/// **Injection safety:** every method that places a caller-supplied bookmark
387/// name, revset, or operation id in a positional argv slot rejects a value
388/// that is empty or begins with `-` (jj would parse it as a flag) with an
389/// [`Error::Spawn`] *before* spawning. Flag-value slots (`-r <revset>`,
390/// `-m <msg>`) and the `run`/`run_raw` escape hatches are not guarded. For
391/// eager validation at an input boundary, see [`RevsetExpr`].
392#[cfg_attr(feature = "mock", mockall::automock)]
393#[async_trait::async_trait]
394pub trait JjApi: Send + Sync {
395    /// Run `jj <args>`, returning trimmed stdout (throws on a non-zero exit).
396    async fn run(&self, args: &[String]) -> Result<String>;
397    /// Like [`JjApi::run`] but never errors on a non-zero exit — returns the
398    /// captured [`ProcessResult`].
399    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
400    /// Installed Jujutsu version (`jj --version`).
401    async fn version(&self) -> Result<String>;
402    /// The installed binary's parsed version, as [`JjCapabilities`]
403    /// (`jj --version`). A value type — probe once and keep it; an
404    /// unrecognisable version string is an [`Error::Parse`].
405    async fn capabilities(&self) -> Result<JjCapabilities>;
406    /// Parsed working-copy changes — the files changed in `@`
407    /// (`jj diff -r @ --summary`), mirroring `vcs_git` `status`.
408    async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>>;
409    /// Raw `jj status` text (human-readable) — the unparsed counterpart of
410    /// [`status`](JjApi::status), mirroring `vcs_git` `status_text`.
411    async fn status_text(&self, dir: &Path) -> Result<String>;
412    /// Changes matching `revset`, newest first, up to `max` (`jj log`).
413    async fn log(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>>;
414    /// The working-copy change (`jj log -r @`).
415    async fn current_change(&self, dir: &Path) -> Result<Change>;
416    /// Set the working-copy change's description (`jj describe -m`).
417    async fn describe(&self, dir: &Path, message: &str) -> Result<()>;
418    /// Set the description of an arbitrary revision (`jj describe -r <revset> -m`).
419    async fn describe_rev(&self, dir: &Path, revset: &str, message: &str) -> Result<()>;
420    /// Start a new change on top of the working copy (`jj new -m`).
421    async fn new_change(&self, dir: &Path, message: &str) -> Result<()>;
422    /// Local bookmarks (`jj bookmark list`).
423    async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
424    /// Local *and* remote-tracking bookmarks (`jj bookmark list -a`).
425    async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>>;
426    /// Local bookmarks on the nearest commits reachable from `@`
427    /// (`log -r 'heads(::@ & bookmarks())'`) — the candidate targets a commit
428    /// "belongs to". A commit carrying several bookmarks yields one entry each.
429    async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
430    /// Track a remote bookmark (`jj bookmark track <name>@<remote>`).
431    async fn bookmark_track(&self, dir: &Path, name: &str, remote: &str) -> Result<()>;
432    /// Point a bookmark at `revision` (`jj bookmark set <name> -r <revision>`).
433    async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> Result<()>;
434    /// Fetch from the git remote (`jj git fetch`); transient (network) failures
435    /// are retried (3 attempts, 500 ms backoff).
436    async fn git_fetch(&self, dir: &Path) -> Result<()>;
437    /// Fetch from a *named* git remote (`jj git fetch --remote <remote>`);
438    /// transient failures are retried like [`git_fetch`](JjApi::git_fetch).
439    async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
440    /// Push to the git remote (`jj git push`, optionally `-b <bookmark>`). The
441    /// bookmark is owned (`Option<String>`) to keep the trait `mockall`-friendly.
442    async fn git_push(&self, dir: &Path, bookmark: Option<String>) -> Result<()>;
443
444    // --- Discovery / identity ------------------------------------------------
445
446    /// Working-copy root of the current workspace (`jj root`).
447    async fn root(&self, dir: &Path) -> Result<PathBuf>;
448    /// The local bookmark on the working-copy change `@`, if exactly one (or the
449    /// first of several); `None` when `@` carries no bookmark. `ws` enforces the
450    /// one-bookmark policy on top.
451    async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>>;
452    /// The trunk bookmark (`jj log -r 'trunk()'`); `None` when unresolved.
453    async fn trunk(&self, dir: &Path) -> Result<Option<String>>;
454
455    // --- Bookmarks -----------------------------------------------------------
456
457    /// Create a bookmark at a revision (`bookmark create <name> -r <rev>`).
458    async fn bookmark_create(&self, dir: &Path, name: &str, revision: &str) -> Result<()>;
459    /// Rename a bookmark (`bookmark rename <old> <new>`).
460    async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
461    /// Delete a bookmark (`bookmark delete <name>`).
462    async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()>;
463    /// Move a bookmark to a revision (`bookmark move <name> --to <rev>
464    /// [--allow-backwards]`).
465    async fn bookmark_move(
466        &self,
467        dir: &Path,
468        name: &str,
469        to: &str,
470        allow_backwards: bool,
471    ) -> Result<()>;
472
473    // --- Diff / query / state ------------------------------------------------
474
475    /// Per-file change summary for a range (`diff -r <from>..<to> --summary`).
476    async fn diff_summary(&self, dir: &Path, from: &str, to: &str) -> Result<Vec<ChangedPath>>;
477    /// Aggregate change stats for a revset (`diff -r <revset> --stat`).
478    async fn diff_stat(&self, dir: &Path, revset: &str) -> Result<DiffStat>;
479    /// Raw git-format unified diff text for `spec` (`diff -r <spec> --git`) —
480    /// stable machine output.
481    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
482    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](JjApi::diff_text).
483    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
484    /// Count commits in a revset (`log -r <revset> --no-graph`, one id per line).
485    async fn commit_count(&self, dir: &Path, revset: &str) -> Result<usize>;
486    /// Whether the commit a revset resolves to has a conflict.
487    async fn is_conflicted(&self, dir: &Path, revset: &str) -> Result<bool>;
488    /// Whether the working copy has unresolved conflicts (`jj status`).
489    async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool>;
490    /// Paths with unresolved conflicts in `revset` (`jj resolve --list -r <revset>`).
491    /// Empty when there are none.
492    async fn resolve_list(&self, dir: &Path, revset: &str) -> Result<Vec<String>>;
493    /// Run an arbitrary templated `jj log` query and return raw stdout
494    /// (`log -r <revset> --no-graph [--limit n] -T <template>`).
495    async fn template_query(
496        &self,
497        dir: &Path,
498        revset: &str,
499        template: &str,
500        limit: Option<usize>,
501    ) -> Result<String>;
502    /// The full (possibly multiline) description of the commit `revset` resolves
503    /// to, trailing whitespace trimmed; empty for an undescribed change — or for
504    /// a revset matching no commit (an *invalid* revset still errors). A
505    /// multi-commit revset yields only the newest commit's description
506    /// (`jj log` order, `--limit 1`).
507    async fn description(&self, dir: &Path, revset: &str) -> Result<String>;
508    /// How the commit a revset resolves to evolved, newest snapshot first, up
509    /// to `max` (`jj evolog -r <revset>`) — one [`Change`] row per recorded
510    /// predecessor.
511    async fn evolog(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>>;
512    /// Per-line authorship of `path` (`jj file annotate <path> [-r <revset>]`;
513    /// `None` = `@`): which change introduced each line.
514    async fn file_annotate(
515        &self,
516        dir: &Path,
517        path: &str,
518        revset: Option<String>,
519    ) -> Result<Vec<AnnotationLine>>;
520    /// A file's content at a revision (`jj file show -r <revset>
521    /// file:"<path>"` — the path is wrapped as an exact-path fileset, so
522    /// fileset metacharacters in the name stay literal). Content is decoded
523    /// lossily — a binary file comes back mangled rather than erroring.
524    async fn file_show(&self, dir: &Path, revset: &str, path: &str) -> Result<String>;
525
526    // --- Mutations -----------------------------------------------------------
527
528    /// Rebase the working copy onto a destination (`rebase -d <onto>`).
529    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
530    /// Rebase a whole branch onto a destination (`rebase -b <branch> -d <dest>`).
531    async fn rebase_branch(&self, dir: &Path, branch: &str, dest: &str) -> Result<()>;
532    /// Move the working copy to a revision (`edit <rev>`).
533    async fn edit(&self, dir: &Path, revset: &str) -> Result<()>;
534    /// Squash the working copy into a revision (`squash --into <rev>`). When
535    /// `use_destination_message`, keep the destination's description
536    /// (`--use-destination-message`) instead of combining the two.
537    async fn squash_into(
538        &self,
539        dir: &Path,
540        into: &str,
541        use_destination_message: bool,
542    ) -> Result<()>;
543    /// Finalise a commit from exactly these filesets (`commit -m <message>
544    /// <filesets>`); the rest stay in the new working-copy change.
545    async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
546    /// Squash exactly these filesets from one revision into another
547    /// (`squash --from <from> --into <into> [--use-destination-message] <filesets>`).
548    async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()>;
549    /// Set the working copy's sparse patterns to exactly `patterns`
550    /// (`sparse set --clear --add <p>…`); an empty list clears the working copy.
551    async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()>;
552    /// Create a new change with the given parents (`new -m <msg> <p1> <p2> …`).
553    async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<String>) -> Result<()>;
554    /// Abandon a revision (`abandon <rev>`).
555    async fn abandon(&self, dir: &Path, revset: &str) -> Result<()>;
556    /// Fetch a single bookmark from origin (`git fetch --remote origin -b <branch>`);
557    /// transient failures are retried (3×, 500 ms).
558    async fn git_fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
559    /// Import git refs into jj (`jj git import`) — colocated-repo sync.
560    async fn git_import(&self, dir: &Path) -> Result<()>;
561    /// Clone a git repository into `dest` (`jj git clone <url> <dest>
562    /// --colocate|--no-colocate`). Runs without a working directory — pass an
563    /// **absolute** `dest`. The flag is always passed explicitly: whether
564    /// colocation (a visible `.git` alongside `.jj`) is jj's default depends
565    /// on the jj version *and* the user's `git.colocate` config, so `colocate`
566    /// decides deterministically.
567    async fn git_clone(&self, url: &str, dest: &Path, colocate: bool) -> Result<()>;
568    /// Fold working-copy edits into the mutable ancestors that introduced the
569    /// touched lines (`absorb [--from <revset>] [<filesets>…]`); empty
570    /// `filesets` absorbs everything.
571    async fn absorb(&self, dir: &Path, from: Option<String>, filesets: &[JjFileset]) -> Result<()>;
572    /// Split exactly these filesets out of `@` into their own commit described
573    /// by `message` (`split -m <message> <filesets>…`); the remainder stays
574    /// behind. `filesets` must be non-empty — a fileset-less split opens jj's
575    /// interactive diff editor (a headless hang), so it is refused with an
576    /// error before spawning.
577    async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
578    /// Duplicate the commits a revset resolves to (`duplicate <revset>`).
579    async fn duplicate(&self, dir: &Path, revset: &str) -> Result<()>;
580
581    // --- Operation log -------------------------------------------------------
582
583    /// The current operation id (`op log --no-graph --limit 1`) — capture before
584    /// a risky sequence to roll back to.
585    async fn op_head(&self, dir: &Path) -> Result<String>;
586    /// The newest `limit` operations, newest first (`op log --no-graph
587    /// --limit n`).
588    async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>>;
589    /// Restore the repo to an operation (`op restore <id>`).
590    async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()>;
591    /// Undo the latest operation (`op undo`).
592    async fn op_undo(&self, dir: &Path) -> Result<()>;
593
594    // --- Workspaces ----------------------------------------------------------
595
596    /// List workspaces (`workspace list`).
597    async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>>;
598    /// Resolve a workspace's root path (`workspace root [--name <name>]`).
599    async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf>;
600    /// Add a workspace (`workspace add --name <name> -r <base> <path>`).
601    async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()>;
602    /// Forget a workspace (`workspace forget <name>`).
603    async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()>;
604}
605
606processkit::cli_client!(
607    /// The real jj client. Generic over the [`ProcessRunner`] so tests can inject
608    /// a fake process executor; `Jj::new()` uses the real job-backed runner.
609    pub struct Jj => BINARY
610);
611
612impl<R: ProcessRunner> Jj<R> {
613    /// A repo-scoped `jj` command with `--color never` forced on. jj honours
614    /// `ui.color = "always"` from user config even when its output is piped, which
615    /// would wrap our templated output — and the command error text we classify —
616    /// in ANSI escapes and break parsing; `--color never` is the only thing that
617    /// overrides that config (`NO_COLOR`/`CLICOLOR` do not). It is a global flag,
618    /// appended here (no jj subcommand takes a trailing `--`, so this is safe).
619    fn cmd_in<I, S>(&self, dir: &Path, args: I) -> processkit::Command
620    where
621        I: IntoIterator<Item = S>,
622        S: AsRef<std::ffi::OsStr>,
623    {
624        self.core.command_in(dir, args).arg("--color").arg("never")
625    }
626}
627
628#[async_trait::async_trait]
629impl<R: ProcessRunner> JjApi for Jj<R> {
630    async fn run(&self, args: &[String]) -> Result<String> {
631        self.core.run(self.core.command(args)).await
632    }
633
634    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
635        self.core.output(self.core.command(args)).await
636    }
637
638    async fn version(&self) -> Result<String> {
639        self.core.run(self.core.command(["--version"])).await
640    }
641
642    async fn capabilities(&self) -> Result<JjCapabilities> {
643        let raw = self.version().await?;
644        let version = parse::parse_jj_version(&raw).ok_or_else(|| Error::Parse {
645            program: BINARY.to_string(),
646            message: format!("unrecognisable `jj --version` output: {raw:?}"),
647        })?;
648        Ok(JjCapabilities { version })
649    }
650
651    async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>> {
652        // `diff -r @ --summary` is the machine-stable form of the working-copy
653        // changes that `jj status` renders for humans: one `<letter> <path>` line.
654        self.core
655            .parse(
656                self.cmd_in(dir, ["diff", "-r", "@", "--summary"]),
657                parse::parse_diff_summary,
658            )
659            .await
660    }
661
662    async fn status_text(&self, dir: &Path) -> Result<String> {
663        self.core.run(self.cmd_in(dir, ["status"])).await
664    }
665
666    async fn log(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>> {
667        let n = format!("-n{max}");
668        self.core
669            .parse(
670                self.cmd_in(
671                    dir,
672                    [
673                        "log",
674                        "-r",
675                        revset,
676                        n.as_str(),
677                        "--no-graph",
678                        "-T",
679                        parse::CHANGE_TEMPLATE,
680                    ],
681                ),
682                parse::parse_changes,
683            )
684            .await
685    }
686
687    async fn current_change(&self, dir: &Path) -> Result<Change> {
688        let mut changes = self.log(dir, "@", 1).await?;
689        changes.pop().ok_or_else(|| Error::Parse {
690            program: BINARY.to_string(),
691            message: "no working-copy change found".to_string(),
692        })
693    }
694
695    async fn describe(&self, dir: &Path, message: &str) -> Result<()> {
696        self.core
697            .run_unit(self.cmd_in(dir, ["describe", "-m", message]))
698            .await
699    }
700
701    async fn describe_rev(&self, dir: &Path, revset: &str, message: &str) -> Result<()> {
702        self.core
703            .run_unit(self.cmd_in(dir, ["describe", "-r", revset, "-m", message]))
704            .await
705    }
706
707    async fn new_change(&self, dir: &Path, message: &str) -> Result<()> {
708        self.core
709            .run_unit(self.cmd_in(dir, ["new", "-m", message]))
710            .await
711    }
712
713    async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
714        self.core
715            .parse(
716                self.cmd_in(dir, ["bookmark", "list"]),
717                parse::parse_bookmarks,
718            )
719            .await
720    }
721
722    async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>> {
723        self.core
724            .parse(
725                self.cmd_in(
726                    dir,
727                    ["bookmark", "list", "-a", "-T", parse::BOOKMARK_ALL_TEMPLATE],
728                ),
729                parse::parse_bookmarks_all,
730            )
731            .await
732    }
733
734    async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
735        self.core
736            .parse(
737                self.cmd_in(
738                    dir,
739                    [
740                        "log",
741                        "-r",
742                        "heads(::@ & bookmarks())",
743                        "--no-graph",
744                        "-T",
745                        parse::REACHABLE_BOOKMARKS_TEMPLATE,
746                    ],
747                ),
748                parse::parse_reachable_bookmarks,
749            )
750            .await
751    }
752
753    async fn bookmark_track(&self, dir: &Path, name: &str, remote: &str) -> Result<()> {
754        // A leading-`-` name makes the whole `{name}@{remote}` token start with
755        // `-`, which jj parses as a global flag (e.g. `--config`); guard it.
756        reject_flag_like("bookmark name", name)?;
757        let target = format!("{name}@{remote}");
758        self.core
759            .run_unit(self.cmd_in(dir, ["bookmark", "track", target.as_str()]))
760            .await
761    }
762
763    async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> Result<()> {
764        reject_flag_like("bookmark name", name)?;
765        self.core
766            .run_unit(self.cmd_in(dir, ["bookmark", "set", name, "-r", revision]))
767            .await
768    }
769
770    async fn git_fetch(&self, dir: &Path) -> Result<()> {
771        // Idempotent → `retry` replays it on a transient (network) failure.
772        let cmd = self.cmd_in(dir, ["git", "fetch"]).retry(
773            FETCH_ATTEMPTS,
774            FETCH_BACKOFF,
775            is_transient_fetch_error,
776        );
777        self.core.run_unit(cmd).await
778    }
779
780    async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
781        // Idempotent → `retry` replays it on a transient (network) failure.
782        let cmd = self
783            .cmd_in(dir, ["git", "fetch", "--remote", remote])
784            .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
785        self.core.run_unit(cmd).await
786    }
787
788    async fn git_push(&self, dir: &Path, bookmark: Option<String>) -> Result<()> {
789        let mut args = vec!["git", "push"];
790        if let Some(name) = bookmark.as_deref() {
791            args.push("-b");
792            args.push(name);
793        }
794        self.core.run_unit(self.cmd_in(dir, args)).await
795    }
796
797    async fn root(&self, dir: &Path) -> Result<PathBuf> {
798        Ok(PathBuf::from(
799            self.core.run(self.cmd_in(dir, ["root"])).await?,
800        ))
801    }
802
803    async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>> {
804        let out = self
805            .core
806            .run(self.cmd_in(
807                dir,
808                [
809                    "log",
810                    "-r",
811                    "@",
812                    "--no-graph",
813                    "--limit",
814                    "1",
815                    "-T",
816                    parse::BOOKMARKS_TEMPLATE,
817                ],
818            ))
819            .await?;
820        Ok(first_bookmark(&out))
821    }
822
823    async fn trunk(&self, dir: &Path) -> Result<Option<String>> {
824        let out = self
825            .core
826            .run(self.cmd_in(
827                dir,
828                [
829                    "log",
830                    "-r",
831                    "trunk()",
832                    "--no-graph",
833                    "--limit",
834                    "1",
835                    "-T",
836                    parse::BOOKMARKS_TEMPLATE,
837                ],
838            ))
839            .await?;
840        Ok(first_bookmark(&out))
841    }
842
843    async fn bookmark_create(&self, dir: &Path, name: &str, revision: &str) -> Result<()> {
844        reject_flag_like("bookmark name", name)?;
845        self.core
846            .run_unit(self.cmd_in(dir, ["bookmark", "create", name, "-r", revision]))
847            .await
848    }
849
850    async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()> {
851        reject_flag_like("bookmark name", old)?;
852        reject_flag_like("bookmark name", new)?;
853        self.core
854            .run_unit(self.cmd_in(dir, ["bookmark", "rename", old, new]))
855            .await
856    }
857
858    async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()> {
859        reject_flag_like("bookmark name", name)?;
860        self.core
861            .run_unit(self.cmd_in(dir, ["bookmark", "delete", name]))
862            .await
863    }
864
865    async fn bookmark_move(
866        &self,
867        dir: &Path,
868        name: &str,
869        to: &str,
870        allow_backwards: bool,
871    ) -> Result<()> {
872        reject_flag_like("bookmark name", name)?;
873        let mut args = vec!["bookmark", "move", name, "--to", to];
874        if allow_backwards {
875            args.push("--allow-backwards");
876        }
877        self.core.run_unit(self.cmd_in(dir, args)).await
878    }
879
880    async fn diff_summary(&self, dir: &Path, from: &str, to: &str) -> Result<Vec<ChangedPath>> {
881        // Parenthesise each endpoint so a compound revset (e.g. `x | y`) keeps its
882        // meaning inside the `..` range instead of binding by operator precedence.
883        let range = format!("({from})..({to})");
884        self.core
885            .parse(
886                self.cmd_in(dir, ["diff", "-r", range.as_str(), "--summary"]),
887                parse::parse_diff_summary,
888            )
889            .await
890    }
891
892    async fn diff_stat(&self, dir: &Path, revset: &str) -> Result<DiffStat> {
893        self.core
894            .parse(
895                self.cmd_in(dir, ["diff", "-r", revset, "--stat"]),
896                parse::parse_diff_stat,
897            )
898            .await
899    }
900
901    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
902        // `@` selects the working-copy change; otherwise the caller's revset.
903        // `--git` emits stable git-format output the shared parser understands.
904        let revset = match spec {
905            DiffSpec::WorkingTree => "@".to_string(),
906            DiffSpec::Rev(rev) => rev,
907        };
908        self.core
909            .run(self.cmd_in(dir, ["diff", "-r", revset.as_str(), "--git"]))
910            .await
911    }
912
913    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
914        let text = self.diff_text(dir, spec).await?;
915        Ok(parse_diff(&text))
916    }
917
918    async fn commit_count(&self, dir: &Path, revset: &str) -> Result<usize> {
919        self.core
920            .parse(
921                self.cmd_in(
922                    dir,
923                    [
924                        "log",
925                        "-r",
926                        revset,
927                        "--no-graph",
928                        "-T",
929                        parse::COUNT_TEMPLATE,
930                    ],
931                ),
932                |s| s.lines().filter(|line| !line.is_empty()).count(),
933            )
934            .await
935    }
936
937    async fn is_conflicted(&self, dir: &Path, revset: &str) -> Result<bool> {
938        let out = self
939            .core
940            .run(self.cmd_in(
941                dir,
942                [
943                    "log",
944                    "-r",
945                    revset,
946                    "--no-graph",
947                    "--limit",
948                    "1",
949                    "-T",
950                    parse::CONFLICT_TEMPLATE,
951                ],
952            ))
953            .await?;
954        Ok(out.trim() == "1")
955    }
956
957    async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool> {
958        // Ask the template engine directly rather than string-matching localized
959        // `jj status` prose: `@` is conflicted iff its `conflict` flag is set.
960        self.is_conflicted(dir, "@").await
961    }
962
963    async fn resolve_list(&self, dir: &Path, revset: &str) -> Result<Vec<String>> {
964        let res = self
965            .core
966            .output(self.cmd_in(dir, ["resolve", "--list", "-r", revset]))
967            .await?;
968        match res.code() {
969            Some(0) => Ok(parse::parse_resolve_list(res.stdout())),
970            // jj exits non-zero with "No conflicts found …" when the revision is
971            // conflict-free — the one non-zero we read as an empty list. Any other
972            // failure (bad revset, not a repo, …) must surface, not masquerade as
973            // "no conflicts".
974            _ if res.stderr().contains("No conflicts") => Ok(Vec::new()),
975            _ => {
976                res.ensure_success()?;
977                Ok(Vec::new()) // unreachable: a non-zero exit always errors above.
978            }
979        }
980    }
981
982    async fn template_query(
983        &self,
984        dir: &Path,
985        revset: &str,
986        template: &str,
987        limit: Option<usize>,
988    ) -> Result<String> {
989        let mut args: Vec<String> = vec![
990            "log".into(),
991            "-r".into(),
992            revset.into(),
993            "--no-graph".into(),
994        ];
995        if let Some(n) = limit {
996            args.push("--limit".into());
997            args.push(n.to_string());
998        }
999        args.push("-T".into());
1000        args.push(template.into());
1001        self.core.run(self.cmd_in(dir, args)).await
1002    }
1003
1004    async fn description(&self, dir: &Path, revset: &str) -> Result<String> {
1005        self.template_query(dir, revset, "description", Some(1))
1006            .await
1007    }
1008
1009    async fn evolog(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>> {
1010        // Evolog templates render in a *commit* context (bare `change_id`
1011        // doesn't exist there) — EVOLOG_TEMPLATE uses the `commit.` method
1012        // form but emits the same columns CHANGE_TEMPLATE does.
1013        let limit = max.to_string();
1014        self.core
1015            .parse(
1016                self.cmd_in(
1017                    dir,
1018                    [
1019                        "evolog",
1020                        "-r",
1021                        revset,
1022                        "--no-graph",
1023                        "--limit",
1024                        limit.as_str(),
1025                        "-T",
1026                        parse::EVOLOG_TEMPLATE,
1027                    ],
1028                ),
1029                parse::parse_changes,
1030            )
1031            .await
1032    }
1033
1034    async fn file_annotate(
1035        &self,
1036        dir: &Path,
1037        path: &str,
1038        revset: Option<String>,
1039    ) -> Result<Vec<AnnotationLine>> {
1040        // `file annotate` takes a plain PATH (not a fileset — the `file:"…"`
1041        // form is rejected), so a leading-`-` path would be parsed as a flag.
1042        // The `--` separator before it keeps even a `-dash.txt` literal safe —
1043        // but global flags (`--color never`) MUST precede `--`, so this builds
1044        // the command directly instead of via `cmd_in` (which trails them).
1045        let mut args = vec!["file", "annotate"];
1046        if let Some(revset) = revset.as_deref() {
1047            args.push("-r");
1048            args.push(revset);
1049        }
1050        args.extend([
1051            "-T",
1052            parse::ANNOTATE_TEMPLATE,
1053            "--color",
1054            "never",
1055            "--",
1056            path,
1057        ]);
1058        self.core
1059            .parse(self.core.command_in(dir, args), parse::parse_annotate)
1060            .await
1061    }
1062
1063    async fn file_show(&self, dir: &Path, revset: &str, path: &str) -> Result<String> {
1064        // `file show` takes FILESETS, so a bare path with a fileset
1065        // metacharacter (`(`, `*`, `~`, …) would be parsed as an expression —
1066        // wrap it in the exact-path form. (`file annotate` is the opposite: it
1067        // takes a plain PATH and rejects the `file:"…"` form.)
1068        let fileset = JjFileset::path(path);
1069        self.core
1070            .run(self.cmd_in(dir, ["file", "show", "-r", revset, fileset.as_str()]))
1071            .await
1072    }
1073
1074    async fn rebase(&self, dir: &Path, onto: &str) -> Result<()> {
1075        self.core
1076            .run_unit(self.cmd_in(dir, ["rebase", "-d", onto]))
1077            .await
1078    }
1079
1080    async fn rebase_branch(&self, dir: &Path, branch: &str, dest: &str) -> Result<()> {
1081        self.core
1082            .run_unit(self.cmd_in(dir, ["rebase", "-b", branch, "-d", dest]))
1083            .await
1084    }
1085
1086    async fn edit(&self, dir: &Path, revset: &str) -> Result<()> {
1087        reject_flag_like("revset", revset)?;
1088        self.core.run_unit(self.cmd_in(dir, ["edit", revset])).await
1089    }
1090
1091    async fn squash_into(
1092        &self,
1093        dir: &Path,
1094        into: &str,
1095        use_destination_message: bool,
1096    ) -> Result<()> {
1097        let mut command = self.cmd_in(dir, ["squash", "--into", into]);
1098        if use_destination_message {
1099            command = command.arg("--use-destination-message");
1100        }
1101        self.core.run_unit(command).await
1102    }
1103
1104    async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()> {
1105        let mut args: Vec<String> = vec!["commit".into(), "-m".into(), message.into()];
1106        args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1107        self.core.run_unit(self.cmd_in(dir, args)).await
1108    }
1109
1110    async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()> {
1111        let mut args: Vec<String> = vec![
1112            "squash".into(),
1113            "--from".into(),
1114            spec.from,
1115            "--into".into(),
1116            spec.into,
1117        ];
1118        if spec.use_destination_message {
1119            args.push("--use-destination-message".into());
1120        }
1121        args.extend(spec.filesets.iter().map(|f| f.as_str().to_string()));
1122        self.core.run_unit(self.cmd_in(dir, args)).await
1123    }
1124
1125    async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()> {
1126        // `--clear` empties the working copy first, then each `--add` reinstates a
1127        // pattern — so the working copy ends up holding exactly `patterns`.
1128        let mut args: Vec<String> = vec!["sparse".into(), "set".into(), "--clear".into()];
1129        for pattern in patterns {
1130            args.push("--add".into());
1131            args.push(pattern.clone());
1132        }
1133        self.core.run_unit(self.cmd_in(dir, args)).await
1134    }
1135
1136    async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<String>) -> Result<()> {
1137        // Parents are bare positionals — a leading-`-` one (e.g.
1138        // `--ignore-working-copy`) would be silently consumed as a flag.
1139        for parent in &parents {
1140            reject_flag_like("parent", parent)?;
1141        }
1142        let mut args: Vec<String> = vec!["new".into(), "-m".into(), message.into()];
1143        args.extend(parents);
1144        self.core.run_unit(self.cmd_in(dir, args)).await
1145    }
1146
1147    async fn abandon(&self, dir: &Path, revset: &str) -> Result<()> {
1148        reject_flag_like("revset", revset)?;
1149        self.core
1150            .run_unit(self.cmd_in(dir, ["abandon", revset]))
1151            .await
1152    }
1153
1154    async fn git_fetch_branch(&self, dir: &Path, branch: &str) -> Result<()> {
1155        let cmd = self
1156            .cmd_in(dir, ["git", "fetch", "--remote", "origin", "-b", branch])
1157            .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1158        self.core.run_unit(cmd).await
1159    }
1160
1161    async fn git_import(&self, dir: &Path) -> Result<()> {
1162        self.core
1163            .run_unit(self.cmd_in(dir, ["git", "import"]))
1164            .await
1165    }
1166
1167    async fn git_clone(&self, url: &str, dest: &Path, colocate: bool) -> Result<()> {
1168        // A leading-`-` url is a bare positional — guard it (a real URL never
1169        // leads with `-`, so no false positives).
1170        reject_flag_like("url", url)?;
1171        // No working directory yet (the clone creates `dest`), so this builds
1172        // on the raw `command` and appends `--color never` at the end — the
1173        // `workspace_add` precedent for color-after-value-args. The colocate
1174        // flag is ALWAYS passed: jj's default flipped across versions and is
1175        // overridable via `git.colocate` config, so an omitted flag would make
1176        // `colocate: false` a lie on some setups.
1177        let command = self
1178            .core
1179            .command(["git", "clone", url])
1180            .arg(dest)
1181            .arg(if colocate {
1182                "--colocate"
1183            } else {
1184                "--no-colocate"
1185            });
1186        self.core
1187            .run_unit(command.arg("--color").arg("never"))
1188            .await
1189    }
1190
1191    async fn absorb(&self, dir: &Path, from: Option<String>, filesets: &[JjFileset]) -> Result<()> {
1192        let mut args: Vec<String> = vec!["absorb".into()];
1193        if let Some(from) = from.as_deref() {
1194            args.push("--from".into());
1195            args.push(from.into());
1196        }
1197        args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1198        self.core.run_unit(self.cmd_in(dir, args)).await
1199    }
1200
1201    async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()> {
1202        // A fileset-less `jj split` opens the interactive diff editor — even
1203        // with `-m` — which would hang a headless run indefinitely. Refuse
1204        // before spawning anything.
1205        if filesets.is_empty() {
1206            return Err(Error::Spawn {
1207                program: BINARY.to_string(),
1208                source: std::io::Error::new(
1209                    std::io::ErrorKind::InvalidInput,
1210                    "split_paths requires at least one fileset — an empty split \
1211                     opens jj's interactive diff editor",
1212                ),
1213            });
1214        }
1215        // `-m` doubles as the description-editor suppressor.
1216        let mut args: Vec<String> = vec!["split".into(), "-m".into(), message.into()];
1217        args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1218        self.core.run_unit(self.cmd_in(dir, args)).await
1219    }
1220
1221    async fn duplicate(&self, dir: &Path, revset: &str) -> Result<()> {
1222        reject_flag_like("revset", revset)?;
1223        self.core
1224            .run_unit(self.cmd_in(dir, ["duplicate", revset]))
1225            .await
1226    }
1227
1228    async fn op_head(&self, dir: &Path) -> Result<String> {
1229        self.core
1230            .run(self.cmd_in(
1231                dir,
1232                [
1233                    "op",
1234                    "log",
1235                    "--no-graph",
1236                    "--limit",
1237                    "1",
1238                    "-T",
1239                    "id.short()",
1240                ],
1241            ))
1242            .await
1243    }
1244
1245    async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>> {
1246        let limit = limit.to_string();
1247        self.core
1248            .parse(
1249                self.cmd_in(
1250                    dir,
1251                    [
1252                        "op",
1253                        "log",
1254                        "--no-graph",
1255                        "--limit",
1256                        limit.as_str(),
1257                        "-T",
1258                        parse::OP_TEMPLATE,
1259                    ],
1260                ),
1261                parse::parse_operations,
1262            )
1263            .await
1264    }
1265
1266    async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()> {
1267        reject_flag_like("operation id", op_id)?;
1268        self.core
1269            .run_unit(self.cmd_in(dir, ["op", "restore", op_id]))
1270            .await
1271    }
1272
1273    async fn op_undo(&self, dir: &Path) -> Result<()> {
1274        self.core.run_unit(self.cmd_in(dir, ["op", "undo"])).await
1275    }
1276
1277    async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>> {
1278        self.core
1279            .parse(
1280                self.cmd_in(dir, ["workspace", "list", "-T", parse::WORKSPACE_TEMPLATE]),
1281                parse::parse_workspaces,
1282            )
1283            .await
1284    }
1285
1286    async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf> {
1287        let mut args: Vec<String> = vec!["workspace".into(), "root".into()];
1288        if let Some(n) = name.as_deref() {
1289            args.push("--name".into());
1290            args.push(n.to_string());
1291        }
1292        Ok(PathBuf::from(self.core.run(self.cmd_in(dir, args)).await?))
1293    }
1294
1295    async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()> {
1296        // Built directly on `command_in` (not `cmd_in`) because the trailing
1297        // `--color never` must come after the chained value args, not between
1298        // `--name` and its value.
1299        let mut command = self
1300            .core
1301            .command_in(dir, ["workspace", "add", "--name"])
1302            .arg(&spec.name)
1303            .arg("-r")
1304            .arg(&spec.base);
1305        if let Some(mode) = spec.sparse_patterns {
1306            command = command.arg("--sparse-patterns").arg(mode.as_arg());
1307        }
1308        command = command.arg(&spec.path).arg("--color").arg("never");
1309        self.core.run_unit(command).await
1310    }
1311
1312    async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()> {
1313        reject_flag_like("workspace name", name)?;
1314        self.core
1315            .run_unit(self.cmd_in(dir, ["workspace", "forget", name]))
1316            .await
1317    }
1318}
1319
1320/// Total attempts / fixed backoff for a transient-retried fetch — the shared
1321/// policy from `vcs-cli-support`, aliased so the retry call sites read locally.
1322const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
1323const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
1324
1325/// How many `jj workspace root` lookups [`Jj::workspace_roots`] keeps in flight at
1326/// once — a cap so a repo with many workspaces doesn't spawn an unbounded burst of
1327/// processes, while still overlapping the (fast, network-free) calls.
1328const WORKSPACE_ROOTS_CONCURRENCY: usize = 8;
1329
1330impl<R: ProcessRunner> Jj<R> {
1331    /// Run `jj <args>` over string slices — `jj.run_args(&["log", "-r", "@"])`
1332    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
1333    /// trait), so it can take `&[&str]`; forwards to the same path as
1334    /// [`JjApi::run`].
1335    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
1336        self.core.run(self.core.command(args)).await
1337    }
1338
1339    /// Resolve several workspaces' root paths in one **bounded fan-out** — one
1340    /// `jj workspace root --name <n>` per name, at most
1341    /// `WORKSPACE_ROOTS_CONCURRENCY` (8) live at a time — instead of awaiting each in
1342    /// turn. Per-name `Ok`/`Err` mirrors [`workspace_root`](JjApi::workspace_root)
1343    /// (a non-zero exit or spawn failure → `Err`); results come back in `names`
1344    /// order. Runs through this client's own runner, so a `ScriptedRunner` test
1345    /// drives it hermetically. Inherent (not on the object-safe trait): it's a
1346    /// throughput shape over the trait method, and the batch primitive isn't a
1347    /// mockable per-call seam.
1348    pub async fn workspace_roots(&self, dir: &Path, names: &[String]) -> Vec<Result<PathBuf>> {
1349        let commands = names
1350            .iter()
1351            .map(|n| self.cmd_in(dir, ["workspace", "root", "--name", n.as_str()]));
1352        processkit::output_all(commands, WORKSPACE_ROOTS_CONCURRENCY, self.core.runner())
1353            .await
1354            .into_iter()
1355            .map(|r| {
1356                r.and_then(|pr| pr.ensure_success())
1357                    // `trim_end` (not `trim`) for exact parity with the single
1358                    // `workspace_root`, which trims via `core.run`'s `trim_end`.
1359                    .map(|pr| PathBuf::from(pr.stdout().trim_end()))
1360            })
1361            .collect()
1362    }
1363
1364    /// Like [`run_args`](Jj::run_args) but never errors on a non-zero exit
1365    /// (mirrors [`JjApi::run_raw`]).
1366    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
1367        self.core.output(self.core.command(args)).await
1368    }
1369
1370    /// Bind this client to `dir`, returning a [`JjAt`] handle whose methods omit
1371    /// the `dir` argument: `jj.at(dir).status()` runs [`status`](JjApi::status)
1372    /// against `dir`. The dir-taking [`JjApi`] methods stay on [`Jj`] for driving
1373    /// many directories (e.g. workspaces) from one client.
1374    pub fn at<'a>(&'a self, dir: &'a Path) -> JjAt<'a, R> {
1375        JjAt { jj: self, dir }
1376    }
1377
1378    /// Run a mutation sequence with op-log rollback: capture the current
1379    /// operation ([`op_head`](JjApi::op_head)), run `f` with a [`JjAt`] bound to
1380    /// `dir`, and on `Err` restore the repo to the captured operation
1381    /// ([`op_restore`](JjApi::op_restore)) before returning the error.
1382    ///
1383    /// ```no_run
1384    /// # async fn demo(jj: &vcs_jj::Jj) -> Result<(), processkit::Error> {
1385    /// jj.transaction(std::path::Path::new("."), |tx| async move {
1386    ///     tx.describe("wip").await?;
1387    ///     tx.new_change("next").await // an Err here rolls back the describe
1388    /// })
1389    /// .await?;
1390    /// # Ok(()) }
1391    /// ```
1392    ///
1393    /// Inherent (not on the object-safe trait): the closure parameter is
1394    /// generic, which `mockall` / trait objects can't express.
1395    ///
1396    /// Caveats:
1397    /// - Rollback runs on `Err` only — **not** on panic or cancellation (a
1398    ///   dropped future); there is no async `Drop`. Convert panics to `Err`
1399    ///   inside `f` if you need that safety.
1400    /// - If the restore itself fails, the *original* error from `f` is returned
1401    ///   and the repo may be left mid-transaction; re-probe
1402    ///   [`op_head`](JjApi::op_head) to detect that.
1403    pub async fn transaction<'a, T, F, Fut>(&'a self, dir: &'a Path, f: F) -> Result<T>
1404    where
1405        F: FnOnce(JjAt<'a, R>) -> Fut,
1406        Fut: Future<Output = Result<T>> + 'a,
1407    {
1408        let pre = self.op_head(dir).await?;
1409        match f(self.at(dir)).await {
1410            Ok(value) => Ok(value),
1411            Err(err) => {
1412                // Best-effort restore; the closure's error is the cause and is
1413                // what the caller must see even when the restore also fails.
1414                let _ = self.op_restore(dir, &pre).await;
1415                Err(err)
1416            }
1417        }
1418    }
1419}
1420
1421/// A [`Jj`] client with a working directory bound, so calls drop the leading
1422/// `dir` argument — `jj.at(dir).status()` is `jj.status(dir)`. Construct one with
1423/// [`Jj::at`] (or, through the facade, `vcs_core::Repo::jj_at`). Cheap to copy: it
1424/// only borrows the client and the path.
1425pub struct JjAt<'a, R: ProcessRunner = processkit::JobRunner> {
1426    jj: &'a Jj<R>,
1427    dir: &'a Path,
1428}
1429
1430// Hand-written rather than derived: holding only references, the view is `Copy`
1431// for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy` bound the
1432// default `JobRunner` doesn't satisfy, silently dropping `Copy` on the production
1433// handle.
1434impl<R: ProcessRunner> Clone for JjAt<'_, R> {
1435    fn clone(&self) -> Self {
1436        *self
1437    }
1438}
1439impl<R: ProcessRunner> Copy for JjAt<'_, R> {}
1440
1441/// Generate [`JjAt`] forwarders from a method list: `bare` methods forward
1442/// verbatim, `dir` methods inject `self.dir` as the first argument.
1443macro_rules! jj_at_forwarders {
1444    (
1445        bare { $( fn $bn:ident( $($ba:ident: $bt:ty),* $(,)? ) -> $br:ty; )* }
1446        dir  { $( fn $dn:ident( $($da:ident: $dt:ty),* $(,)? ) -> $dr:ty; )* }
1447    ) => {
1448        impl<'a, R: ProcessRunner> JjAt<'a, R> {
1449            $(
1450                #[doc = concat!("Bound form of [`Jj`]'s `", stringify!($bn), "`.")]
1451                pub async fn $bn(&self, $($ba: $bt),*) -> $br {
1452                    self.jj.$bn($($ba),*).await
1453                }
1454            )*
1455            $(
1456                #[doc = concat!("Bound form of [`Jj`]'s `", stringify!($dn), "` (with `dir` pre-bound).")]
1457                pub async fn $dn(&self, $($da: $dt),*) -> $dr {
1458                    self.jj.$dn(self.dir, $($da),*).await
1459                }
1460            )*
1461        }
1462    };
1463}
1464
1465jj_at_forwarders! {
1466    bare {
1467        fn run(args: &[String]) -> Result<String>;
1468        fn run_raw(args: &[String]) -> Result<ProcessResult<String>>;
1469        fn run_args(args: &[&str]) -> Result<String>;
1470        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>>;
1471        fn version() -> Result<String>;
1472        fn capabilities() -> Result<JjCapabilities>;
1473        fn git_clone(url: &str, dest: &Path, colocate: bool) -> Result<()>;
1474    }
1475    dir {
1476        fn status() -> Result<Vec<ChangedPath>>;
1477        fn status_text() -> Result<String>;
1478        fn log(revset: &str, max: usize) -> Result<Vec<Change>>;
1479        fn current_change() -> Result<Change>;
1480        fn describe(message: &str) -> Result<()>;
1481        fn describe_rev(revset: &str, message: &str) -> Result<()>;
1482        fn new_change(message: &str) -> Result<()>;
1483        fn bookmarks() -> Result<Vec<Bookmark>>;
1484        fn bookmarks_all() -> Result<Vec<BookmarkRef>>;
1485        fn reachable_bookmarks() -> Result<Vec<Bookmark>>;
1486        fn bookmark_track(name: &str, remote: &str) -> Result<()>;
1487        fn bookmark_set(name: &str, revision: &str) -> Result<()>;
1488        fn git_fetch() -> Result<()>;
1489        fn git_fetch_from(remote: &str) -> Result<()>;
1490        fn git_push(bookmark: Option<String>) -> Result<()>;
1491        fn root() -> Result<PathBuf>;
1492        fn current_bookmark() -> Result<Option<String>>;
1493        fn trunk() -> Result<Option<String>>;
1494        fn bookmark_create(name: &str, revision: &str) -> Result<()>;
1495        fn bookmark_rename(old: &str, new: &str) -> Result<()>;
1496        fn bookmark_delete(name: &str) -> Result<()>;
1497        fn bookmark_move(name: &str, to: &str, allow_backwards: bool) -> Result<()>;
1498        fn diff_summary(from: &str, to: &str) -> Result<Vec<ChangedPath>>;
1499        fn diff_stat(revset: &str) -> Result<DiffStat>;
1500        fn diff_text(spec: DiffSpec) -> Result<String>;
1501        fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
1502        fn commit_count(revset: &str) -> Result<usize>;
1503        fn is_conflicted(revset: &str) -> Result<bool>;
1504        fn has_workingcopy_conflict() -> Result<bool>;
1505        fn resolve_list(revset: &str) -> Result<Vec<String>>;
1506        fn template_query(revset: &str, template: &str, limit: Option<usize>) -> Result<String>;
1507        fn description(revset: &str) -> Result<String>;
1508        fn evolog(revset: &str, max: usize) -> Result<Vec<Change>>;
1509        fn file_annotate(path: &str, revset: Option<String>) -> Result<Vec<AnnotationLine>>;
1510        fn file_show(revset: &str, path: &str) -> Result<String>;
1511        fn absorb(from: Option<String>, filesets: &[JjFileset]) -> Result<()>;
1512        fn split_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
1513        fn duplicate(revset: &str) -> Result<()>;
1514        fn rebase(onto: &str) -> Result<()>;
1515        fn rebase_branch(branch: &str, dest: &str) -> Result<()>;
1516        fn edit(revset: &str) -> Result<()>;
1517        fn squash_into(into: &str, use_destination_message: bool) -> Result<()>;
1518        fn commit_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
1519        fn squash_paths(spec: SquashPaths) -> Result<()>;
1520        fn sparse_set(patterns: &[String]) -> Result<()>;
1521        fn new_merge(message: &str, parents: Vec<String>) -> Result<()>;
1522        fn abandon(revset: &str) -> Result<()>;
1523        fn git_fetch_branch(branch: &str) -> Result<()>;
1524        fn git_import() -> Result<()>;
1525        fn op_head() -> Result<String>;
1526        fn op_log(limit: usize) -> Result<Vec<Operation>>;
1527        fn op_restore(op_id: &str) -> Result<()>;
1528        fn op_undo() -> Result<()>;
1529        fn workspace_list() -> Result<Vec<Workspace>>;
1530        fn workspace_root(name: Option<String>) -> Result<PathBuf>;
1531        fn workspace_add(spec: WorkspaceAdd) -> Result<()>;
1532        fn workspace_forget(name: &str) -> Result<()>;
1533    }
1534}
1535
1536// Manual forwarder: `transaction` takes a generic closure, which the declarative
1537// forwarder macro (fixed argument lists) cannot express.
1538impl<'a, R: ProcessRunner> JjAt<'a, R> {
1539    /// Bound form of [`Jj::transaction`] (with `dir` pre-bound): run `f` with
1540    /// op-log rollback on `Err`. See [`Jj::transaction`] for the caveats.
1541    pub async fn transaction<T, F, Fut>(&self, f: F) -> Result<T>
1542    where
1543        F: FnOnce(JjAt<'a, R>) -> Fut,
1544        Fut: Future<Output = Result<T>> + 'a,
1545    {
1546        self.jj.transaction(self.dir, f).await
1547    }
1548}
1549
1550/// Synchronous, best-effort helpers for contexts that cannot `.await` — chiefly
1551/// a `Drop` guard. They shell out through `std::process` directly (no async, no
1552/// job-containment), so reserve them for short-lived cleanup.
1553pub mod blocking {
1554    use std::path::{Path, PathBuf};
1555    use std::process::Command;
1556
1557    /// Forget a workspace synchronously (`jj workspace forget <name>`).
1558    pub fn workspace_forget(dir: &Path, name: &str) -> std::io::Result<()> {
1559        let status = Command::new(super::BINARY)
1560            .current_dir(dir)
1561            .args(["workspace", "forget", name])
1562            .status()?;
1563        if status.success() {
1564            Ok(())
1565        } else {
1566            Err(std::io::Error::other(format!(
1567                "`jj workspace forget` exited with {status}"
1568            )))
1569        }
1570    }
1571
1572    /// Resolve the workspace *name* whose root matches `path`, synchronously —
1573    /// for `Drop`, which can't `.await` the typed `workspace_list`/`workspace_root`.
1574    /// Lists workspaces (`workspace list -T name`), then matches each
1575    /// `workspace root --name <n>` against `path` (canonicalised, Windows
1576    /// verbatim-prefix stripped). `None` when jj is missing or nothing matches —
1577    /// the caller then skips the forget rather than guessing.
1578    pub fn workspace_name_for_path(dir: &Path, path: &Path) -> Option<String> {
1579        let target = normalize(path);
1580        let out = Command::new(super::BINARY)
1581            .current_dir(dir)
1582            .args(["workspace", "list", "-T", "name ++ \"\\n\""])
1583            .output()
1584            .ok()?;
1585        if !out.status.success() {
1586            return None;
1587        }
1588        for name in String::from_utf8_lossy(&out.stdout).lines() {
1589            let name = name.trim();
1590            if name.is_empty() {
1591                continue;
1592            }
1593            let root = Command::new(super::BINARY)
1594                .current_dir(dir)
1595                .args(["workspace", "root", "--name", name])
1596                .output();
1597            if let Ok(r) = root
1598                && r.status.success()
1599            {
1600                let p = PathBuf::from(String::from_utf8_lossy(&r.stdout).trim().to_string());
1601                if normalize(&p) == target || p == target || p == path {
1602                    return Some(name.to_string());
1603                }
1604            }
1605        }
1606        None
1607    }
1608
1609    /// Canonicalise + strip the Windows verbatim prefix (`\\?\…`, which
1610    /// `canonicalize` adds but jj never emits) for stable path comparison.
1611    fn normalize(p: &Path) -> PathBuf {
1612        let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
1613        #[cfg(windows)]
1614        {
1615            let s = canonical.to_string_lossy();
1616            if let Some(rest) = s.strip_prefix(r"\\?\")
1617                && !rest.starts_with("UNC\\")
1618            {
1619                return PathBuf::from(rest.to_string());
1620            }
1621        }
1622        canonical
1623    }
1624}
1625
1626#[cfg(test)]
1627mod tests {
1628    use super::*;
1629    use processkit::{RecordingRunner, Reply, ScriptedRunner};
1630
1631    #[test]
1632    fn binary_name_is_jj() {
1633        assert_eq!(BINARY, "jj");
1634    }
1635
1636    // Compile-time guard: the bound view stays `Copy` for the default `JobRunner`.
1637    #[allow(dead_code)]
1638    fn bound_view_is_copy_for_default_runner() {
1639        fn assert_copy<T: Copy>() {}
1640        assert_copy::<JjAt<'static, processkit::JobRunner>>();
1641    }
1642
1643    // The bound view (`jj.at(dir)`) must produce byte-identical argv to the
1644    // dir-taking call — including the forced `--color never`.
1645    #[tokio::test]
1646    async fn bound_view_matches_dir_taking_calls() {
1647        let dir = Path::new("/repo");
1648        let rec = RecordingRunner::replying(Reply::ok(""));
1649        let jj = Jj::with_runner(&rec);
1650
1651        jj.bookmark_move(dir, "main", "@", true).await.unwrap();
1652        jj.at(dir).bookmark_move("main", "@", true).await.unwrap();
1653        jj.describe_rev(dir, "feat", "msg").await.unwrap();
1654        jj.at(dir).describe_rev("feat", "msg").await.unwrap();
1655        jj.description(dir, "@-").await.unwrap();
1656        jj.at(dir).description("@-").await.unwrap();
1657        // One of the §4 additions.
1658        jj.duplicate(dir, "@-").await.unwrap();
1659        jj.at(dir).duplicate("@-").await.unwrap();
1660
1661        let calls = rec.calls();
1662        assert_eq!(calls[0].args_str(), calls[1].args_str());
1663        assert_eq!(calls[2].args_str(), calls[3].args_str());
1664        assert_eq!(calls[4].args_str(), calls[5].args_str());
1665        assert_eq!(calls[6].args_str(), calls[7].args_str());
1666        assert_eq!(calls[1].cwd.as_deref(), Some(dir.as_os_str()));
1667    }
1668
1669    #[tokio::test]
1670    async fn workspace_list_parses_template_rows() {
1671        let jj = Jj::with_runner(ScriptedRunner::new().on(
1672            ["workspace", "list"],
1673            Reply::ok("default\te2aa3420\tmain\nws1\t12345678\t\n"),
1674        ));
1675        let got = jj.workspace_list(Path::new(".")).await.expect("list");
1676        assert_eq!(got.len(), 2);
1677        assert_eq!(got[0].name, "default");
1678        assert_eq!(got[0].bookmarks, vec!["main".to_string()]);
1679        assert!(got[1].bookmarks.is_empty());
1680    }
1681
1682    // `workspace_roots` fans out one `workspace root --name <n>` per name, returns
1683    // a path per slot in input order, and maps a non-zero exit to `Err` for that
1684    // slot (mirroring the single `workspace_root`). Runs through the scripted
1685    // runner, so it's hermetic.
1686    #[tokio::test]
1687    async fn workspace_roots_batches_per_name_and_maps_errors() {
1688        let rec = RecordingRunner::new(
1689            ScriptedRunner::new()
1690                .on(
1691                    ["workspace", "root", "--name", "default"],
1692                    Reply::ok("/repo\n"),
1693                )
1694                .on(
1695                    ["workspace", "root", "--name", "ws1"],
1696                    Reply::ok("/repo/ws1\n"),
1697                )
1698                .on(
1699                    ["workspace", "root", "--name", "gone"],
1700                    Reply::fail(1, "Error: No such workspace"),
1701                ),
1702        );
1703        let jj = Jj::with_runner(&rec);
1704        let roots = jj
1705            .workspace_roots(
1706                Path::new("/repo"),
1707                &["default".into(), "gone".into(), "ws1".into()],
1708            )
1709            .await;
1710        // Order matches the input, regardless of completion order.
1711        assert_eq!(roots.len(), 3);
1712        assert_eq!(roots[0].as_deref().unwrap(), Path::new("/repo"));
1713        assert!(roots[1].is_err(), "a non-zero `workspace root` is Err");
1714        assert_eq!(roots[2].as_deref().unwrap(), Path::new("/repo/ws1"));
1715        // Exactly one `workspace root --name <n>` command per name.
1716        let calls = rec.calls();
1717        assert_eq!(calls.len(), 3);
1718        assert!(
1719            calls
1720                .iter()
1721                .all(|c| c.args_str()[..2] == ["workspace", "root"])
1722        );
1723    }
1724
1725    // `workspace add` must build `--name <n> -r <base> <path>` in order.
1726    #[tokio::test]
1727    async fn workspace_add_builds_name_base_path() {
1728        let rec = RecordingRunner::replying(Reply::ok(""));
1729        let jj = Jj::with_runner(&rec);
1730        jj.workspace_add(Path::new("/repo"), WorkspaceAdd::new("ws1", "main", "/wt"))
1731            .await
1732            .expect("workspace add");
1733        assert_eq!(
1734            rec.only_call().args_str(),
1735            [
1736                "workspace",
1737                "add",
1738                "--name",
1739                "ws1",
1740                "-r",
1741                "main",
1742                "/wt",
1743                "--color",
1744                "never"
1745            ]
1746        );
1747    }
1748
1749    // `--sparse-patterns <mode>` lands between `-r <base>` and the path.
1750    #[tokio::test]
1751    async fn workspace_add_with_sparse_mode() {
1752        let rec = RecordingRunner::replying(Reply::ok(""));
1753        let jj = Jj::with_runner(&rec);
1754        jj.workspace_add(
1755            Path::new("/repo"),
1756            WorkspaceAdd::new("ws1", "main", "/wt").sparse(SparseMode::Empty),
1757        )
1758        .await
1759        .expect("workspace add");
1760        assert_eq!(
1761            rec.only_call().args_str(),
1762            [
1763                "workspace",
1764                "add",
1765                "--name",
1766                "ws1",
1767                "-r",
1768                "main",
1769                "--sparse-patterns",
1770                "empty",
1771                "/wt",
1772                "--color",
1773                "never"
1774            ]
1775        );
1776    }
1777
1778    #[test]
1779    fn fileset_quotes_metacharacters() {
1780        assert_eq!(
1781            JjFileset::path("src/a(b).rs").as_str(),
1782            "file:\"src/a(b).rs\""
1783        );
1784        // A Windows backslash separator is normalised to `/` so jj matches it
1785        // (a literal-backslash filename would match nothing).
1786        assert_eq!(JjFileset::path("src\\a.rs").as_str(), "file:\"src/a.rs\"");
1787        // A literal quote is escaped for the `file:"…"` string literal.
1788        assert_eq!(JjFileset::path("a\"b").as_str(), "file:\"a\\\"b\"");
1789    }
1790
1791    #[tokio::test]
1792    async fn commit_paths_builds_filesets() {
1793        let rec = RecordingRunner::replying(Reply::ok(""));
1794        let jj = Jj::with_runner(&rec);
1795        jj.commit_paths(
1796            Path::new("."),
1797            &[JjFileset::path("x|y.rs"), JjFileset::path("z.rs")],
1798            "msg",
1799        )
1800        .await
1801        .expect("commit_paths");
1802        assert_eq!(
1803            rec.only_call().args_str(),
1804            [
1805                "commit",
1806                "-m",
1807                "msg",
1808                "file:\"x|y.rs\"",
1809                "file:\"z.rs\"",
1810                "--color",
1811                "never"
1812            ]
1813        );
1814    }
1815
1816    #[tokio::test]
1817    async fn squash_paths_builds_from_into_filesets() {
1818        let rec = RecordingRunner::replying(Reply::ok(""));
1819        let jj = Jj::with_runner(&rec);
1820        jj.squash_paths(
1821            Path::new("."),
1822            SquashPaths::new("@", "feat").filesets([JjFileset::path("a.rs")]),
1823        )
1824        .await
1825        .expect("squash_paths");
1826        assert_eq!(
1827            rec.only_call().args_str(),
1828            [
1829                "squash",
1830                "--from",
1831                "@",
1832                "--into",
1833                "feat",
1834                "file:\"a.rs\"",
1835                "--color",
1836                "never"
1837            ]
1838        );
1839    }
1840
1841    #[tokio::test]
1842    async fn squash_paths_keeps_destination_message() {
1843        let rec = RecordingRunner::replying(Reply::ok(""));
1844        let jj = Jj::with_runner(&rec);
1845        jj.squash_paths(
1846            Path::new("."),
1847            SquashPaths::new("@", "feat")
1848                .filesets([JjFileset::path("a.rs")])
1849                .use_destination_message(),
1850        )
1851        .await
1852        .expect("squash_paths");
1853        assert_eq!(
1854            rec.only_call().args_str(),
1855            [
1856                "squash",
1857                "--from",
1858                "@",
1859                "--into",
1860                "feat",
1861                "--use-destination-message",
1862                "file:\"a.rs\"",
1863                "--color",
1864                "never"
1865            ]
1866        );
1867    }
1868
1869    #[tokio::test]
1870    async fn jj_new_revision_scoped_ops_build_args() {
1871        let rec = RecordingRunner::replying(Reply::ok(""));
1872        let jj = Jj::with_runner(&rec);
1873        jj.describe_rev(Path::new("."), "feat", "msg")
1874            .await
1875            .unwrap();
1876        assert_eq!(
1877            rec.only_call().args_str(),
1878            ["describe", "-r", "feat", "-m", "msg", "--color", "never"]
1879        );
1880
1881        let rec = RecordingRunner::replying(Reply::ok(""));
1882        let jj = Jj::with_runner(&rec);
1883        jj.rebase_branch(Path::new("."), "feat", "main")
1884            .await
1885            .unwrap();
1886        assert_eq!(
1887            rec.only_call().args_str(),
1888            ["rebase", "-b", "feat", "-d", "main", "--color", "never"]
1889        );
1890
1891        let rec = RecordingRunner::replying(Reply::ok(""));
1892        let jj = Jj::with_runner(&rec);
1893        jj.bookmark_track(Path::new("."), "feat", "origin")
1894            .await
1895            .unwrap();
1896        assert_eq!(
1897            rec.only_call().args_str(),
1898            ["bookmark", "track", "feat@origin", "--color", "never"]
1899        );
1900    }
1901
1902    #[tokio::test]
1903    async fn bookmarks_all_parses_local_and_remote() {
1904        let jj = Jj::with_runner(ScriptedRunner::new().on(
1905            ["bookmark", "list"],
1906            Reply::ok("main\t\t0\tabc123\nmain\torigin\t1\tabc123\n"),
1907        ));
1908        let refs = jj.bookmarks_all(Path::new(".")).await.unwrap();
1909        assert_eq!(refs.len(), 2);
1910        assert_eq!(refs[0].name, "main");
1911        assert!(refs[0].remote.is_none() && !refs[0].tracked);
1912        assert_eq!(refs[1].remote.as_deref(), Some("origin"));
1913        assert!(refs[1].tracked);
1914    }
1915
1916    #[tokio::test]
1917    async fn sparse_set_clears_then_adds() {
1918        let rec = RecordingRunner::replying(Reply::ok(""));
1919        let jj = Jj::with_runner(&rec);
1920        jj.sparse_set(Path::new("."), &["README.md".into(), "lib".into()])
1921            .await
1922            .expect("sparse_set");
1923        assert_eq!(
1924            rec.only_call().args_str(),
1925            [
1926                "sparse",
1927                "set",
1928                "--clear",
1929                "--add",
1930                "README.md",
1931                "--add",
1932                "lib",
1933                "--color",
1934                "never"
1935            ]
1936        );
1937    }
1938
1939    // Parsed status() is backed by `diff -r @ --summary`, not `jj status`.
1940    #[tokio::test]
1941    async fn status_parses_diff_summary() {
1942        let jj = Jj::with_runner(ScriptedRunner::new().on(
1943            ["diff", "-r", "@", "--summary"],
1944            Reply::ok("M a.rs\nA b.rs\n"),
1945        ));
1946        let entries = jj.status(Path::new(".")).await.expect("status");
1947        assert_eq!(entries.len(), 2);
1948        assert_eq!(entries[0].status, 'M');
1949        assert_eq!(entries[1].path, "b.rs");
1950    }
1951
1952    #[tokio::test]
1953    async fn status_text_is_raw_jj_status() {
1954        let jj = Jj::with_runner(
1955            ScriptedRunner::new().on(["status"], Reply::ok("Working copy changes:\n")),
1956        );
1957        assert!(
1958            jj.status_text(Path::new("."))
1959                .await
1960                .expect("status_text")
1961                .contains("Working copy changes")
1962        );
1963    }
1964
1965    #[tokio::test]
1966    async fn run_args_forwards_str_slices() {
1967        let jj = Jj::with_runner(ScriptedRunner::new().on(["root"], Reply::ok("/r\n")));
1968        assert_eq!(jj.run_args(&["root"]).await.unwrap(), "/r");
1969    }
1970
1971    #[tokio::test]
1972    async fn bookmark_move_appends_allow_backwards() {
1973        let rec = RecordingRunner::replying(Reply::ok(""));
1974        let jj = Jj::with_runner(&rec);
1975        jj.bookmark_move(Path::new("/r"), "main", "@", true)
1976            .await
1977            .unwrap();
1978        assert_eq!(
1979            rec.only_call().args_str(),
1980            [
1981                "bookmark",
1982                "move",
1983                "main",
1984                "--to",
1985                "@",
1986                "--allow-backwards",
1987                "--color",
1988                "never"
1989            ]
1990        );
1991    }
1992
1993    #[tokio::test]
1994    async fn new_merge_appends_parents() {
1995        let rec = RecordingRunner::replying(Reply::ok(""));
1996        let jj = Jj::with_runner(&rec);
1997        jj.new_merge(Path::new("/r"), "m", vec!["p1".into(), "p2".into()])
1998            .await
1999            .unwrap();
2000        assert_eq!(
2001            rec.only_call().args_str(),
2002            ["new", "-m", "m", "p1", "p2", "--color", "never"]
2003        );
2004    }
2005
2006    #[tokio::test]
2007    async fn is_conflicted_reads_template_flag() {
2008        let yes = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("1\n")));
2009        assert!(yes.is_conflicted(Path::new("."), "@").await.unwrap());
2010        let no = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("0\n")));
2011        assert!(!no.is_conflicted(Path::new("."), "@").await.unwrap());
2012    }
2013
2014    #[tokio::test]
2015    async fn commit_count_counts_template_lines() {
2016        let jj = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("a\nb\nc\n")));
2017        assert_eq!(jj.commit_count(Path::new("."), "::@").await.unwrap(), 3);
2018    }
2019
2020    #[tokio::test]
2021    async fn reachable_bookmarks_queries_heads_revset() {
2022        let rec = RecordingRunner::replying(Reply::ok("main\tabc123\n"));
2023        let jj = Jj::with_runner(&rec);
2024        let got = jj.reachable_bookmarks(Path::new(".")).await.unwrap();
2025        assert_eq!(got.len(), 1);
2026        assert_eq!(got[0].name, "main");
2027        let args = rec.only_call().args_str();
2028        assert_eq!(
2029            &args[..4],
2030            &["log", "-r", "heads(::@ & bookmarks())", "--no-graph"]
2031        );
2032    }
2033
2034    #[tokio::test]
2035    async fn resolve_list_distinguishes_no_conflicts_from_errors() {
2036        // The benign "no conflicts" non-zero exit → empty list.
2037        let none = Jj::with_runner(ScriptedRunner::new().on(
2038            ["resolve"],
2039            Reply::fail(2, "Error: No conflicts found at this revision"),
2040        ));
2041        assert!(
2042            none.resolve_list(Path::new("."), "@")
2043                .await
2044                .unwrap()
2045                .is_empty()
2046        );
2047        // A real failure (e.g. bad revset) must surface, not read as "no conflicts".
2048        let bad = Jj::with_runner(ScriptedRunner::new().on(
2049            ["resolve"],
2050            Reply::fail(1, "Error: Revision `bogus` doesn't exist"),
2051        ));
2052        assert!(bad.resolve_list(Path::new("."), "bogus").await.is_err());
2053        // Success with conflicts → parsed paths.
2054        let some = Jj::with_runner(
2055            ScriptedRunner::new().on(["resolve"], Reply::ok("a.rs    2-sided conflict\n")),
2056        );
2057        assert_eq!(
2058            some.resolve_list(Path::new("."), "@").await.unwrap(),
2059            ["a.rs"]
2060        );
2061    }
2062
2063    #[tokio::test]
2064    async fn current_bookmark_takes_first_or_none() {
2065        let some = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("main\n")));
2066        assert_eq!(
2067            some.current_bookmark(Path::new("."))
2068                .await
2069                .unwrap()
2070                .as_deref(),
2071            Some("main")
2072        );
2073        let none = Jj::with_runner(ScriptedRunner::new().on(["log"], Reply::ok("\n")));
2074        assert!(
2075            none.current_bookmark(Path::new("."))
2076                .await
2077                .unwrap()
2078                .is_none()
2079        );
2080    }
2081
2082    // Hermetic: real log() arg-building + template parsing against canned output.
2083    #[tokio::test]
2084    async fn current_change_parses_scripted_output() {
2085        let jj = Jj::with_runner(
2086            ScriptedRunner::new().on(["log"], Reply::ok("kztuxlro\t38e00654\tfalse\thello jj\n")),
2087        );
2088        let change = jj
2089            .current_change(Path::new("."))
2090            .await
2091            .expect("current_change");
2092        assert_eq!(change.change_id, "kztuxlro");
2093        assert!(!change.empty);
2094        assert_eq!(change.description, "hello jj");
2095    }
2096
2097    // With a bookmark, the run must build `git push -b <name>`. Only that 4-token
2098    // command is scripted (no fallback), so a regression that dropped the flag
2099    // would match no rule and error.
2100    #[tokio::test]
2101    async fn git_push_appends_bookmark_flag() {
2102        let jj = Jj::with_runner(
2103            ScriptedRunner::new().on(["git", "push", "-b", "feature"], Reply::ok("")),
2104        );
2105        jj.git_push(Path::new("."), Some("feature".to_string()))
2106            .await
2107            .expect("should build `git push -b feature`");
2108    }
2109
2110    // Without a bookmark, the run is a bare `git push`.
2111    #[tokio::test]
2112    async fn git_push_without_bookmark_is_bare() {
2113        let jj = Jj::with_runner(ScriptedRunner::new().on(["git", "push"], Reply::ok("")));
2114        jj.git_push(Path::new("."), None).await.expect("bare push");
2115    }
2116
2117    // `git_fetch` retries a transient (network) failure up to FETCH_ATTEMPTS times.
2118    #[tokio::test]
2119    async fn git_fetch_retries_transient_failures() {
2120        let rec = RecordingRunner::replying(Reply::fail(1, "Error: Could not resolve host: x"));
2121        let jj = Jj::with_runner(&rec);
2122        assert!(jj.git_fetch(Path::new(".")).await.is_err());
2123        assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
2124    }
2125
2126    // `git_fetch_from` names the remote and shares `git_fetch`'s transient retry.
2127    #[tokio::test]
2128    async fn git_fetch_from_builds_args_and_retries() {
2129        let rec = RecordingRunner::replying(Reply::ok(""));
2130        let jj = Jj::with_runner(&rec);
2131        jj.git_fetch_from(Path::new("."), "upstream")
2132            .await
2133            .expect("git_fetch_from");
2134        assert_eq!(
2135            rec.only_call().args_str(),
2136            ["git", "fetch", "--remote", "upstream", "--color", "never"]
2137        );
2138
2139        let failing = RecordingRunner::replying(Reply::fail(1, "Error: Connection timed out"));
2140        let jj = Jj::with_runner(&failing);
2141        assert!(jj.git_fetch_from(Path::new("."), "upstream").await.is_err());
2142        assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
2143    }
2144
2145    // `transaction` captures the op head and restores it when the closure errors —
2146    // and the original (closure) error is what surfaces.
2147    #[tokio::test]
2148    async fn transaction_restores_op_head_on_error() {
2149        let rec = RecordingRunner::new(
2150            ScriptedRunner::new()
2151                .on(["op", "log"], Reply::ok("abc123\n"))
2152                .on(["op", "restore"], Reply::ok(""))
2153                .on(["describe"], Reply::fail(1, "boom")),
2154        );
2155        let jj = Jj::with_runner(&rec);
2156        let res = jj
2157            .transaction(
2158                Path::new("/r"),
2159                |tx| async move { tx.describe("wip").await },
2160            )
2161            .await;
2162        let err = res.expect_err("closure error must surface");
2163        assert!(matches!(err, Error::Exit { .. }));
2164        let calls = rec.calls();
2165        assert_eq!(calls.len(), 3, "op head, mutation, restore: {calls:?}");
2166        assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
2167        assert_eq!(calls[1].args_str()[0], "describe");
2168        assert_eq!(calls[2].args_str()[..3], ["op", "restore", "abc123"]);
2169    }
2170
2171    // A successful transaction must NOT restore (that would undo the work).
2172    #[tokio::test]
2173    async fn transaction_keeps_changes_on_success() {
2174        let rec = RecordingRunner::new(
2175            ScriptedRunner::new()
2176                .on(["op", "log"], Reply::ok("abc123\n"))
2177                .on(["describe"], Reply::ok("")),
2178        );
2179        let jj = Jj::with_runner(&rec);
2180        jj.transaction(
2181            Path::new("/r"),
2182            |tx| async move { tx.describe("wip").await },
2183        )
2184        .await
2185        .expect("transaction");
2186        let calls = rec.calls();
2187        assert_eq!(calls.len(), 2);
2188        assert!(
2189            calls.iter().all(|c| c.args_str()[..2] != ["op", "restore"]),
2190            "no restore on success: {calls:?}"
2191        );
2192    }
2193
2194    // The bound view forwards `transaction` with `dir` pre-bound.
2195    #[tokio::test]
2196    async fn bound_view_forwards_transaction() {
2197        let dir = Path::new("/repo");
2198        let rec = RecordingRunner::new(
2199            ScriptedRunner::new()
2200                .on(["op", "log"], Reply::ok("op9\n"))
2201                .on(["new"], Reply::ok("")),
2202        );
2203        let jj = Jj::with_runner(&rec);
2204        jj.at(dir)
2205            .transaction(|tx| async move { tx.new_change("x").await })
2206            .await
2207            .expect("transaction");
2208        assert_eq!(rec.calls()[1].cwd.as_deref(), Some(dir.as_os_str()));
2209    }
2210
2211    // The injection guard: a flag-shaped value in any exposed positional slot
2212    // must be refused BEFORE anything spawns.
2213    #[tokio::test]
2214    async fn flag_like_positionals_are_rejected_before_spawning() {
2215        let rec = RecordingRunner::replying(Reply::ok(""));
2216        let jj = Jj::with_runner(&rec);
2217        let dir = Path::new("/r");
2218
2219        assert!(jj.bookmark_create(dir, "-evil", "@").await.is_err());
2220        assert!(jj.bookmark_rename(dir, "ok", "-bad").await.is_err());
2221        assert!(jj.bookmark_delete(dir, "--all").await.is_err());
2222        assert!(jj.bookmark_move(dir, "-evil", "@", false).await.is_err());
2223        assert!(jj.edit(dir, "-evil").await.is_err());
2224        assert!(jj.duplicate(dir, "-r").await.is_err());
2225        assert!(jj.abandon(dir, "-evil").await.is_err());
2226        // Token-prefix and other bare positionals:
2227        assert!(
2228            jj.bookmark_track(dir, "--config=x", "origin")
2229                .await
2230                .is_err(),
2231            "name leads the {{name}}@{{remote}} token"
2232        );
2233        assert!(jj.bookmark_set(dir, "-evil", "@").await.is_err());
2234        assert!(jj.op_restore(dir, "--help").await.is_err());
2235        assert!(jj.workspace_forget(dir, "-evil").await.is_err());
2236        assert!(
2237            jj.new_merge(dir, "m", vec!["@".into(), "--ignore-working-copy".into()])
2238                .await
2239                .is_err(),
2240            "a flag-shaped parent is refused"
2241        );
2242        assert!(jj.git_clone("-evil", dir, false).await.is_err());
2243        assert!(jj.edit(dir, "").await.is_err(), "empty refused too");
2244        assert!(
2245            rec.calls().is_empty(),
2246            "nothing may spawn: {:?}",
2247            rec.calls()
2248        );
2249
2250        // …and legitimate values still pass through unchanged.
2251        jj.edit(dir, "abc123").await.expect("edit");
2252        assert_eq!(
2253            rec.only_call().args_str(),
2254            ["edit", "abc123", "--color", "never"]
2255        );
2256    }
2257
2258    #[test]
2259    fn revset_expr_validates() {
2260        assert!(RevsetExpr::new("heads(::@ & bookmarks())").is_ok());
2261        assert_eq!(RevsetExpr::new("@-").unwrap().as_str(), "@-");
2262        assert!(RevsetExpr::new("-evil").is_err());
2263        assert!(RevsetExpr::new("").is_err());
2264    }
2265
2266    // capabilities parses jj's version line (incl. dev-build suffixes) and
2267    // gates precisely on the validated 0.38 floor.
2268    #[tokio::test]
2269    async fn capabilities_parse_and_gate_versions() {
2270        let jj = Jj::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("jj 0.38.0\n")));
2271        let caps = jj.capabilities().await.expect("capabilities");
2272        assert!(caps.is_supported());
2273        caps.ensure_supported().expect("supported");
2274
2275        // A dev-build suffix parses; an older release fails the precise gate.
2276        let dev = Jj::with_runner(
2277            ScriptedRunner::new().on(["--version"], Reply::ok("jj 0.39.0-dev+abc123\n")),
2278        );
2279        assert!(dev.capabilities().await.unwrap().is_supported());
2280
2281        let old =
2282            Jj::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("jj 0.35.0\n")));
2283        let caps = old.capabilities().await.expect("capabilities");
2284        assert!(!caps.is_supported());
2285        let err = caps.ensure_supported().expect_err("unsupported");
2286        // The message must name both the floor and the found version.
2287        let Error::Spawn { source, .. } = &err else {
2288            panic!("expected Spawn, got {err:?}");
2289        };
2290        let message = source.to_string();
2291        assert!(message.contains("0.38.0"), "names the floor: {message}");
2292        assert!(
2293            message.contains("0.35.0"),
2294            "names the found version: {message}"
2295        );
2296
2297        let garbage = Jj::with_runner(ScriptedRunner::new().on(["--version"], Reply::ok("nope")));
2298        assert!(matches!(
2299            garbage.capabilities().await.unwrap_err(),
2300            Error::Parse { .. }
2301        ));
2302    }
2303
2304    // git_clone is dir-less; the colocate flag is ALWAYS explicit (jj's default
2305    // varies by version/config) and `--color never` still lands at the very end.
2306    #[tokio::test]
2307    async fn git_clone_builds_dirless_args() {
2308        let rec = RecordingRunner::replying(Reply::ok(""));
2309        let jj = Jj::with_runner(&rec);
2310        jj.git_clone("https://x/r.git", Path::new("/dest"), true)
2311            .await
2312            .expect("clone");
2313        let call = rec.only_call();
2314        assert_eq!(
2315            call.args_str(),
2316            [
2317                "git",
2318                "clone",
2319                "https://x/r.git",
2320                "/dest",
2321                "--colocate",
2322                "--color",
2323                "never"
2324            ]
2325        );
2326        assert_eq!(call.cwd, None, "clone runs without a working directory");
2327
2328        let plain = RecordingRunner::replying(Reply::ok(""));
2329        let jj = Jj::with_runner(&plain);
2330        jj.git_clone("u", Path::new("/d"), false).await.unwrap();
2331        let call = plain.only_call();
2332        assert!(call.has_flag("--no-colocate"), "explicit either way");
2333        assert!(!call.has_flag("--colocate"));
2334    }
2335
2336    #[tokio::test]
2337    async fn absorb_and_split_build_args() {
2338        let rec = RecordingRunner::replying(Reply::ok(""));
2339        let jj = Jj::with_runner(&rec);
2340        jj.absorb(Path::new("/r"), None, &[]).await.unwrap();
2341        jj.absorb(
2342            Path::new("/r"),
2343            Some("@-".into()),
2344            &[JjFileset::path("src/a.rs")],
2345        )
2346        .await
2347        .unwrap();
2348        jj.split_paths(Path::new("/r"), &[JjFileset::path("b.rs")], "split out b")
2349            .await
2350            .unwrap();
2351        jj.duplicate(Path::new("/r"), "@-").await.unwrap();
2352        let calls = rec.calls();
2353        assert_eq!(calls[0].args_str(), ["absorb", "--color", "never"]);
2354        assert_eq!(
2355            calls[1].args_str(),
2356            [
2357                "absorb",
2358                "--from",
2359                "@-",
2360                "file:\"src/a.rs\"",
2361                "--color",
2362                "never"
2363            ]
2364        );
2365        assert_eq!(
2366            calls[2].args_str(),
2367            [
2368                "split",
2369                "-m",
2370                "split out b",
2371                "file:\"b.rs\"",
2372                "--color",
2373                "never"
2374            ]
2375        );
2376        assert_eq!(calls[3].args_str(), ["duplicate", "@-", "--color", "never"]);
2377    }
2378
2379    // An empty split would open jj's interactive diff editor and hang headless —
2380    // it must be refused BEFORE any process spawns.
2381    #[tokio::test]
2382    async fn split_paths_refuses_empty_filesets_without_spawning() {
2383        let rec = RecordingRunner::replying(Reply::ok(""));
2384        let jj = Jj::with_runner(&rec);
2385        let err = jj
2386            .split_paths(Path::new("/r"), &[], "msg")
2387            .await
2388            .expect_err("empty filesets must be refused");
2389        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
2390        assert!(rec.calls().is_empty(), "nothing may spawn");
2391    }
2392
2393    #[tokio::test]
2394    async fn op_log_parses_template_rows() {
2395        let rec = RecordingRunner::new(ScriptedRunner::new().on(
2396            ["op", "log"],
2397            Reply::ok("abc\tu@h\t2026-06-05T10:00:00+0200\tnew empty commit\n"),
2398        ));
2399        let jj = Jj::with_runner(&rec);
2400        let ops = jj.op_log(Path::new("."), 5).await.expect("op_log");
2401        assert_eq!(ops.len(), 1);
2402        assert_eq!(ops[0].id, "abc");
2403        assert_eq!(ops[0].description, "new empty commit");
2404        let args = rec.only_call().args_str();
2405        assert_eq!(&args[..5], &["op", "log", "--no-graph", "--limit", "5"]);
2406    }
2407
2408    // evolog must use the commit-context template (bare `change_id` doesn't
2409    // exist there) but flows through the same Change parser.
2410    #[tokio::test]
2411    async fn evolog_uses_commit_context_template() {
2412        let rec = RecordingRunner::new(
2413            ScriptedRunner::new().on(["evolog"], Reply::ok("kz\t38\tfalse\twip\n")),
2414        );
2415        let jj = Jj::with_runner(&rec);
2416        let rows = jj.evolog(Path::new("."), "@", 10).await.expect("evolog");
2417        assert_eq!(rows.len(), 1);
2418        assert_eq!(rows[0].description, "wip");
2419        let args = rec.only_call().args_str();
2420        assert_eq!(
2421            &args[..6],
2422            &["evolog", "-r", "@", "--no-graph", "--limit", "10"]
2423        );
2424        let template = &args[7];
2425        assert!(
2426            template.contains("commit.change_id()"),
2427            "commit-context form required, got {template}"
2428        );
2429    }
2430
2431    #[tokio::test]
2432    async fn file_annotate_and_show_build_args() {
2433        let rec = RecordingRunner::new(
2434            ScriptedRunner::new()
2435                .on(
2436                    ["file", "annotate"],
2437                    Reply::ok("kz\tline one\nkz\tline two"),
2438                )
2439                .on(["file", "show"], Reply::ok("content\n")),
2440        );
2441        let jj = Jj::with_runner(&rec);
2442        let lines = jj
2443            .file_annotate(Path::new("."), "src/a.rs", Some("@-".into()))
2444            .await
2445            .expect("annotate");
2446        assert_eq!(lines.len(), 2);
2447        assert_eq!(lines[0].change_id, "kz");
2448        assert_eq!(lines[1].line, 2);
2449        assert_eq!(
2450            jj.file_show(Path::new("."), "@-", "src/a.rs")
2451                .await
2452                .unwrap(),
2453            "content"
2454        );
2455        let calls = rec.calls();
2456        // The path follows a `--` separator (a leading-`-` filename stays safe);
2457        // `--color never` must precede `--`, not trail it.
2458        assert_eq!(
2459            calls[0].args_str(),
2460            [
2461                "file",
2462                "annotate",
2463                "-r",
2464                "@-",
2465                "-T",
2466                parse::ANNOTATE_TEMPLATE,
2467                "--color",
2468                "never",
2469                "--",
2470                "src/a.rs"
2471            ]
2472        );
2473        // file_show wraps the path as an exact-path fileset (metacharacters in
2474        // the name must stay literal); annotate takes a PLAIN path — quoting
2475        // it would break jj's path lookup.
2476        assert_eq!(
2477            calls[1].args_str(),
2478            [
2479                "file",
2480                "show",
2481                "-r",
2482                "@-",
2483                "file:\"src/a.rs\"",
2484                "--color",
2485                "never"
2486            ]
2487        );
2488    }
2489
2490    // `description` is a fixed template query: first match only, raw description.
2491    #[tokio::test]
2492    async fn description_builds_single_commit_template_query() {
2493        let rec = RecordingRunner::replying(Reply::ok("feat: parser\n\nbody\n"));
2494        let jj = Jj::with_runner(&rec);
2495        let text = jj
2496            .description(Path::new("."), "abc123")
2497            .await
2498            .expect("description");
2499        assert_eq!(text, "feat: parser\n\nbody");
2500        assert_eq!(
2501            rec.only_call().args_str(),
2502            [
2503                "log",
2504                "-r",
2505                "abc123",
2506                "--no-graph",
2507                "--limit",
2508                "1",
2509                "-T",
2510                "description",
2511                "--color",
2512                "never"
2513            ]
2514        );
2515    }
2516
2517    // `diff_text` for the working copy must build `diff -r @ --git`.
2518    #[tokio::test]
2519    async fn diff_text_builds_working_copy_args() {
2520        let rec = RecordingRunner::replying(Reply::ok(""));
2521        let jj = Jj::with_runner(&rec);
2522        jj.diff_text(Path::new("."), DiffSpec::WorkingTree)
2523            .await
2524            .expect("diff_text");
2525        assert_eq!(
2526            rec.only_call().args_str(),
2527            ["diff", "-r", "@", "--git", "--color", "never"]
2528        );
2529    }
2530
2531    // Every repo-scoped command forces `--color never` so a user's
2532    // `ui.color = "always"` config can't wrap parsed output in ANSI escapes.
2533    #[tokio::test]
2534    async fn commands_force_color_off() {
2535        let rec = RecordingRunner::replying(Reply::ok("x\n"));
2536        let jj = Jj::with_runner(&rec);
2537        jj.status_text(Path::new(".")).await.expect("status_text");
2538        let args = rec.only_call().args_str();
2539        let pos = args.iter().position(|a| a == "--color");
2540        assert_eq!(
2541            pos.map(|p| args.get(p + 1).map(String::as_str)),
2542            Some(Some("never"))
2543        );
2544    }
2545
2546    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
2547    // canned git-format output.
2548    #[tokio::test]
2549    async fn diff_parses_scripted_output() {
2550        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
2551        let jj = Jj::with_runner(ScriptedRunner::new().on(["diff"], Reply::ok(out)));
2552        let files = jj
2553            .diff(Path::new("."), DiffSpec::Rev("@-".into()))
2554            .await
2555            .expect("diff");
2556        assert_eq!(files.len(), 1);
2557        assert_eq!(files[0].path, "m");
2558        assert_eq!(files[0].change, ChangeKind::Modified);
2559    }
2560
2561    #[cfg(feature = "mock")]
2562    #[tokio::test]
2563    async fn consumer_mocks_the_interface() {
2564        let mut mock = MockJjApi::new();
2565        mock.expect_describe().returning(|_, _| Ok(()));
2566        assert!(mock.describe(Path::new("."), "msg").await.is_ok());
2567    }
2568}
2569
2570// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
2571#[doc = include_str!("../docs/jj.md")]
2572#[allow(rustdoc::broken_intra_doc_links)]
2573pub mod guide {}