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//! You call typed `async` methods; `vcs-jj` runs the real `jj`, parses its
6//! templated output, and hands you structured values — so you get *jj's own*
7//! behaviour and config, not a reimplementation of the operation log or backend.
8//! Async, structured errors, mockable. Every command runs inside an OS **job** (an
9//! OS-level container that kills the whole process tree if your program exits, via
10//! [`processkit`]) so a `jj` subprocess is never orphaned, with an optional
11//! per-client [timeout](Jj::default_timeout).
12//!
13//! # What you can do
14//!
15//! Working-copy status & the change log · describe / new change · bookmarks · the
16//! operation log (restore / undo — jj's safety net) · workspaces · squash / split /
17//! absorb / duplicate / abandon · diff & template queries · git sync (fetch / push
18//! / clone / import) · parse & resolve jj's native conflict markers · transactions
19//! that roll the op log back on error. One tiny call to start:
20//!
21//! ```no_run
22//! use std::path::Path;
23//! use vcs_jj::{Jj, JjApi};
24//! # async fn demo() -> Result<(), processkit::Error> {
25//! let jj = Jj::new();
26//! // the working-copy change `@`:
27//! println!("{}", jj.current_change(Path::new(".")).await?.change_id);
28//! # Ok(()) }
29//! ```
30//!
31//! # The surface (engineering reference)
32//!
33//! - **[`JjApi`]** — the object-safe trait every operation lives on. Depend on
34//!   `&dyn JjApi` (or generically on `impl JjApi`) so a test can swap the real
35//!   client for a double. Most methods take the working directory as the first
36//!   argument and return typed results ([`Change`], [`Bookmark`],
37//!   [`BookmarkRef`], [`Operation`], [`Workspace`], [`ChangedPath`],
38//!   [`FileDiff`], [`AnnotationLine`], …) or a structured [`Error`]. The groups:
39//!   changes ([`status`](JjApi::status), [`log`](JjApi::log),
40//!   [`describe`](JjApi::describe), [`new_change`](JjApi::new_change)),
41//!   bookmarks ([`bookmarks`](JjApi::bookmarks),
42//!   [`bookmark_create`](JjApi::bookmark_create),
43//!   [`bookmark_move`](JjApi::bookmark_move), …), the operation log
44//!   ([`op_log`](JjApi::op_log), [`op_head`](JjApi::op_head),
45//!   [`op_restore`](JjApi::op_restore), [`op_undo`](JjApi::op_undo)),
46//!   diff/query ([`diff`](JjApi::diff), [`diff_stat`](JjApi::diff_stat),
47//!   [`evolog`](JjApi::evolog), [`file_annotate`](JjApi::file_annotate),
48//!   [`template_query`](JjApi::template_query)), mutations
49//!   ([`rebase`](JjApi::rebase), [`squash_paths`](JjApi::squash_paths),
50//!   [`split_paths`](JjApi::split_paths), [`absorb`](JjApi::absorb),
51//!   [`abandon`](JjApi::abandon)), git sync
52//!   ([`git_fetch`](JjApi::git_fetch), [`git_push`](JjApi::git_push),
53//!   [`git_clone`](JjApi::git_clone), [`git_import`](JjApi::git_import)), and
54//!   workspaces ([`workspace_list`](JjApi::workspace_list),
55//!   [`workspace_root`](JjApi::workspace_root),
56//!   [`workspace_add`](JjApi::workspace_add)).
57//! - **[`Jj`]** — the real client. [`Jj::new`] uses the job-backed runner;
58//!   [`Jj::with_runner`] injects a fake one for tests. It is generic over the
59//!   [`ProcessRunner`] seam, defaulting to the production runner.
60//! - **[`JjAt`]** — a cwd-bound view ([`Jj::at`]) whose methods drop the leading
61//!   `dir`, so `jj.at(dir).status()` reads as `jj.status(dir)` — handy when one
62//!   client drives one checkout.
63//! - **[`Jj::transaction`]** — run a mutation sequence with concurrency-safe op-log
64//!   rollback: capture the current operation, run a closure, and on `Err` restore
65//!   the repo to it ([`Jj::rollback_to`]) — a rollback that survives a cancelled
66//!   closure and refuses to clobber a concurrent process's work, reporting the
67//!   outcome on [`TransactionError`] / [`Rollback`]. The op log is jj's safety net;
68//!   this wraps it as a scope. [`Jj::workspace_roots`] is a sibling inherent method
69//!   — a bounded fan-out resolving many workspace roots at once.
70//! - **Builder specs** for the multi-option commands — [`WorkspaceAdd`],
71//!   [`SquashPaths`], [`BookmarkMove`], [`SquashInto`], [`GitClone`] — each
72//!   `#[non_exhaustive]`, built with a constructor +
73//!   chained setters, named after the flags they emit. [`JjFileset`] wraps a
74//!   workspace-root-relative path as an exact-path `root-file:"…"` fileset;
75//!   [`RevsetExpr`] is an optional up-front-validated revset newtype for untrusted input.
76//! - **[`conflict`]** — a typed model of jj's *native* conflict markers (the
77//!   `diff`/`snapshot` styles): parse a materialized file into structured
78//!   regions, re-render byte-exact, and resolve to a chosen side. (Files
79//!   materialized in the `git` style are parsed by `vcs_git::conflict` instead.)
80//! - **[`capabilities`](JjApi::capabilities)** — probe the installed binary's
81//!   version against this crate's validated floor (jj ≥ 0.38); see
82//!   [`JjCapabilities`].
83//!
84//! There is deliberately **no `Jj::hardened()`** counterpart to vcs-git's
85//! untrusted-repo profile: jj has no repo-local hooks, and its config comes from
86//! the user/repo TOML files jj itself trusts. In a *colocated* repo the risk
87//! lives on the git side — git hooks fire when **git** commands run there, so
88//! harden the `Git` client you point at it.
89//!
90//! # Recipes
91//!
92//! Read state — depend on the trait so the same code takes a real client or a mock:
93//!
94//! ```no_run
95//! use std::path::Path;
96//! use vcs_jj::{Jj, JjApi};
97//! # async fn demo() -> Result<(), processkit::Error> {
98//! let jj = Jj::new();
99//! let dir = Path::new(".");
100//! let current = jj.current_change(dir).await?;       // the working-copy change `@`
101//! let dirty = !jj.status(dir).await?.is_empty();     // any working-copy edit?
102//! # let _ = (current, dirty); Ok(()) }
103//! ```
104//!
105//! Mutate inside a [`transaction`](Jj::transaction) — an `Err` rolls the op log
106//! back (safely: the cleanup survives a cancelled closure and refuses to clobber a
107//! concurrent process's work — see [`TransactionError`] / [`Rollback`]):
108//!
109//! ```no_run
110//! use std::path::Path;
111//! use vcs_jj::Jj;
112//! # async fn demo(jj: &Jj) -> Result<(), vcs_jj::TransactionError> {
113//! let dir = Path::new(".");
114//! jj.transaction(dir, |tx| async move {
115//!     tx.describe("wip").await?;
116//!     tx.new_change("next").await        // an Err here undoes the describe
117//! })
118//! .await?;
119//! # Ok(()) }
120//! ```
121//!
122//! A binding (or any caller that can't pass a Rust closure) drives the same
123//! rollback imperatively with the primitives [`transaction`](Jj::transaction)
124//! wraps — [`op_head`](JjApi::op_head) to capture a savepoint and
125//! [`rollback_to`](Jj::rollback_to) to roll back to it on failure with the same
126//! cancellation-safe, divergence-checked protocol.
127//!
128//! # Testing
129//!
130//! Two seams: enable the **`mock`** feature for a `mockall`-generated
131//! `MockJjApi` (stub whole methods), or inject a
132//! [`ScriptedRunner`](processkit::testing::ScriptedRunner) with [`Jj::with_runner`] to
133//! exercise the *real* argv-building and parsing against canned output. The
134//! cross-cutting testing patterns live in
135//! [vcs-testkit's guide](https://docs.rs/vcs-testkit/latest/vcs_testkit/guide/testing/).
136//!
137//! # Safety
138//!
139//! Every caller value placed in a bare positional argv slot (bookmark name,
140//! revset, operation id, merge parent, …) is refused before spawning if it is
141//! empty or starts with `-` (jj would parse it as a flag); flag-value slots
142//! (`-r <revset>`, `-m <msg>`) and the `run`/`run_raw` escape hatches are not
143//! guarded. For eager validation at an input boundary, [`RevsetExpr`] validates
144//! up front. Paths go through the exact-path [`JjFileset`] form.
145//!
146//! A concrete instance of the flag-value-slot rule: [`DiffSpec::Rev`] on
147//! `diff_text`/`diff` (`diff_text_budgeted`) is a bare `String` from the
148//! shared `vcs-diff` crate, passed verbatim into `-r <revset>` — unguarded
149//! here, same as any other flag-value slot, and rejected by `jj` itself if it
150//! starts with `-`.
151//!
152//! # In-depth guide
153//!
154//! Beyond this page, this crate ships a full how-to guide — rendered on docs.rs
155//! from `docs/`. See the [`guide`] module. The conflict model is covered by
156//! [vcs-git's conflicts guide](https://docs.rs/vcs-git/latest/vcs_git/guide/conflicts/),
157//! which spans both backends.
158
159use std::future::Future;
160use std::path::{Path, PathBuf};
161use std::time::Duration;
162
163// Re-export the processkit types in this crate's public API, so consumers needn't
164// depend on processkit directly — incl. `ProcessRunner` (the `with_runner`/`Jj<R>`
165// seam) and the `JobRunner` default. (Also brings `Error`/`Result`/`ProcessResult`/
166// `ProcessRunner` into scope here.)
167pub use processkit::{Error, JobRunner, ProcessResult, ProcessRunner, Result};
168// Re-exported so a consumer can name the token for `default_cancel_on` without
169// taking a direct `processkit` dependency.
170pub use processkit::CancellationToken;
171
172pub mod conflict;
173mod parse;
174pub use parse::{AnnotationLine, Bookmark, BookmarkRef, Change, ChangedPath, Operation, Workspace};
175// The git-format diff model + parser and the version type are shared with
176// `vcs-git` (identical output) — re-exported so `vcs_jj::FileDiff`,
177// `vcs_jj::parse_diff`, `vcs_jj::JjVersion`, … still resolve.
178pub use vcs_diff::{
179    ChangeKind, DiffLine, DiffSpec, DiffStat, FileDiff, Hunk, Version as JjVersion, parse_diff,
180};
181// The error classifiers live in the shared plumbing crate — re-exported so
182// `vcs_jj::is_transient_fetch_error`, `vcs_jj::is_lock_contention` still resolve.
183pub use vcs_cli_support::{
184    OutputBudget, RetryPolicy, is_lock_contention, is_transient_fetch_error,
185};
186
187/// Name of the underlying CLI binary this crate drives.
188pub const BINARY: &str = "jj";
189
190/// How a new workspace inherits sparse patterns (`jj workspace add
191/// --sparse-patterns <mode>`).
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193#[non_exhaustive]
194pub enum SparseMode {
195    /// Copy all sparse patterns from the current workspace (jj's default).
196    Copy,
197    /// Include every file in the new workspace.
198    Full,
199    /// Start with no files — the caller sets patterns afterwards (CoW flow).
200    Empty,
201}
202
203impl SparseMode {
204    /// The `--sparse-patterns` value jj expects.
205    fn as_arg(self) -> &'static str {
206        match self {
207            SparseMode::Copy => "copy",
208            SparseMode::Full => "full",
209            SparseMode::Empty => "empty",
210        }
211    }
212}
213
214/// An exact-path jj fileset (`root-file:"<path>"`), so path metacharacters like `(`,
215/// `)`, `|`, `*` are treated literally rather than as fileset operators.
216///
217/// Build it with [`JjFileset::path`]; the path is **workspace-root-relative** and
218/// resolved as such regardless of the command's working directory.
219#[derive(Debug, Clone, PartialEq, Eq)]
220pub struct JjFileset(String);
221
222impl JjFileset {
223    /// Wrap a workspace-root-relative `path` as an exact-path fileset. Uses jj's
224    /// **`root-file:`** anchor (not the cwd-relative `file:`), so the path is
225    /// interpreted relative to the workspace root even when the command runs from a
226    /// subdirectory (`dir` ≠ root) — a plain `file:` there would silently target a
227    /// same-named file under `dir`, or nothing (M2). **On Windows** the caller's `\`
228    /// path separators are normalised to jj's forward slash (so `src\a.rs` matches);
229    /// **on Unix** `\` is a legitimate filename byte and is left intact — rewriting it
230    /// there would corrupt a real path (matching `vcs-git`'s twin, which also gates
231    /// the rewrite on Windows). Then `\` and `"` are escaped for the string literal.
232    pub fn path(path: impl AsRef<str>) -> Self {
233        let path = path.as_ref();
234        #[cfg(windows)]
235        let normalised = path.replace('\\', "/");
236        #[cfg(not(windows))]
237        let normalised = path.to_string();
238        let escaped = normalised.replace('\\', "\\\\").replace('"', "\\\"");
239        JjFileset(format!("root-file:\"{escaped}\""))
240    }
241
242    /// The rendered `root-file:"…"` expression.
243    pub fn as_str(&self) -> &str {
244        &self.0
245    }
246}
247
248/// Options for [`JjApi::workspace_add`] (`jj workspace add`).
249///
250/// `#[non_exhaustive]`, so build it through [`WorkspaceAdd::new`].
251#[derive(Debug, Clone)]
252#[non_exhaustive]
253pub struct WorkspaceAdd {
254    /// Name for the new workspace.
255    pub name: String,
256    /// Revision the workspace's working copy starts at (`-r <base>`).
257    pub base: RevsetExpr,
258    /// Filesystem path for the new workspace.
259    pub path: PathBuf,
260    /// How to seed the new workspace's sparse patterns (`--sparse-patterns`);
261    /// `None` leaves jj's default (inherit from the current workspace).
262    pub sparse_patterns: Option<SparseMode>,
263}
264
265impl WorkspaceAdd {
266    /// A workspace named `name`, based at `base`, materialised at `path`.
267    pub fn new(name: impl Into<String>, base: RevsetExpr, path: impl Into<PathBuf>) -> Self {
268        Self {
269            name: name.into(),
270            base,
271            path: path.into(),
272            sparse_patterns: None,
273        }
274    }
275
276    /// Seed the new workspace's sparse patterns with `mode` (`--sparse-patterns`).
277    pub fn sparse(mut self, mode: SparseMode) -> Self {
278        self.sparse_patterns = Some(mode);
279        self
280    }
281}
282
283/// Options for [`JjApi::squash_paths`] (`jj squash --from <from> --into <into>
284/// [--use-destination-message] <filesets>`).
285///
286/// `#[non_exhaustive]`, so build it through [`SquashPaths::new`] and the chained
287/// setters rather than a struct literal.
288#[derive(Debug, Clone)]
289#[non_exhaustive]
290pub struct SquashPaths {
291    /// Source revision the filesets are squashed out of (`--from`).
292    pub from: RevsetExpr,
293    /// Destination revision the filesets are squashed into (`--into`).
294    pub into: RevsetExpr,
295    /// The exact filesets to move; empty squashes the whole `from` change.
296    pub filesets: Vec<JjFileset>,
297    /// Keep the destination's description rather than combining the two
298    /// (`--use-destination-message`).
299    pub use_destination_message: bool,
300}
301
302impl SquashPaths {
303    /// Squash from `from` into `into`, with no filesets selected yet.
304    pub fn new(from: RevsetExpr, into: RevsetExpr) -> Self {
305        Self {
306            from,
307            into,
308            filesets: Vec::new(),
309            use_destination_message: false,
310        }
311    }
312
313    /// Set the filesets to move (replacing any already added).
314    pub fn filesets(mut self, filesets: impl IntoIterator<Item = JjFileset>) -> Self {
315        self.filesets = filesets.into_iter().collect();
316        self
317    }
318
319    /// Keep the destination's description (`--use-destination-message`) instead
320    /// of combining the two.
321    pub fn use_destination_message(mut self) -> Self {
322        self.use_destination_message = true;
323        self
324    }
325}
326
327/// Options for [`JjApi::bookmark_move`] (`jj bookmark move <name> --to <rev>`).
328///
329/// `#[non_exhaustive]`, so build it through [`BookmarkMove::new`] and the chained
330/// [`allow_backwards`](BookmarkMove::allow_backwards) setter rather than a bare
331/// `bool` (`bookmark_move(name, to, true)` doesn't say what `true` permits).
332#[derive(Debug, Clone, PartialEq, Eq)]
333#[non_exhaustive]
334pub struct BookmarkMove {
335    /// The bookmark to move.
336    pub name: BookmarkName,
337    /// The revision to move it to (`--to`).
338    pub to: RevsetExpr,
339    /// Allow moving the bookmark to a commit that is not a descendant of its
340    /// current target (`--allow-backwards`).
341    pub allow_backwards: bool,
342}
343
344impl BookmarkMove {
345    /// Move bookmark `name` to revision `to`; a backwards move is refused.
346    pub fn new(name: BookmarkName, to: RevsetExpr) -> Self {
347        Self {
348            name,
349            to,
350            allow_backwards: false,
351        }
352    }
353
354    /// Allow moving to a commit that is not a descendant of the current target
355    /// (`--allow-backwards`).
356    pub fn allow_backwards(mut self) -> Self {
357        self.allow_backwards = true;
358        self
359    }
360}
361
362/// Options for [`JjApi::squash_into`] (`jj squash --into <rev>`).
363///
364/// `#[non_exhaustive]`, so build it through [`SquashInto::new`] and the chained
365/// [`use_destination_message`](SquashInto::use_destination_message) setter rather
366/// than a bare `bool`.
367#[derive(Debug, Clone, PartialEq, Eq)]
368#[non_exhaustive]
369pub struct SquashInto {
370    /// The destination revision the working copy is squashed into (`--into`).
371    pub into: RevsetExpr,
372    /// Keep the destination's description rather than combining the two
373    /// (`--use-destination-message`).
374    pub use_destination_message: bool,
375}
376
377impl SquashInto {
378    /// Squash the working copy into `into`, combining the two descriptions.
379    pub fn new(into: RevsetExpr) -> Self {
380        Self {
381            into,
382            use_destination_message: false,
383        }
384    }
385
386    /// Keep the destination's description (`--use-destination-message`) instead
387    /// of combining the two.
388    pub fn use_destination_message(mut self) -> Self {
389        self.use_destination_message = true;
390        self
391    }
392}
393
394/// Colocation choice for [`JjApi::git_clone`] (`jj git clone
395/// --colocate|--no-colocate`).
396///
397/// The flag is **always** passed explicitly — jj's default flipped across versions
398/// and is overridable via `git.colocate` config — so there is deliberately no
399/// default: pick [`GitClone::colocated`] or [`GitClone::separate`].
400/// `#[non_exhaustive]`.
401#[derive(Debug, Clone, PartialEq, Eq)]
402#[non_exhaustive]
403pub struct GitClone {
404    /// Create a visible `.git` alongside `.jj` (`--colocate`) rather than a
405    /// jj-only checkout (`--no-colocate`).
406    pub colocate: bool,
407}
408
409impl GitClone {
410    /// A colocated clone — a visible `.git` beside `.jj` (`--colocate`).
411    pub fn colocated() -> Self {
412        Self { colocate: true }
413    }
414
415    /// A non-colocated clone — jj-only, no `.git` (`--no-colocate`).
416    pub fn separate() -> Self {
417        Self { colocate: false }
418    }
419}
420
421/// The first bookmark name from a [`BOOKMARKS_TEMPLATE`](parse::BOOKMARKS_TEMPLATE)
422/// render (space-joined `.escape_json()` names), decoded; `None` when the commit
423/// carries no local bookmark. Delegates to [`parse::first_bookmark_name`] so the
424/// escaping contract lives in one place.
425fn first_bookmark(rendered: &str) -> Option<String> {
426    parse::first_bookmark_name(rendered)
427}
428
429/// Injection guard for bare positional argv slots: a caller-supplied value
430/// with a leading `-` is parsed by jj's CLI as a *flag* (verified: `jj edit
431/// -evil` → "unexpected argument"), and an empty value changes a command's
432/// meaning. Refuse both before anything spawns. Flag-VALUE positions
433/// (`-r <revset>`, `-m <msg>`) need no guard — jj itself rejects dash-values
434/// there with a clear error rather than misparsing them.
435fn reject_flag_like(what: &str, value: &str) -> Result<()> {
436    vcs_cli_support::reject_flag_like(BINARY, what, value)
437}
438
439/// The working-copy revset `@` as a validated [`RevsetExpr`]. Infallible — `@`
440/// is always a valid revset — for the internal helpers that query `@` directly.
441fn at_revset() -> RevsetExpr {
442    RevsetExpr::new("@").expect("`@` is a valid revset")
443}
444
445/// Wrap a caller-supplied bookmark/branch/remote name as jj's `exact:` string
446/// pattern. jj treats a bare `<NAMES>` / `-b <BOOKMARK>` / `--remote <REMOTE>`
447/// argument as a **glob** pattern (verified on 0.42: `bookmark delete '*'`
448/// deletes every bookmark; `git push -b '*'` pushes them all), so a name that
449/// happens to contain `*`/`?` — or a hostile `"*"` from a UI/bot — would fan the
450/// operation out across every matching ref. `exact:` forces a literal match of
451/// exactly this name (verified: `exact:foo1` deletes only `foo1`, and a literal
452/// `*` in a name is matched verbatim under `exact:`), so these typed methods
453/// mutate exactly the one ref the caller named.
454fn exact(name: &str) -> String {
455    format!("exact:{name}")
456}
457
458/// Injection guard for the remote segment of jj's positional `<name>@<remote>`
459/// bookmark-tracking pattern. Unlike a bare `<NAMES>`/`--remote` slot, the
460/// remote segment of this composite form is **not** itself parsed as a
461/// string-pattern: a `exact:`/`glob:` prefix on it is taken as part of the
462/// *literal* remote name instead of being interpreted (verified on jj 0.42:
463/// `bookmark track exact:main@exact:origin` warns "No matching remote
464/// bookmarks for names: main@\"exact:origin\"" and tracks nothing — a silent
465/// no-op, not an error — and `main@glob:origin` is rejected outright with
466/// "remote bookmark must be specified in bookmark@remote form"). The segment
467/// is, however, still glob-matched positionally (`main@ori?in` tracks
468/// `origin`), so a hostile/glob-bearing remote name must be rejected before
469/// spawn rather than wrapped in `exact:`.
470fn reject_glob_like(what: &str, value: &str) -> Result<()> {
471    if value.contains(['*', '?', '[', ']']) {
472        return Err(Error::spawn(
473            BINARY,
474            std::io::Error::new(
475                std::io::ErrorKind::InvalidInput,
476                format!(
477                    "{what} {value:?} contains a glob metacharacter and could fan out across \
478                     remotes — refusing to pass it as a positional argument"
479                ),
480            ),
481        ));
482    }
483    Ok(())
484}
485
486/// Pin `LC_ALL=C` on a command whose failure output is classified by matching
487/// **untranslated English substrings** — the transient-fetch markers
488/// (`is_transient_fetch_error`). jj's `git fetch` surfaces libc/gai/curl network
489/// errors ("Temporary failure in name resolution"), which a localized environment
490/// would translate — silently turning a retryable transient failure into an
491/// unclassified one that is *not* retried. Mirrors `vcs-git`'s `c_locale`.
492fn c_locale(cmd: processkit::Command) -> processkit::Command {
493    cmd.env("LC_ALL", "C")
494}
495
496/// A validated revset expression. Every [`JjApi`] operation that resolves a
497/// revision/revset takes a `RevsetExpr` (directly or inside its options struct),
498/// so a revset from untrusted input (UIs, bots, agents) is validated once, at
499/// construction, and the type is the flag-injection barrier from then on.
500/// Deliberately *minimal* — jj's revset grammar is too rich to validate here —
501/// it only guarantees the expression is non-empty and cannot be parsed as a flag
502/// (no leading `-`). A rejected expression is an [`vcs_cli_support::is_invalid_input`]
503/// failure. For a value that must be a bookmark **name** (create/move/delete a
504/// bookmark) use [`BookmarkName`].
505#[derive(Debug, Clone, PartialEq, Eq, Hash)]
506pub struct RevsetExpr(String);
507
508impl RevsetExpr {
509    /// Validate `revset` (non-empty, no leading `-`).
510    pub fn new(revset: impl Into<String>) -> Result<Self> {
511        let revset = revset.into();
512        reject_flag_like("revset", &revset)?;
513        Ok(RevsetExpr(revset))
514    }
515
516    /// The validated expression.
517    pub fn as_str(&self) -> &str {
518        &self.0
519    }
520}
521
522impl std::fmt::Display for RevsetExpr {
523    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
524        f.write_str(&self.0)
525    }
526}
527
528impl std::str::FromStr for RevsetExpr {
529    type Err = Error;
530    fn from_str(s: &str) -> Result<Self> {
531        Self::new(s)
532    }
533}
534
535/// A validated jj bookmark name (jj's equivalent of a git branch). Every
536/// [`JjApi`] operation that names a bookmark to create, move, rename, delete,
537/// track, fetch, or push takes a `BookmarkName`, so a name from untrusted input
538/// is validated once, at construction. jj bookmark names are permissive, so the
539/// guarantee is the load-bearing one: non-empty and not flag-shaped (no leading
540/// `-`), matching the injection guard these operations applied internally before.
541/// The typed methods additionally wrap the name in jj's `exact:` string pattern
542/// so a `*`/`?` in a name can never fan the operation out across every bookmark.
543/// A rejected name is an [`vcs_cli_support::is_invalid_input`] failure.
544#[derive(Debug, Clone, PartialEq, Eq, Hash)]
545pub struct BookmarkName(String);
546
547impl BookmarkName {
548    /// Validate `name` as a bookmark name (non-empty, no leading `-`).
549    pub fn new(name: impl Into<String>) -> Result<Self> {
550        let name = name.into();
551        reject_flag_like("bookmark name", &name)?;
552        Ok(BookmarkName(name))
553    }
554
555    /// The validated name.
556    pub fn as_str(&self) -> &str {
557        &self.0
558    }
559}
560
561impl std::fmt::Display for BookmarkName {
562    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
563        f.write_str(&self.0)
564    }
565}
566
567impl std::str::FromStr for BookmarkName {
568    type Err = Error;
569    fn from_str(s: &str) -> Result<Self> {
570        Self::new(s)
571    }
572}
573
574/// What the installed `jj` binary supports, probed via
575/// [`JjApi::capabilities`]. A value type — the client holds no state, so probe
576/// once and keep the result (callers cache it).
577#[derive(Debug, Clone, Copy, PartialEq, Eq)]
578#[non_exhaustive]
579pub struct JjCapabilities {
580    /// The binary's parsed version.
581    pub version: JjVersion,
582}
583
584/// The validated jj floor: every parser and flag in this crate was verified
585/// empirically against this release. jj's CLI moves fast, so the floor is a full
586/// version pinned to a validated release; vcs-git instead gates on the highest
587/// version its own argv requires (`2.31`).
588const MIN_SUPPORTED: JjVersion = JjVersion {
589    major: 0,
590    minor: 38,
591    patch: 0,
592};
593
594impl JjCapabilities {
595    /// Whether the binary meets the validated floor (jj ≥ 0.38).
596    pub fn is_supported(&self) -> bool {
597        self.version >= MIN_SUPPORTED
598    }
599
600    /// Error unless [`is_supported`](Self::is_supported) — a clear "needs jj
601    /// ≥ 0.38, found 0.35.0" instead of a cryptic argv/template failure later.
602    pub fn ensure_supported(&self) -> Result<()> {
603        if self.is_supported() {
604            return Ok(());
605        }
606        Err(Error::spawn(
607            BINARY,
608            std::io::Error::new(
609                std::io::ErrorKind::Unsupported,
610                format!(
611                    "vcs-jj requires jj >= {MIN_SUPPORTED} (the validated floor), found {}",
612                    self.version
613                ),
614            ),
615        ))
616    }
617}
618
619/// The jj operations this crate exposes — the interface consumers code against
620/// and mock in tests.
621///
622/// **Injection safety:** bookmark names and revsets are taken as the validated
623/// [`BookmarkName`] / [`RevsetExpr`] newtypes (directly or inside an options
624/// struct), so a flag-like or malformed value is rejected at construction,
625/// before it can reach an argv slot. The remaining caller-supplied bare
626/// positionals that are *not* bookmarks/revsets — remote names and operation
627/// ids — keep an internal guard: a value that is empty or begins with `-` is
628/// rejected with an [`Error::Spawn`] *before* spawning. Flag-value slots
629/// (`-m <msg>`) and the `run`/`run_raw` escape hatches are not guarded.
630#[cfg_attr(feature = "mock", mockall::automock)]
631#[async_trait::async_trait]
632pub trait JjApi: Send + Sync {
633    /// Run `jj <args>` **in the process's current directory**, returning trimmed
634    /// stdout (throws on a non-zero exit).
635    ///
636    /// **Unguarded escape hatch — you own its safety.** `args` is forwarded
637    /// verbatim, so never pass untrusted tokens here: jj's `--config`/
638    /// `--config-toml` and user-defined aliases can reach code execution. The
639    /// guarded typed methods are the safe path.
640    ///
641    /// This method on the client is the **process-cwd** escape hatch; the
642    /// `at(dir)` bound view's [`run`](JjAt::run) is instead **bound to `dir`** (it
643    /// forwards to [`Jj::run_in`], so `jj.at(dir).run(…)` runs in the bound repo).
644    /// Use `jj.at(dir).run(…)` (or [`Jj::run_in`]) for the bound repo (T-035).
645    async fn run(&self, args: &[String]) -> Result<String>;
646    /// Like [`JjApi::run`] but never errors on a non-zero exit — returns the
647    /// captured [`ProcessResult`]. Same unguarded-escape-hatch caveat as
648    /// [`run`](JjApi::run): never forward untrusted argv.
649    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;
650    /// Installed Jujutsu version (`jj --version`).
651    async fn version(&self) -> Result<String>;
652    /// The installed binary's parsed version, as [`JjCapabilities`]
653    /// (`jj --version`). A value type — probe once and keep it; an
654    /// unrecognisable version string is an [`Error::Parse`].
655    async fn capabilities(&self) -> Result<JjCapabilities>;
656    /// Parsed working-copy changes — the files changed in `@`
657    /// (`jj diff -r @ --summary`), mirroring `vcs_git` `status`.
658    ///
659    /// Like every ordinary jj command, this **snapshots the working copy first**
660    /// (recording a new operation, possibly moving `@`); for a read-only probe
661    /// that must not perturb the repo, use
662    /// [`status_ignoring_working_copy`](JjApi::status_ignoring_working_copy).
663    async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>>;
664    /// [`status`](JjApi::status) as a **read-only** query: adds
665    /// `--ignore-working-copy`, so it reports the working-copy changes of the
666    /// **last recorded operation** without snapshotting — no new operation is
667    /// recorded and `@` never moves. A bare filesystem edit that jj has not yet
668    /// snapshotted is therefore **not** reflected — that is the read-only
669    /// trade-off. Built for an observer (a repo watcher / prompt) that must not
670    /// mutate the state it reads.
671    async fn status_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<ChangedPath>>;
672    /// Raw `jj status` text (human-readable) — the unparsed counterpart of
673    /// [`status`](JjApi::status), mirroring `vcs_git` `status_text`.
674    async fn status_text(&self, dir: &Path) -> Result<String>;
675    /// Changes matching `revset`, newest first, up to `max` (`jj log`).
676    async fn log(&self, dir: &Path, revset: &RevsetExpr, max: usize) -> Result<Vec<Change>>;
677    /// Like [`log`](JjApi::log), but scoped to changes that touched `filesets`
678    /// (`jj log -r <revset> <filesets>`) — e.g. "who changed this module".
679    /// Build filesets with [`JjFileset::path`] (same primitive as
680    /// [`commit_paths`](JjApi::commit_paths)/[`squash_paths`](JjApi::squash_paths)).
681    /// An empty `filesets` is refused *before spawning*: silently falling back
682    /// to [`log`](JjApi::log)'s unrestricted history would defeat the "scoped
683    /// to these paths" contract. Mirrors
684    /// [`GitApi::log_paths`](../vcs_git/trait.GitApi.html#tymethod.log_paths),
685    /// which takes pathspecs instead of filesets.
686    async fn log_paths(
687        &self,
688        dir: &Path,
689        revset: &RevsetExpr,
690        max: usize,
691        filesets: &[JjFileset],
692    ) -> Result<Vec<Change>>;
693    /// The working-copy change (`jj log -r @`).
694    async fn current_change(&self, dir: &Path) -> Result<Change>;
695    /// Set the working-copy change's description (`jj describe -m`).
696    async fn describe(&self, dir: &Path, message: &str) -> Result<()>;
697    /// Set the description of an arbitrary revision (`jj describe -r <revset> -m`).
698    async fn describe_rev(&self, dir: &Path, revset: &RevsetExpr, message: &str) -> Result<()>;
699    /// Start a new change on top of the working copy (`jj new -m`).
700    async fn new_change(&self, dir: &Path, message: &str) -> Result<()>;
701    /// Start a new undescribed change on top of `parent` (`jj new <parent>`).
702    async fn new_child(&self, dir: &Path, parent: &RevsetExpr) -> Result<()>;
703    /// Local bookmarks (`jj bookmark list`). Snapshots the working copy first
704    /// (records an operation); for a read-only listing use
705    /// [`bookmarks_ignoring_working_copy`](JjApi::bookmarks_ignoring_working_copy).
706    async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
707    /// [`bookmarks`](JjApi::bookmarks) as a **read-only** query: adds
708    /// `--ignore-working-copy`, so listing the local bookmarks records no
709    /// operation and never moves `@` (the bookmark set is independent of the
710    /// working-copy snapshot, so the result is otherwise identical).
711    async fn bookmarks_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<Bookmark>>;
712    /// Local *and* remote-tracking bookmarks (`jj bookmark list -a`).
713    async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>>;
714    /// Local bookmarks on the nearest commits reachable from `@`
715    /// (`log -r 'heads(::@ & bookmarks())'`) — the candidate targets a commit
716    /// "belongs to". A commit carrying several bookmarks yields one entry each.
717    /// Snapshots the working copy first (records an operation); for the read-only
718    /// form use
719    /// [`reachable_bookmarks_ignoring_working_copy`](JjApi::reachable_bookmarks_ignoring_working_copy).
720    async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
721    /// [`reachable_bookmarks`](JjApi::reachable_bookmarks) as a **read-only**
722    /// query: adds `--ignore-working-copy`, so `@` resolves to the last recorded
723    /// operation's working-copy commit and no new operation is recorded.
724    async fn reachable_bookmarks_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<Bookmark>>;
725    /// Track a remote bookmark (`jj bookmark track <name>@<remote>`).
726    async fn bookmark_track(&self, dir: &Path, name: &BookmarkName, remote: &str) -> Result<()>;
727    /// Point a bookmark at `revision` (`jj bookmark set <name> -r <revision>`).
728    async fn bookmark_set(
729        &self,
730        dir: &Path,
731        name: &BookmarkName,
732        revision: &RevsetExpr,
733    ) -> Result<()>;
734    /// Fetch from the git remote (`jj git fetch`); transient (network) failures
735    /// are retried (3 attempts, 500 ms backoff).
736    async fn git_fetch(&self, dir: &Path) -> Result<()>;
737    /// Fetch from a *named* git remote (`jj git fetch --remote <remote>`);
738    /// transient failures are retried like [`git_fetch`](JjApi::git_fetch).
739    async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
740    /// Push to the git remote (`jj git push`, optionally `-b <bookmark>`). The
741    /// bookmark is owned (`Option<BookmarkName>`) to keep the trait `mockall`-friendly.
742    async fn git_push(&self, dir: &Path, bookmark: Option<BookmarkName>) -> Result<()>;
743
744    // --- Discovery / identity ------------------------------------------------
745
746    /// Working-copy root of the current workspace (`jj root`).
747    async fn root(&self, dir: &Path) -> Result<PathBuf>;
748    /// The local bookmark on the working-copy change `@`, if exactly one (or the
749    /// first of several); `None` when `@` carries no bookmark. `ws` enforces the
750    /// one-bookmark policy on top.
751    async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>>;
752    /// The trunk bookmark (`jj log -r 'trunk()'`); `None` when unresolved.
753    async fn trunk(&self, dir: &Path) -> Result<Option<String>>;
754
755    // --- Bookmarks -----------------------------------------------------------
756
757    /// Create a bookmark at a revision (`bookmark create <name> -r <rev>`).
758    async fn bookmark_create(
759        &self,
760        dir: &Path,
761        name: &BookmarkName,
762        revision: &RevsetExpr,
763    ) -> Result<()>;
764    /// Rename a bookmark (`bookmark rename <old> <new>`).
765    async fn bookmark_rename(
766        &self,
767        dir: &Path,
768        old: &BookmarkName,
769        new: &BookmarkName,
770    ) -> Result<()>;
771    /// Delete a bookmark (`bookmark delete <name>`).
772    async fn bookmark_delete(&self, dir: &Path, name: &BookmarkName) -> Result<()>;
773    /// Move a bookmark to a revision (`bookmark move <name> --to <rev>
774    /// [--allow-backwards]`); see [`BookmarkMove`].
775    async fn bookmark_move(&self, dir: &Path, spec: BookmarkMove) -> Result<()>;
776
777    // --- Diff / query / state ------------------------------------------------
778
779    /// Per-file change summary for a range (`diff -r <from>..<to> --summary`).
780    async fn diff_summary(
781        &self,
782        dir: &Path,
783        from: &RevsetExpr,
784        to: &RevsetExpr,
785    ) -> Result<Vec<ChangedPath>>;
786    /// Aggregate change stats for a revset (`diff -r <revset> --stat`).
787    async fn diff_stat(&self, dir: &Path, revset: &RevsetExpr) -> Result<DiffStat>;
788    /// Raw git-format unified diff text for `spec` (`diff -r <spec> --git`) —
789    /// stable machine output, returned **verbatim** (a trailing blank context line
790    /// is preserved, so the last hunk stays in sync with its `@@` line count).
791    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
792    /// Parsed per-file unified diff for `spec`, layered on [`diff_text`](JjApi::diff_text).
793    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
794    /// Count commits in a revset (`log -r <revset> --no-graph`, one id per line).
795    async fn commit_count(&self, dir: &Path, revset: &RevsetExpr) -> Result<usize>;
796    /// Whether the commit a revset resolves to has a conflict.
797    async fn is_conflicted(&self, dir: &Path, revset: &RevsetExpr) -> Result<bool>;
798    /// Whether the working copy has unresolved conflicts (`jj status`).
799    async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool>;
800    /// Paths with unresolved conflicts in `revset` (`jj resolve --list -r <revset>`).
801    /// Empty when there are none. Returns [`PathBuf`]s built from the raw bytes, so
802    /// a non-UTF-8 conflicted path survives losslessly.
803    async fn resolve_list(&self, dir: &Path, revset: &RevsetExpr) -> Result<Vec<PathBuf>>;
804    /// Run an arbitrary templated `jj log` query and return raw stdout
805    /// (`log -r <revset> --no-graph [--limit n] -T <template>`). Snapshots the
806    /// working copy first (records an operation); for a read-only query use
807    /// [`template_query_ignoring_working_copy`](JjApi::template_query_ignoring_working_copy).
808    async fn template_query(
809        &self,
810        dir: &Path,
811        revset: &RevsetExpr,
812        template: &str,
813        limit: Option<usize>,
814    ) -> Result<String>;
815    /// [`template_query`](JjApi::template_query) as a **read-only** query: adds
816    /// `--ignore-working-copy`, so a revset mentioning `@` resolves to the last
817    /// recorded operation's working-copy commit and the query records no new
818    /// operation and never moves `@`. A template reading working-copy-derived
819    /// keywords (`empty`, `conflict`, …) therefore reflects the last recorded
820    /// state, not a fresh snapshot of unsaved edits.
821    async fn template_query_ignoring_working_copy(
822        &self,
823        dir: &Path,
824        revset: &RevsetExpr,
825        template: &str,
826        limit: Option<usize>,
827    ) -> Result<String>;
828    /// The full (possibly multiline) description of the commit `revset` resolves
829    /// to, trailing whitespace trimmed; empty for an undescribed change — or for
830    /// a revset matching no commit (an *invalid* revset still errors). A
831    /// multi-commit revset yields only the newest commit's description
832    /// (`jj log` order, `--limit 1`).
833    async fn description(&self, dir: &Path, revset: &RevsetExpr) -> Result<String>;
834    /// How the commit a revset resolves to evolved, newest snapshot first, up
835    /// to `max` (`jj evolog -r <revset>`) — one [`Change`] row per recorded
836    /// predecessor.
837    async fn evolog(&self, dir: &Path, revset: &RevsetExpr, max: usize) -> Result<Vec<Change>>;
838    /// Per-line authorship of `path` (`jj file annotate <path> [-r <revset>]`;
839    /// `None` = `@`): which change introduced each line.
840    async fn file_annotate(
841        &self,
842        dir: &Path,
843        path: &str,
844        revset: Option<RevsetExpr>,
845    ) -> Result<Vec<AnnotationLine>>;
846    /// A file's content at a revision (`jj file show -r <revset>
847    /// root-file:"<path>"` — the path is wrapped as a workspace-root-relative
848    /// exact-path fileset, so fileset metacharacters in the name stay literal). Content is decoded
849    /// lossily — a binary file comes back mangled rather than erroring — and
850    /// returned **verbatim**: the file's trailing newline(s) are preserved (not
851    /// trimmed), so a read-modify-write round-trip is byte-exact.
852    async fn file_show(&self, dir: &Path, revset: &RevsetExpr, path: &str) -> Result<String>;
853
854    // --- Mutations -----------------------------------------------------------
855
856    /// Rebase the working-copy change and its branch onto `<onto>` (`rebase
857    /// -d <onto>`, i.e. jj's default `-b @`). jj's branch set is `(onto..@)::` —
858    /// the fork-point-to-`@` line **and its whole descendant closure**: `@`,
859    /// everything stacked on top of `@`, and any sibling that branches off an
860    /// *intermediate* commit of that line all move onto `<onto>`.
861    ///
862    /// This is **not** identical to git's `rebase <onto>`, which moves only
863    /// `merge-base(@,onto)..@` — `@`'s own ancestor line — and leaves commits
864    /// stacked on `@` (and intermediate-fork siblings) where they are. On a
865    /// linear `@` the two agree; on a **stacked or intermediate-fork** layout jj
866    /// moves strictly more. A sibling that branches off the **fork point itself**
867    /// is untouched by both (it is not in `(onto..@)::`). Use
868    /// [`rebase_branch`](JjApi::rebase_branch) with an explicit revset for
869    /// narrower control.
870    async fn rebase(&self, dir: &Path, onto: &RevsetExpr) -> Result<()>;
871    /// Rebase a whole branch onto a destination (`rebase -b <branch> -d <dest>`).
872    async fn rebase_branch(&self, dir: &Path, branch: &RevsetExpr, dest: &RevsetExpr)
873    -> Result<()>;
874    /// Move the working copy to a revision (`edit <rev>`).
875    async fn edit(&self, dir: &Path, revset: &RevsetExpr) -> Result<()>;
876    /// Squash the working copy into a revision (`squash --into <rev>
877    /// [--use-destination-message]`); see [`SquashInto`].
878    async fn squash_into(&self, dir: &Path, spec: SquashInto) -> Result<()>;
879    /// Finalise a commit from exactly these filesets (`commit -m <message>
880    /// <filesets>`); the rest stay in the new working-copy change. An **empty**
881    /// `filesets` slice is refused with `Error::Spawn`/`InvalidInput` before spawning
882    /// (a bare `jj commit` would commit the whole working copy, not "exactly these").
883    async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
884    /// Squash exactly these filesets from one revision into another
885    /// (`squash --from <from> --into <into> [--use-destination-message] <filesets>`).
886    async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()>;
887    /// Set the working copy's sparse patterns to exactly `patterns`
888    /// (`sparse set --clear --add <p>…`); an empty list clears the working copy.
889    async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()>;
890    /// Create a new change with the given parents (`new -m <msg> <p1> <p2> …`).
891    async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<RevsetExpr>) -> Result<()>;
892    /// Abandon a revision (`abandon <rev>`).
893    async fn abandon(&self, dir: &Path, revset: &RevsetExpr) -> Result<()>;
894    /// Fetch a single bookmark from origin (`git fetch --remote origin -b <branch>`);
895    /// transient failures are retried (3×, 500 ms).
896    async fn git_fetch_branch(&self, dir: &Path, branch: &BookmarkName) -> Result<()>;
897    /// Import git refs into jj (`jj git import`) — colocated-repo sync.
898    async fn git_import(&self, dir: &Path) -> Result<()>;
899    /// Clone a git repository into `dest` (`jj git clone <url> <dest>
900    /// --colocate|--no-colocate`). Runs without a working directory — pass an
901    /// **absolute** `dest`. The flag is always passed explicitly: whether
902    /// colocation (a visible `.git` alongside `.jj`) is jj's default depends
903    /// on the jj version *and* the user's `git.colocate` config, so the
904    /// [`GitClone`] choice decides deterministically.
905    async fn git_clone(&self, url: &str, dest: &Path, spec: GitClone) -> Result<()>;
906    /// Fold working-copy edits into the mutable ancestors that introduced the
907    /// touched lines (`absorb [--from <revset>] [<filesets>…]`); empty
908    /// `filesets` absorbs everything.
909    async fn absorb(
910        &self,
911        dir: &Path,
912        from: Option<RevsetExpr>,
913        filesets: &[JjFileset],
914    ) -> Result<()>;
915    /// Split exactly these filesets out of `@` into their own commit described
916    /// by `message` (`split -m <message> <filesets>…`); the remainder stays
917    /// behind. `filesets` must be non-empty — a fileset-less split opens jj's
918    /// interactive diff editor (a headless hang), so it is refused with an
919    /// error before spawning.
920    async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
921    /// Duplicate the commits a revset resolves to (`duplicate <revset>`).
922    async fn duplicate(&self, dir: &Path, revset: &RevsetExpr) -> Result<()>;
923
924    // --- Operation log -------------------------------------------------------
925
926    /// The current operation id (`op log --no-graph --limit 1`) — capture before
927    /// a risky sequence to roll back to.
928    async fn op_head(&self, dir: &Path) -> Result<String>;
929    /// The newest `limit` operations, newest first (`op log --no-graph
930    /// --limit n`).
931    async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>>;
932    /// Restore the repo to an operation (`op restore <id>`).
933    async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()>;
934    /// Undo the latest operation (`op undo`).
935    async fn op_undo(&self, dir: &Path) -> Result<()>;
936
937    // --- Workspaces ----------------------------------------------------------
938
939    /// List workspaces (`workspace list`).
940    async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>>;
941    /// Resolve a workspace's root path (`workspace root [--name <name>]`).
942    async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf>;
943    /// Add a workspace (`workspace add --name <name> -r <base> <path>`).
944    async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()>;
945    /// Forget a workspace (`workspace forget <name>`).
946    async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()>;
947}
948
949vcs_cli_support::managed_client! {
950    /// The real jj client. Generic over the [`ProcessRunner`] so tests can inject a
951    /// fake process executor; [`Jj::new`] uses the real job-backed runner.
952    ///
953    /// Wraps a [`ManagedClient`](vcs_cli_support::ManagedClient): enable lock-contention retry with
954    /// [`with_retry`](Jj::with_retry) (opt-in; off by default).
955    ///
956    /// **Remote authentication is ambient.** Unlike `vcs-git` (which accepts a
957    /// per-operation `CredentialProvider` via `with_credentials`), `jj`'s git remote
958    /// support runs through its own in-process backend, which offers no per-invocation
959    /// credential override — `jj git fetch`/`push` authenticate from the ambient git
960    /// credential helpers / SSH agent. Configure those out of band.
961    pub struct Jj => BINARY
962}
963
964/// Validate and canonically format paths emitted from the workspace root.
965fn normalize_changed_paths(entries: Vec<ChangedPath>) -> Result<Vec<ChangedPath>> {
966    entries
967        .into_iter()
968        .map(|mut entry| {
969            entry.path = normalize_workspace_path(&entry.path)?;
970            entry.old_path = entry
971                .old_path
972                .as_deref()
973                .map(normalize_workspace_path)
974                .transpose()?;
975            Ok(entry)
976        })
977        .collect()
978}
979
980/// Validate that `path` is workspace-root-relative and normalise its separators,
981/// operating on the **raw path bytes** so a non-UTF-8 (Unix) filename is never
982/// corrupted by a `String` round-trip. The structural checks (`/`, `\`, `:`, `.`,
983/// `..`) are all single-byte ASCII, so byte-wise slicing is exact.
984fn normalize_workspace_path(path: &Path) -> Result<PathBuf> {
985    // `as_encoded_bytes` is the raw OS bytes on Unix (lossless) and WTF-8
986    // elsewhere; only ASCII structure is inspected, so both are safe to scan.
987    let raw = path.as_os_str().as_encoded_bytes();
988    let normalized: Vec<u8> = raw
989        .iter()
990        .map(|&b| if b == b'\\' { b'/' } else { b })
991        .collect();
992    // Absolute if it leads with `/` or carries a `X:` drive letter.
993    if normalized.first() == Some(&b'/') || (normalized.len() >= 2 && normalized[1] == b':') {
994        return Err(Error::parse(
995            BINARY,
996            format!("summary path is not workspace-relative: {path:?}"),
997        ));
998    }
999    let mut parts: Vec<&[u8]> = Vec::new();
1000    for part in normalized.split(|&b| b == b'/') {
1001        match part {
1002            b"" | b"." => {}
1003            b".." => {
1004                return Err(Error::parse(
1005                    BINARY,
1006                    format!("summary path escapes the workspace root: {path:?}"),
1007                ));
1008            }
1009            _ => parts.push(part),
1010        }
1011    }
1012    if parts.is_empty() {
1013        return Err(Error::parse(
1014            BINARY,
1015            format!("summary path is empty after normalisation: {path:?}"),
1016        ));
1017    }
1018    let mut joined = Vec::new();
1019    for (i, part) in parts.iter().enumerate() {
1020        if i > 0 {
1021            joined.push(b'/');
1022        }
1023        joined.extend_from_slice(part);
1024    }
1025    Ok(vcs_diff::path_from_bytes(&joined))
1026}
1027
1028impl<R: ProcessRunner> Jj<R> {
1029    /// Retry **lock-contention** failures (another process holds jj's working-copy
1030    /// lock) per `policy` — opt-in, off by default. Safe even for mutating commands:
1031    /// a lock-acquisition failure is pre-execution (jj never ran). See [`RetryPolicy`]
1032    /// and [`is_lock_contention`]. Note jj's operation log already auto-resolves most
1033    /// concurrency, so hard lock failures are rarer than with git.
1034    ///
1035    /// **Caveat:** modern jj generally *blocks* on the working-copy / operation-heads
1036    /// lock until it is free, rather than failing — so contention usually surfaces as
1037    /// a wait (bounded by the client's `default_timeout`), not a retryable error. This
1038    /// retry therefore catches only the residual cases where jj surfaces a lock error;
1039    /// for most jj concurrency the blocking behavior is what serializes access.
1040    pub fn with_retry(mut self, policy: RetryPolicy) -> Self {
1041        self.core = self.core.with_retry(policy);
1042        self
1043    }
1044}
1045
1046/// Whether a query lets jj **snapshot the working copy** before answering.
1047///
1048/// jj snapshots by default: it takes the working-copy lock, imports any bare
1049/// filesystem edits into a fresh `@`, and **records a new operation** in the op
1050/// log — so an ordinary `jj log`/`jj status`/`jj bookmark list` is a *mutation*,
1051/// not a pure read. [`WorkingCopy::Ignore`] appends the global
1052/// `--ignore-working-copy` flag, which reports the state of the **last recorded
1053/// operation** without any of that: no lock, no new operation, `@` unmoved. That
1054/// is the read-only mode an *observer* (a watcher, a prompt refresh) needs —
1055/// reading the repo must not perturb the very state it reports.
1056///
1057/// Trade-off: because [`Ignore`](WorkingCopy::Ignore) does not snapshot, a bare
1058/// working-tree edit that no jj command has recorded yet is invisible to it
1059/// (state is as of the last operation). Callers that must observe such edits opt
1060/// into [`Snapshot`](WorkingCopy::Snapshot) and accept the recorded operation.
1061#[derive(Clone, Copy, PartialEq, Eq)]
1062enum WorkingCopy {
1063    /// jj's default: snapshot the working copy first (records an operation, may
1064    /// move `@`).
1065    Snapshot,
1066    /// Pass `--ignore-working-copy`: read the last recorded operation's state,
1067    /// recording no operation and never moving `@`.
1068    Ignore,
1069}
1070
1071impl<R: ProcessRunner> Jj<R> {
1072    /// A repo-scoped `jj` command with `--color never` forced on. jj honours
1073    /// `ui.color = "always"` from user config even when its output is piped, which
1074    /// would wrap our templated output — and the command error text we classify —
1075    /// in ANSI escapes and break parsing; `--color never` is the only thing that
1076    /// overrides that config (`NO_COLOR`/`CLICOLOR` do not). It is a global flag,
1077    /// appended here (no jj subcommand takes a trailing `--`, so this is safe).
1078    fn cmd_in<I, S>(&self, dir: &Path, args: I) -> processkit::Command
1079    where
1080        I: IntoIterator<Item = S>,
1081        S: AsRef<std::ffi::OsStr>,
1082    {
1083        self.cmd_in_wc(dir, args, WorkingCopy::Snapshot)
1084    }
1085
1086    /// Like [`cmd_in`](Self::cmd_in), but chooses whether jj may snapshot the
1087    /// working copy first. On [`WorkingCopy::Ignore`] it also appends the global
1088    /// `--ignore-working-copy` flag, so the command records no operation and never
1089    /// moves `@` (a genuinely read-only query). Like `--color never`, it is a
1090    /// global flag appended after the subcommand (no jj subcommand takes a
1091    /// trailing `--`, so appending is safe).
1092    fn cmd_in_wc<I, S>(&self, dir: &Path, args: I, wc: WorkingCopy) -> processkit::Command
1093    where
1094        I: IntoIterator<Item = S>,
1095        S: AsRef<std::ffi::OsStr>,
1096    {
1097        let cmd = self.core.command_in(dir, args).arg("--color").arg("never");
1098        match wc {
1099            WorkingCopy::Snapshot => cmd,
1100            WorkingCopy::Ignore => cmd.arg("--ignore-working-copy"),
1101        }
1102    }
1103
1104    /// Shared core of [`status`](JjApi::status) /
1105    /// [`status_ignoring_working_copy`](JjApi::status_ignoring_working_copy): the
1106    /// only difference is whether jj snapshots the working copy first.
1107    async fn status_wc(&self, dir: &Path, wc: WorkingCopy) -> Result<Vec<ChangedPath>> {
1108        // `diff -r @ --summary` is the machine-stable form of the working-copy
1109        // changes that `jj status` renders for humans: one `<letter> <path>` line.
1110        // jj renders those paths relative to its cwd, so first resolve the
1111        // workspace and run the machine query at its root. This also means jj can
1112        // never legitimately emit a path that walks above the workspace.
1113        //
1114        // The root lookup itself must honour `wc`: a plain (snapshotting)
1115        // `self.root(dir)` here would defeat `status_ignoring_working_copy`'s
1116        // whole point by recording an operation as a side effect of resolving
1117        // the path prefix.
1118        let root = self.root_wc(dir, wc).await?;
1119        // `parse_bytes`: `--summary` paths are raw bytes that may not be valid
1120        // UTF-8 on Unix, so parse from the byte stream rather than a lossy `String`.
1121        let entries = self
1122            .core
1123            .parse_bytes(
1124                self.cmd_in_wc(&root, ["diff", "-r", "@", "--summary"], wc),
1125                parse::parse_diff_summary,
1126            )
1127            .await?;
1128        normalize_changed_paths(entries)
1129    }
1130
1131    /// Shared core of [`root`](JjApi::root): resolves the workspace root,
1132    /// honouring `wc` so an ignoring-working-copy caller (e.g.
1133    /// [`status_wc`](Self::status_wc) on [`WorkingCopy::Ignore`]) does not have
1134    /// this lookup itself snapshot the working copy.
1135    async fn root_wc(&self, dir: &Path, wc: WorkingCopy) -> Result<PathBuf> {
1136        Ok(PathBuf::from(
1137            self.core.run(self.cmd_in_wc(dir, ["root"], wc)).await?,
1138        ))
1139    }
1140
1141    /// Shared core of [`bookmarks`](JjApi::bookmarks) /
1142    /// [`bookmarks_ignoring_working_copy`](JjApi::bookmarks_ignoring_working_copy).
1143    async fn bookmarks_wc(&self, dir: &Path, wc: WorkingCopy) -> Result<Vec<Bookmark>> {
1144        self.core
1145            .parse(
1146                self.cmd_in_wc(
1147                    dir,
1148                    ["bookmark", "list", "-T", parse::BOOKMARK_LIST_TEMPLATE],
1149                    wc,
1150                ),
1151                parse::parse_bookmarks,
1152            )
1153            .await
1154    }
1155
1156    /// Shared core of [`reachable_bookmarks`](JjApi::reachable_bookmarks) /
1157    /// [`reachable_bookmarks_ignoring_working_copy`](JjApi::reachable_bookmarks_ignoring_working_copy).
1158    async fn reachable_bookmarks_wc(&self, dir: &Path, wc: WorkingCopy) -> Result<Vec<Bookmark>> {
1159        self.core
1160            .parse(
1161                self.cmd_in_wc(
1162                    dir,
1163                    [
1164                        "log",
1165                        "-r",
1166                        "heads(::@ & bookmarks())",
1167                        "--no-graph",
1168                        "-T",
1169                        parse::REACHABLE_BOOKMARKS_TEMPLATE,
1170                    ],
1171                    wc,
1172                ),
1173                parse::parse_reachable_bookmarks,
1174            )
1175            .await
1176    }
1177
1178    /// Shared core of [`template_query`](JjApi::template_query) /
1179    /// [`template_query_ignoring_working_copy`](JjApi::template_query_ignoring_working_copy).
1180    async fn template_query_wc(
1181        &self,
1182        dir: &Path,
1183        revset: &RevsetExpr,
1184        template: &str,
1185        limit: Option<usize>,
1186        wc: WorkingCopy,
1187    ) -> Result<String> {
1188        let mut args: Vec<String> = vec![
1189            "log".into(),
1190            "-r".into(),
1191            revset.as_str().into(),
1192            "--no-graph".into(),
1193        ];
1194        if let Some(n) = limit {
1195            args.push("--limit".into());
1196            args.push(n.to_string());
1197        }
1198        args.push("-T".into());
1199        args.push(template.into());
1200        // `run_untrimmed`: `template_query` is documented to return the template's
1201        // **raw** stdout, so a template that deliberately ends in `\n\n` or trailing
1202        // spaces (e.g. fixed-width joins) is preserved, not silently stripped (H7).
1203        // Callers that want a scalar trim it themselves (see `description`).
1204        self.core.run_untrimmed(self.cmd_in_wc(dir, args, wc)).await
1205    }
1206
1207    /// [`diff_text`](JjApi::diff_text) with an explicit per-call [`OutputBudget`],
1208    /// instead of this client's [`default_output_budget`](Jj::default_output_budget).
1209    /// Past the ceiling the read errors with
1210    /// [`Error::OutputTooLarge`] (actual and
1211    /// allowed sizes) rather than buffering an unbounded diff.
1212    pub async fn diff_text_within(
1213        &self,
1214        dir: &Path,
1215        spec: DiffSpec,
1216        budget: OutputBudget,
1217    ) -> Result<String> {
1218        self.diff_text_budgeted(dir, spec, budget).await
1219    }
1220
1221    /// [`diff`](JjApi::diff) with an explicit per-call [`OutputBudget`] — the
1222    /// parsed-model counterpart of [`diff_text_within`](Jj::diff_text_within).
1223    pub async fn diff_within(
1224        &self,
1225        dir: &Path,
1226        spec: DiffSpec,
1227        budget: OutputBudget,
1228    ) -> Result<Vec<FileDiff>> {
1229        let text = self.diff_text_budgeted(dir, spec, budget).await?;
1230        Ok(parse_diff(&text))
1231    }
1232
1233    /// Shared body of [`diff_text`](JjApi::diff_text) /
1234    /// [`diff_text_within`](Jj::diff_text_within), run under `budget`.
1235    async fn diff_text_budgeted(
1236        &self,
1237        dir: &Path,
1238        spec: DiffSpec,
1239        budget: OutputBudget,
1240    ) -> Result<String> {
1241        // `@` selects the working-copy change; otherwise the caller's revset.
1242        // `--git` emits stable git-format output the shared parser understands.
1243        let revset = match spec {
1244            DiffSpec::WorkingTree => "@".to_string(),
1245            DiffSpec::Rev(rev) => rev,
1246        };
1247        // `run_untrimmed_within`: trimming the diff would drop a trailing blank
1248        // context line, desyncing the last hunk from its `@@` line count for a
1249        // consumer that re-parses/re-applies it — same as git's `diff_text` (H7);
1250        // the budget bounds it.
1251        self.core
1252            .run_untrimmed_within(
1253                self.cmd_in(dir, ["diff", "-r", revset.as_str(), "--git"]),
1254                budget,
1255            )
1256            .await
1257    }
1258
1259    /// [`file_show`](JjApi::file_show) with an explicit per-call [`OutputBudget`],
1260    /// instead of this client's [`default_output_budget`](Jj::default_output_budget).
1261    /// Reads a file's bytes under `budget`: past the ceiling the read errors with
1262    /// [`Error::OutputTooLarge`] rather than
1263    /// buffering an unbounded file.
1264    pub async fn file_show_within(
1265        &self,
1266        dir: &Path,
1267        revset: &RevsetExpr,
1268        path: &str,
1269        budget: OutputBudget,
1270    ) -> Result<String> {
1271        // `file show` takes FILESETS, so a bare path with a fileset metacharacter
1272        // (`(`, `*`, `~`, …) would be parsed as an expression — wrap it in the exact-
1273        // path form. (`file annotate` is the opposite: it takes a plain PATH and
1274        // rejects the `file:"…"` form.)
1275        let fileset = JjFileset::path(path);
1276        // `run_untrimmed_within`: a file's trailing newline(s) are part of its
1277        // content; trimming corrupts a read-modify-write round-trip (H7). The budget
1278        // bounds it.
1279        self.core
1280            .run_untrimmed_within(
1281                self.cmd_in(
1282                    dir,
1283                    ["file", "show", "-r", revset.as_str(), fileset.as_str()],
1284                ),
1285                budget,
1286            )
1287            .await
1288    }
1289}
1290
1291#[async_trait::async_trait]
1292impl<R: ProcessRunner> JjApi for Jj<R> {
1293    async fn run(&self, args: &[String]) -> Result<String> {
1294        self.core.run(args).await
1295    }
1296
1297    async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>> {
1298        self.core.output_string(args).await
1299    }
1300
1301    async fn version(&self) -> Result<String> {
1302        self.core.run(["--version"]).await
1303    }
1304
1305    async fn capabilities(&self) -> Result<JjCapabilities> {
1306        let raw = self.version().await?;
1307        let version = parse::parse_jj_version(&raw).ok_or_else(|| {
1308            Error::parse(
1309                BINARY,
1310                format!("unrecognisable `jj --version` output: {raw:?}"),
1311            )
1312        })?;
1313        Ok(JjCapabilities { version })
1314    }
1315
1316    async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>> {
1317        self.status_wc(dir, WorkingCopy::Snapshot).await
1318    }
1319
1320    async fn status_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<ChangedPath>> {
1321        self.status_wc(dir, WorkingCopy::Ignore).await
1322    }
1323
1324    async fn status_text(&self, dir: &Path) -> Result<String> {
1325        self.core.run(self.cmd_in(dir, ["status"])).await
1326    }
1327
1328    async fn log(&self, dir: &Path, revset: &RevsetExpr, max: usize) -> Result<Vec<Change>> {
1329        let n = format!("-n{max}");
1330        self.core
1331            .parse(
1332                self.cmd_in(
1333                    dir,
1334                    [
1335                        "log",
1336                        "-r",
1337                        revset.as_str(),
1338                        n.as_str(),
1339                        "--no-graph",
1340                        "-T",
1341                        parse::CHANGE_TEMPLATE,
1342                    ],
1343                ),
1344                parse::parse_changes,
1345            )
1346            .await
1347    }
1348
1349    async fn log_paths(
1350        &self,
1351        dir: &Path,
1352        revset: &RevsetExpr,
1353        max: usize,
1354        filesets: &[JjFileset],
1355    ) -> Result<Vec<Change>> {
1356        // An empty fileset slice would degrade `jj log -r <revset> <filesets…>`
1357        // to a bare `jj log -r <revset>` — UNRESTRICTED history, the opposite
1358        // of "scoped to these paths". Refuse before spawning (mirrors
1359        // `commit_paths`/`split_paths`).
1360        if filesets.is_empty() {
1361            return Err(Error::spawn(
1362                BINARY,
1363                std::io::Error::new(
1364                    std::io::ErrorKind::InvalidInput,
1365                    "log_paths requires at least one fileset — an empty set would log \
1366                     unrestricted history, not history scoped to the named paths",
1367                ),
1368            ));
1369        }
1370        let n = format!("-n{max}");
1371        let mut args: Vec<String> = vec![
1372            "log".into(),
1373            "-r".into(),
1374            revset.as_str().into(),
1375            n,
1376            "--no-graph".into(),
1377            "-T".into(),
1378            parse::CHANGE_TEMPLATE.into(),
1379        ];
1380        args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1381        self.core
1382            .parse(self.cmd_in(dir, args), parse::parse_changes)
1383            .await
1384    }
1385
1386    async fn current_change(&self, dir: &Path) -> Result<Change> {
1387        let mut changes = self.log(dir, &at_revset(), 1).await?;
1388        changes
1389            .pop()
1390            .ok_or_else(|| Error::parse(BINARY, "no working-copy change found"))
1391    }
1392
1393    async fn describe(&self, dir: &Path, message: &str) -> Result<()> {
1394        self.core
1395            .run_unit(self.cmd_in(dir, ["describe", "-m", message]))
1396            .await
1397    }
1398
1399    async fn describe_rev(&self, dir: &Path, revset: &RevsetExpr, message: &str) -> Result<()> {
1400        self.core
1401            .run_unit(self.cmd_in(dir, ["describe", "-r", revset.as_str(), "-m", message]))
1402            .await
1403    }
1404
1405    async fn new_change(&self, dir: &Path, message: &str) -> Result<()> {
1406        self.core
1407            .run_unit(self.cmd_in(dir, ["new", "-m", message]))
1408            .await
1409    }
1410
1411    async fn new_child(&self, dir: &Path, parent: &RevsetExpr) -> Result<()> {
1412        self.core
1413            .run_unit(self.cmd_in(dir, ["new", parent.as_str()]))
1414            .await
1415    }
1416
1417    async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
1418        self.bookmarks_wc(dir, WorkingCopy::Snapshot).await
1419    }
1420
1421    async fn bookmarks_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<Bookmark>> {
1422        self.bookmarks_wc(dir, WorkingCopy::Ignore).await
1423    }
1424
1425    async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>> {
1426        self.core
1427            .parse(
1428                self.cmd_in(
1429                    dir,
1430                    ["bookmark", "list", "-a", "-T", parse::BOOKMARK_ALL_TEMPLATE],
1431                ),
1432                parse::parse_bookmarks_all,
1433            )
1434            .await
1435    }
1436
1437    async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>> {
1438        self.reachable_bookmarks_wc(dir, WorkingCopy::Snapshot)
1439            .await
1440    }
1441
1442    async fn reachable_bookmarks_ignoring_working_copy(&self, dir: &Path) -> Result<Vec<Bookmark>> {
1443        self.reachable_bookmarks_wc(dir, WorkingCopy::Ignore).await
1444    }
1445
1446    async fn bookmark_track(&self, dir: &Path, name: &BookmarkName, remote: &str) -> Result<()> {
1447        // A leading-`-` name makes the whole token start with `-`, which jj
1448        // parses as a global flag (e.g. `--config`); guard it. The bookmark
1449        // segment is wrapped in `exact:` (a real string-pattern there), but
1450        // the remote segment of this `<name>@<remote>` positional form is
1451        // *not* itself pattern-syntax — a `exact:` prefix on it is taken as
1452        // part of the literal remote name and silently matches nothing
1453        // (verified on jj 0.42: see `reject_glob_like`'s doc comment) — so the
1454        // remote is validated against glob metacharacters instead of wrapped.
1455        reject_glob_like("remote", remote)?;
1456        let target = format!("exact:{}@{remote}", name.as_str());
1457        self.core
1458            .run_unit(self.cmd_in(dir, ["bookmark", "track", target.as_str()]))
1459            .await
1460    }
1461
1462    async fn bookmark_set(
1463        &self,
1464        dir: &Path,
1465        name: &BookmarkName,
1466        revision: &RevsetExpr,
1467    ) -> Result<()> {
1468        self.core
1469            .run_unit(self.cmd_in(
1470                dir,
1471                ["bookmark", "set", name.as_str(), "-r", revision.as_str()],
1472            ))
1473            .await
1474    }
1475
1476    async fn git_fetch(&self, dir: &Path) -> Result<()> {
1477        // Idempotent → `retry` replays it on a transient (network) failure.
1478        // `c_locale`: the retry decision classifies the failure's message (M28).
1479        // `budget_diagnostics`: bound the retained failure/progress output (a
1480        // drop-oldest tail — never `OutputTooLarge`, so `is_transient_fetch_error`
1481        // still classifies the tail-preserved message). Unbounded by default.
1482        let cmd = self.core.budget_diagnostics(
1483            c_locale(self.cmd_in(dir, ["git", "fetch"]))
1484                // Graceful terminate-then-kill on a per-client timeout, so a timed-out
1485                // fetch can close its connection cleanly.
1486                .timeout_grace(FETCH_TIMEOUT_GRACE)
1487                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1488        );
1489        self.core.run_unit(cmd).await
1490    }
1491
1492    async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()> {
1493        // `--remote` is glob-matched too, so `exact:` keeps a `*` remote from
1494        // fetching from every configured remote. Idempotent → `retry` replays it
1495        // on a transient (network) failure.
1496        let remote_pat = exact(remote);
1497        // `c_locale`: the retry decision classifies the failure's message (M28).
1498        let cmd = self.core.budget_diagnostics(
1499            c_locale(self.cmd_in(dir, ["git", "fetch", "--remote", remote_pat.as_str()]))
1500                .timeout_grace(FETCH_TIMEOUT_GRACE)
1501                .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error),
1502        );
1503        self.core.run_unit(cmd).await
1504    }
1505
1506    async fn git_push(&self, dir: &Path, bookmark: Option<BookmarkName>) -> Result<()> {
1507        let mut args = vec!["git", "push"];
1508        // `-b` is glob-matched, so `exact:` keeps a `*` bookmark from pushing
1509        // every local bookmark at once (a UI/bot-supplied `"*"`).
1510        let bookmark_pat = bookmark.as_ref().map(|b| exact(b.as_str()));
1511        if let Some(name) = bookmark_pat.as_deref() {
1512            args.push("-b");
1513            args.push(name);
1514        }
1515        // Graceful terminate-then-kill on a per-client timeout, so a timed-out
1516        // push doesn't leave the remote ref half-updated. No-op without a
1517        // deadline (matches `git_fetch`).
1518        let cmd = self.cmd_in(dir, args).timeout_grace(FETCH_TIMEOUT_GRACE);
1519        self.core.run_unit(cmd).await
1520    }
1521
1522    async fn root(&self, dir: &Path) -> Result<PathBuf> {
1523        self.root_wc(dir, WorkingCopy::Snapshot).await
1524    }
1525
1526    async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>> {
1527        let out = self
1528            .core
1529            .run(self.cmd_in(
1530                dir,
1531                [
1532                    "log",
1533                    "-r",
1534                    "@",
1535                    "--no-graph",
1536                    "--limit",
1537                    "1",
1538                    "-T",
1539                    parse::BOOKMARKS_TEMPLATE,
1540                ],
1541            ))
1542            .await?;
1543        Ok(first_bookmark(&out))
1544    }
1545
1546    async fn trunk(&self, dir: &Path) -> Result<Option<String>> {
1547        let out = self
1548            .core
1549            .run(self.cmd_in(
1550                dir,
1551                [
1552                    "log",
1553                    "-r",
1554                    "trunk()",
1555                    "--no-graph",
1556                    "--limit",
1557                    "1",
1558                    "-T",
1559                    parse::BOOKMARKS_TEMPLATE,
1560                ],
1561            ))
1562            .await?;
1563        Ok(first_bookmark(&out))
1564    }
1565
1566    async fn bookmark_create(
1567        &self,
1568        dir: &Path,
1569        name: &BookmarkName,
1570        revision: &RevsetExpr,
1571    ) -> Result<()> {
1572        self.core
1573            .run_unit(self.cmd_in(
1574                dir,
1575                ["bookmark", "create", name.as_str(), "-r", revision.as_str()],
1576            ))
1577            .await
1578    }
1579
1580    async fn bookmark_rename(
1581        &self,
1582        dir: &Path,
1583        old: &BookmarkName,
1584        new: &BookmarkName,
1585    ) -> Result<()> {
1586        self.core
1587            .run_unit(self.cmd_in(dir, ["bookmark", "rename", old.as_str(), new.as_str()]))
1588            .await
1589    }
1590
1591    async fn bookmark_delete(&self, dir: &Path, name: &BookmarkName) -> Result<()> {
1592        let name_pat = exact(name.as_str());
1593        self.core
1594            .run_unit(self.cmd_in(dir, ["bookmark", "delete", name_pat.as_str()]))
1595            .await
1596    }
1597
1598    async fn bookmark_move(&self, dir: &Path, spec: BookmarkMove) -> Result<()> {
1599        // `<NAMES>` is glob-matched, so `exact:` keeps a `*` name from moving
1600        // every bookmark. `to` is a revision, not a pattern — left as-is.
1601        let name_pat = exact(spec.name.as_str());
1602        let mut args = vec![
1603            "bookmark",
1604            "move",
1605            name_pat.as_str(),
1606            "--to",
1607            spec.to.as_str(),
1608        ];
1609        if spec.allow_backwards {
1610            args.push("--allow-backwards");
1611        }
1612        self.core.run_unit(self.cmd_in(dir, args)).await
1613    }
1614
1615    async fn diff_summary(
1616        &self,
1617        dir: &Path,
1618        from: &RevsetExpr,
1619        to: &RevsetExpr,
1620    ) -> Result<Vec<ChangedPath>> {
1621        // Parenthesise each endpoint so a compound revset (e.g. `x | y`) keeps its
1622        // meaning inside the `..` range instead of binding by operator precedence.
1623        let range = format!("({})..({})", from.as_str(), to.as_str());
1624        // `jj diff --summary` makes paths relative to its cwd. Run it at the
1625        // workspace root so this API has one stable, root-relative path contract.
1626        let root = self.root(dir).await?;
1627        let entries = self
1628            .core
1629            .parse_bytes(
1630                self.cmd_in(&root, ["diff", "-r", range.as_str(), "--summary"]),
1631                parse::parse_diff_summary,
1632            )
1633            .await?;
1634        normalize_changed_paths(entries)
1635    }
1636
1637    async fn diff_stat(&self, dir: &Path, revset: &RevsetExpr) -> Result<DiffStat> {
1638        self.core
1639            .parse(
1640                self.cmd_in(dir, ["diff", "-r", revset.as_str(), "--stat"]),
1641                parse::parse_diff_stat,
1642            )
1643            .await
1644    }
1645
1646    async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String> {
1647        self.diff_text_budgeted(dir, spec, self.core.output_budget())
1648            .await
1649    }
1650
1651    async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>> {
1652        let text = self.diff_text(dir, spec).await?;
1653        Ok(parse_diff(&text))
1654    }
1655
1656    async fn commit_count(&self, dir: &Path, revset: &RevsetExpr) -> Result<usize> {
1657        self.core
1658            .parse(
1659                self.cmd_in(
1660                    dir,
1661                    [
1662                        "log",
1663                        "-r",
1664                        revset.as_str(),
1665                        "--no-graph",
1666                        "-T",
1667                        parse::COUNT_TEMPLATE,
1668                    ],
1669                ),
1670                |s| s.lines().filter(|line| !line.is_empty()).count(),
1671            )
1672            .await
1673    }
1674
1675    async fn is_conflicted(&self, dir: &Path, revset: &RevsetExpr) -> Result<bool> {
1676        let out = self
1677            .core
1678            .run(self.cmd_in(
1679                dir,
1680                [
1681                    "log",
1682                    "-r",
1683                    revset.as_str(),
1684                    "--no-graph",
1685                    "--limit",
1686                    "1",
1687                    "-T",
1688                    parse::CONFLICT_TEMPLATE,
1689                ],
1690            ))
1691            .await?;
1692        Ok(out.trim() == "1")
1693    }
1694
1695    async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool> {
1696        // Ask the template engine directly rather than string-matching localized
1697        // `jj status` prose: `@` is conflicted iff its `conflict` flag is set.
1698        self.is_conflicted(dir, &at_revset()).await
1699    }
1700
1701    async fn resolve_list(&self, dir: &Path, revset: &RevsetExpr) -> Result<Vec<PathBuf>> {
1702        // `output_bytes`: a conflicted path may not be valid UTF-8 on Unix, so read
1703        // raw stdout (stderr stays text for the "no conflicts" probe below).
1704        let res = self
1705            .core
1706            .output_bytes(self.cmd_in(dir, ["resolve", "--list", "-r", revset.as_str()]))
1707            .await?;
1708        match res.code() {
1709            Some(0) => Ok(parse::parse_resolve_list(res.stdout())),
1710            // jj exits non-zero with "No conflicts found …" when the revision is
1711            // conflict-free — the one non-zero we read as an empty list. Any other
1712            // failure (bad revset, not a repo, …) must surface, not masquerade as
1713            // "no conflicts". `resolve --list` has no exit-code contract that
1714            // distinguishes the two, so this matches the message; jj's output is
1715            // English-only (no localization), so the risk is version *wording* drift,
1716            // not locale — matched on the stable core phrase, case-insensitively, to
1717            // absorb a capitalization change.
1718            _ if res.stderr().to_ascii_lowercase().contains("no conflicts") => Ok(Vec::new()),
1719            _ => {
1720                let _ = res.ensure_success()?;
1721                Ok(Vec::new()) // unreachable: a non-zero exit always errors above.
1722            }
1723        }
1724    }
1725
1726    async fn template_query(
1727        &self,
1728        dir: &Path,
1729        revset: &RevsetExpr,
1730        template: &str,
1731        limit: Option<usize>,
1732    ) -> Result<String> {
1733        self.template_query_wc(dir, revset, template, limit, WorkingCopy::Snapshot)
1734            .await
1735    }
1736
1737    async fn template_query_ignoring_working_copy(
1738        &self,
1739        dir: &Path,
1740        revset: &RevsetExpr,
1741        template: &str,
1742        limit: Option<usize>,
1743    ) -> Result<String> {
1744        self.template_query_wc(dir, revset, template, limit, WorkingCopy::Ignore)
1745            .await
1746    }
1747
1748    async fn description(&self, dir: &Path, revset: &RevsetExpr) -> Result<String> {
1749        // `template_query` is raw now (H7); `description` is a scalar, so strip the
1750        // trailing newline jj appends to the `description` keyword (preserving the
1751        // pre-H7 contract that this returns the description without a trailing EOL).
1752        let out = self
1753            .template_query(dir, revset, "description", Some(1))
1754            .await?;
1755        Ok(out.trim_end().to_string())
1756    }
1757
1758    async fn evolog(&self, dir: &Path, revset: &RevsetExpr, max: usize) -> Result<Vec<Change>> {
1759        // Evolog templates render in a *commit* context (bare `change_id`
1760        // doesn't exist there) — EVOLOG_TEMPLATE uses the `commit.` method
1761        // form but emits the same columns CHANGE_TEMPLATE does.
1762        let limit = max.to_string();
1763        self.core
1764            .parse(
1765                self.cmd_in(
1766                    dir,
1767                    [
1768                        "evolog",
1769                        "-r",
1770                        revset.as_str(),
1771                        "--no-graph",
1772                        "--limit",
1773                        limit.as_str(),
1774                        "-T",
1775                        parse::EVOLOG_TEMPLATE,
1776                    ],
1777                ),
1778                parse::parse_changes,
1779            )
1780            .await
1781    }
1782
1783    async fn file_annotate(
1784        &self,
1785        dir: &Path,
1786        path: &str,
1787        revset: Option<RevsetExpr>,
1788    ) -> Result<Vec<AnnotationLine>> {
1789        // `file annotate` takes a plain PATH (not a fileset — the `file:"…"`
1790        // form is rejected), so a leading-`-` path would be parsed as a flag.
1791        // The `--` separator before it keeps even a `-dash.txt` literal safe —
1792        // but global flags (`--color never`) MUST precede `--`, so this builds
1793        // the command directly instead of via `cmd_in` (which trails them).
1794        let mut args = vec!["file", "annotate"];
1795        if let Some(revset) = revset.as_ref() {
1796            args.push("-r");
1797            args.push(revset.as_str());
1798        }
1799        args.extend([
1800            "-T",
1801            parse::ANNOTATE_TEMPLATE,
1802            "--color",
1803            "never",
1804            "--",
1805            path,
1806        ]);
1807        self.core
1808            .parse(self.core.command_in(dir, args), parse::parse_annotate)
1809            .await
1810    }
1811
1812    async fn file_show(&self, dir: &Path, revset: &RevsetExpr, path: &str) -> Result<String> {
1813        self.file_show_within(dir, revset, path, self.core.output_budget())
1814            .await
1815    }
1816
1817    async fn rebase(&self, dir: &Path, onto: &RevsetExpr) -> Result<()> {
1818        self.core
1819            .run_unit(self.cmd_in(dir, ["rebase", "-d", onto.as_str()]))
1820            .await
1821    }
1822
1823    async fn rebase_branch(
1824        &self,
1825        dir: &Path,
1826        branch: &RevsetExpr,
1827        dest: &RevsetExpr,
1828    ) -> Result<()> {
1829        self.core
1830            .run_unit(self.cmd_in(dir, ["rebase", "-b", branch.as_str(), "-d", dest.as_str()]))
1831            .await
1832    }
1833
1834    async fn edit(&self, dir: &Path, revset: &RevsetExpr) -> Result<()> {
1835        self.core
1836            .run_unit(self.cmd_in(dir, ["edit", revset.as_str()]))
1837            .await
1838    }
1839
1840    async fn squash_into(&self, dir: &Path, spec: SquashInto) -> Result<()> {
1841        let mut command = self.cmd_in(dir, ["squash", "--into", spec.into.as_str()]);
1842        if spec.use_destination_message {
1843            command = command.arg("--use-destination-message");
1844        }
1845        self.core.run_unit(command).await
1846    }
1847
1848    async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()> {
1849        // An empty fileset slice would degrade `jj commit -m <msg> <filesets…>` to a
1850        // bare `jj commit -m <msg>`, which commits the ENTIRE working copy — the
1851        // opposite of the "exactly these filesets" contract. Refuse it before spawning
1852        // (mirrors `split_paths`).
1853        if filesets.is_empty() {
1854            return Err(Error::spawn(
1855                BINARY,
1856                std::io::Error::new(
1857                    std::io::ErrorKind::InvalidInput,
1858                    "commit_paths requires at least one fileset — an empty set would \
1859                     commit the entire working copy, not just the named paths",
1860                ),
1861            ));
1862        }
1863        let mut args: Vec<String> = vec!["commit".into(), "-m".into(), message.into()];
1864        args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1865        self.core.run_unit(self.cmd_in(dir, args)).await
1866    }
1867
1868    async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()> {
1869        let mut args: Vec<String> = vec![
1870            "squash".into(),
1871            "--from".into(),
1872            spec.from.as_str().into(),
1873            "--into".into(),
1874            spec.into.as_str().into(),
1875        ];
1876        if spec.use_destination_message {
1877            args.push("--use-destination-message".into());
1878        }
1879        args.extend(spec.filesets.iter().map(|f| f.as_str().to_string()));
1880        self.core.run_unit(self.cmd_in(dir, args)).await
1881    }
1882
1883    async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()> {
1884        // `--clear` empties the working copy first, then each `--add` reinstates a
1885        // pattern — so the working copy ends up holding exactly `patterns`.
1886        let mut args: Vec<String> = vec!["sparse".into(), "set".into(), "--clear".into()];
1887        for pattern in patterns {
1888            args.push("--add".into());
1889            args.push(pattern.clone());
1890        }
1891        self.core.run_unit(self.cmd_in(dir, args)).await
1892    }
1893
1894    async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<RevsetExpr>) -> Result<()> {
1895        // Parents are bare positionals, but each is a validated `RevsetExpr`, so a
1896        // leading-`-` one (e.g. `--ignore-working-copy`) can never reach the argv.
1897        let mut args: Vec<String> = vec!["new".into(), "-m".into(), message.into()];
1898        args.extend(parents.iter().map(|p| p.as_str().to_string()));
1899        self.core.run_unit(self.cmd_in(dir, args)).await
1900    }
1901
1902    async fn abandon(&self, dir: &Path, revset: &RevsetExpr) -> Result<()> {
1903        self.core
1904            .run_unit(self.cmd_in(dir, ["abandon", revset.as_str()]))
1905            .await
1906    }
1907
1908    async fn git_fetch_branch(&self, dir: &Path, branch: &BookmarkName) -> Result<()> {
1909        // `-b` is glob-matched, so `exact:` keeps a `*` branch from fetching
1910        // every branch instead of erroring on a bogus name.
1911        let branch_pat = exact(branch.as_str());
1912        // `c_locale`: the retry decision classifies the failure's message (M28).
1913        let cmd = c_locale(self.cmd_in(
1914            dir,
1915            [
1916                "git",
1917                "fetch",
1918                "--remote",
1919                "origin",
1920                "-b",
1921                branch_pat.as_str(),
1922            ],
1923        ))
1924        .timeout_grace(FETCH_TIMEOUT_GRACE)
1925        .retry(FETCH_ATTEMPTS, FETCH_BACKOFF, is_transient_fetch_error);
1926        self.core.run_unit(cmd).await
1927    }
1928
1929    async fn git_import(&self, dir: &Path) -> Result<()> {
1930        self.core
1931            .run_unit(self.cmd_in(dir, ["git", "import"]))
1932            .await
1933    }
1934
1935    async fn git_clone(&self, url: &str, dest: &Path, spec: GitClone) -> Result<()> {
1936        // A leading-`-` url is a bare positional — guard it (a real URL never
1937        // leads with `-`, so no false positives).
1938        reject_flag_like("url", url)?;
1939        // No working directory yet (the clone creates `dest`), so this builds
1940        // on the raw `command` and appends `--color never` at the end — the
1941        // `workspace_add` precedent for color-after-value-args. The colocate
1942        // flag is ALWAYS passed: jj's default flipped across versions and is
1943        // overridable via `git.colocate` config, so an omitted flag would make
1944        // `colocate: false` a lie on some setups.
1945        let command = self
1946            .core
1947            .command(["git", "clone", url])
1948            .arg(dest)
1949            .arg(if spec.colocate {
1950                "--colocate"
1951            } else {
1952                "--no-colocate"
1953            });
1954        // Graceful terminate-then-kill on a per-client timeout. No-op without a deadline.
1955        // `budget_diagnostics`: bound the retained clone progress/failure output (a
1956        // drop-oldest tail — never `OutputTooLarge`). Unbounded by default.
1957        let command = self.core.budget_diagnostics(
1958            command
1959                .arg("--color")
1960                .arg("never")
1961                .timeout_grace(FETCH_TIMEOUT_GRACE),
1962        );
1963
1964        // R7: like `vcs_git::clone_repo`, a failed clone can leave a partial `dest`
1965        // that blocks a retry ("destination already exists"); `timeout_grace` can't
1966        // prevent it (Windows' job-kill is atomic; the Unix grace is too short for a
1967        // multi-GB partial). Clean it via the shared `vcs_cli_support` helper — see its
1968        // docs for the "never touch a non-empty pre-existing dest" contract and why
1969        // `cleanable` must be computed before the clone runs.
1970        let cleanable = vcs_cli_support::clone_dest_cleanable(dest);
1971        let result = self.core.run_unit(command).await;
1972        if result.is_err() {
1973            vcs_cli_support::cleanup_failed_clone_dest(dest, cleanable);
1974        }
1975        result
1976    }
1977
1978    async fn absorb(
1979        &self,
1980        dir: &Path,
1981        from: Option<RevsetExpr>,
1982        filesets: &[JjFileset],
1983    ) -> Result<()> {
1984        let mut args: Vec<String> = vec!["absorb".into()];
1985        if let Some(from) = from.as_ref() {
1986            args.push("--from".into());
1987            args.push(from.as_str().into());
1988        }
1989        args.extend(filesets.iter().map(|f| f.as_str().to_string()));
1990        self.core.run_unit(self.cmd_in(dir, args)).await
1991    }
1992
1993    async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()> {
1994        // A fileset-less `jj split` opens the interactive diff editor — even
1995        // with `-m` — which would hang a headless run indefinitely. Refuse
1996        // before spawning anything.
1997        if filesets.is_empty() {
1998            return Err(Error::spawn(
1999                BINARY,
2000                std::io::Error::new(
2001                    std::io::ErrorKind::InvalidInput,
2002                    "split_paths requires at least one fileset — an empty split \
2003                     opens jj's interactive diff editor",
2004                ),
2005            ));
2006        }
2007        // `-m` doubles as the description-editor suppressor.
2008        let mut args: Vec<String> = vec!["split".into(), "-m".into(), message.into()];
2009        args.extend(filesets.iter().map(|f| f.as_str().to_string()));
2010        self.core.run_unit(self.cmd_in(dir, args)).await
2011    }
2012
2013    async fn duplicate(&self, dir: &Path, revset: &RevsetExpr) -> Result<()> {
2014        self.core
2015            .run_unit(self.cmd_in(dir, ["duplicate", revset.as_str()]))
2016            .await
2017    }
2018
2019    async fn op_head(&self, dir: &Path) -> Result<String> {
2020        self.core
2021            .run(self.cmd_in(
2022                dir,
2023                [
2024                    "op",
2025                    "log",
2026                    "--no-graph",
2027                    "--limit",
2028                    "1",
2029                    "-T",
2030                    "id.short()",
2031                ],
2032            ))
2033            .await
2034    }
2035
2036    async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>> {
2037        let limit = limit.to_string();
2038        self.core
2039            .parse(
2040                self.cmd_in(
2041                    dir,
2042                    [
2043                        "op",
2044                        "log",
2045                        "--no-graph",
2046                        "--limit",
2047                        limit.as_str(),
2048                        "-T",
2049                        parse::OP_TEMPLATE,
2050                    ],
2051                ),
2052                parse::parse_operations,
2053            )
2054            .await
2055    }
2056
2057    async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()> {
2058        reject_flag_like("operation id", op_id)?;
2059        self.core
2060            .run_unit(self.cmd_in(dir, ["op", "restore", op_id]))
2061            .await
2062    }
2063
2064    async fn op_undo(&self, dir: &Path) -> Result<()> {
2065        self.core.run_unit(self.cmd_in(dir, ["op", "undo"])).await
2066    }
2067
2068    async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>> {
2069        self.core
2070            .parse(
2071                self.cmd_in(dir, ["workspace", "list", "-T", parse::WORKSPACE_TEMPLATE]),
2072                parse::parse_workspaces,
2073            )
2074            .await
2075    }
2076
2077    async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf> {
2078        // Read-only: the root is static creation-time metadata, so this must not
2079        // snapshot the working copy — consistent with the batch `workspace_roots` (M10).
2080        let mut args: Vec<String> = vec![
2081            "--ignore-working-copy".into(),
2082            "workspace".into(),
2083            "root".into(),
2084        ];
2085        if let Some(n) = name.as_deref() {
2086            args.push("--name".into());
2087            args.push(n.to_string());
2088        }
2089        // `parse_bytes`: a workspace root path need not be valid UTF-8 on Unix, so
2090        // build the `PathBuf` from raw stdout bytes. The old `run` decoded stdout
2091        // through `String::from_utf8_lossy`, which would flatten a non-UTF-8 root to
2092        // `U+FFFD` and mis-address the workspace it feeds the facade's
2093        // `WorktreeInfo.path`. Read-only like the previous `run` (no lock-retry —
2094        // `--ignore-working-copy`, so lock contention is not a concern).
2095        self.core
2096            .parse_bytes(self.cmd_in(dir, args), parse::workspace_root_from_bytes)
2097            .await
2098    }
2099
2100    async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()> {
2101        // Built directly on `command_in` (not `cmd_in`) because the trailing
2102        // `--color never` must come after the chained value args, not between
2103        // `--name` and its value.
2104        let mut command = self
2105            .core
2106            .command_in(dir, ["workspace", "add", "--name"])
2107            .arg(&spec.name)
2108            .arg("-r")
2109            .arg(spec.base.as_str());
2110        if let Some(mode) = spec.sparse_patterns {
2111            command = command.arg("--sparse-patterns").arg(mode.as_arg());
2112        }
2113        command = command.arg(&spec.path).arg("--color").arg("never");
2114        self.core.run_unit(command).await
2115    }
2116
2117    async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()> {
2118        reject_flag_like("workspace name", name)?;
2119        self.core
2120            .run_unit(self.cmd_in(dir, ["workspace", "forget", name]))
2121            .await
2122    }
2123}
2124
2125/// Total attempts / fixed backoff for a transient-retried fetch — the shared
2126/// policy from `vcs-cli-support`, aliased so the retry call sites read locally.
2127const FETCH_ATTEMPTS: u32 = vcs_cli_support::FETCH_ATTEMPTS;
2128const FETCH_BACKOFF: Duration = vcs_cli_support::FETCH_BACKOFF;
2129const FETCH_TIMEOUT_GRACE: Duration = vcs_cli_support::FETCH_TIMEOUT_GRACE;
2130
2131/// How many `jj workspace root` lookups [`Jj::workspace_roots`] keeps in flight at
2132/// once — a cap so a repo with many workspaces doesn't spawn an unbounded burst of
2133/// processes, while still overlapping the (fast, network-free) calls.
2134const WORKSPACE_ROOTS_CONCURRENCY: usize = 8;
2135
2136/// The dedicated deadline the concurrency-safe rollback ([`Jj::rollback_to`])
2137/// bounds each of its own commands with. Set explicitly (not inherited) so a
2138/// cleanup that follows a *cancelled or timed-out* operation still runs on a full,
2139/// fresh budget rather than a spent one — a local `op log` / `op restore` is quick,
2140/// so this is a generous ceiling, not a tight bound.
2141const ROLLBACK_TIMEOUT: Duration = Duration::from_secs(30);
2142
2143/// How many recent operations the divergence probe ([`Jj::rollback_to`]) walks back
2144/// through, as jj's `--limit`, looking for the captured pre-operation. Generous — a
2145/// single failed transaction records a handful of operations — so an honest rollback
2146/// is only ever refused for genuine divergence, not for depth. If the captured
2147/// operation is not within this window the probe treats the range as unverifiable
2148/// and refuses to revert (see [`Rollback::SkippedDiverged`]).
2149const ROLLBACK_PROBE_LIMIT: &str = "256";
2150
2151/// What the concurrency-safe op-log rollback did after a mutation failed — the
2152/// outcome [`Jj::rollback_to`] returns and [`Jj::transaction`] reports on its
2153/// [`TransactionError`]. It lets a caller tell a completed rollback apart from one
2154/// that was deliberately **refused** (a concurrent process's work would have been
2155/// clobbered) or one that **failed**, instead of guessing by re-probing the op log.
2156#[derive(Debug)]
2157#[non_exhaustive]
2158pub enum Rollback {
2159    /// The repo is back at the captured operation — either `op restore` ran, or the
2160    /// closure failed before recording any operation, so nothing needed undoing.
2161    Restored,
2162    /// The rollback was **skipped** to avoid clobbering a concurrent process: the
2163    /// operation log diverged between the capture and the restore (jj reconciled a
2164    /// foreign operation with a "reconcile divergent operations" merge), so restoring
2165    /// to the captured operation would have silently reverted that work. The repo is
2166    /// left as the closure and the other process left it; the caller must reconcile.
2167    /// Also returned when the captured operation is no longer within the probed
2168    /// window (`ROLLBACK_PROBE_LIMIT` operations), so the range cannot be confirmed
2169    /// safe to revert.
2170    SkippedDiverged,
2171    /// The rollback itself failed — the divergence probe or the `op restore` errored.
2172    /// The repo may be left mid-transaction; the carried [`Error`] is the cause.
2173    Failed(Error),
2174    /// No rollback was attempted: the transaction failed before it captured a
2175    /// savepoint (e.g. the initial [`op_head`](JjApi::op_head) capture itself failed),
2176    /// so there was nothing to roll back.
2177    NotAttempted,
2178}
2179
2180impl Rollback {
2181    /// Whether the repo was returned to (or already at) the captured operation.
2182    pub fn is_restored(&self) -> bool {
2183        matches!(self, Rollback::Restored)
2184    }
2185
2186    /// The error, when the rollback itself [`Failed`](Rollback::Failed); `None`
2187    /// otherwise. Reads it off without destructuring the `#[non_exhaustive]` enum.
2188    pub fn failure(&self) -> Option<&Error> {
2189        match self {
2190            Rollback::Failed(err) => Some(err),
2191            _ => None,
2192        }
2193    }
2194}
2195
2196impl std::fmt::Display for Rollback {
2197    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2198        match self {
2199            Rollback::Restored => f.write_str("rolled back to the captured operation"),
2200            Rollback::SkippedDiverged => f.write_str(
2201                "rollback skipped: the operation log diverged (a concurrent jj process \
2202                 advanced it), so reverting was refused to avoid clobbering that work",
2203            ),
2204            Rollback::Failed(err) => write!(f, "rollback failed: {err}"),
2205            Rollback::NotAttempted => f.write_str("no rollback was attempted"),
2206        }
2207    }
2208}
2209
2210/// The error [`Jj::transaction`] returns when its closure fails. It preserves the
2211/// closure's own error in [`cause`](Self::cause) — the same value the previous
2212/// (rollback-swallowing) `Result<T>` contract returned — and additionally records
2213/// what the concurrency-safe rollback did in [`rollback`](Self::rollback), so a
2214/// failed or refused rollback is **visible** to the caller instead of silently
2215/// dropped (the earlier `let _ = op_restore(..)` discarded it).
2216///
2217/// Match [`rollback`](Self::rollback) to distinguish `Restored` / `SkippedDiverged`
2218/// / `Failed`; call [`into_cause`](Self::into_cause) for a drop-in of the old
2219/// "closure error only" behavior.
2220#[derive(Debug)]
2221#[non_exhaustive]
2222pub struct TransactionError {
2223    /// The error the closure returned — the transaction's root cause.
2224    pub cause: Error,
2225    /// What the concurrency-safe rollback did in response to `cause`.
2226    pub rollback: Rollback,
2227}
2228
2229impl TransactionError {
2230    /// The closure's error — the transaction's root cause (what the old `Result<T>`
2231    /// contract returned).
2232    pub fn cause(&self) -> &Error {
2233        &self.cause
2234    }
2235
2236    /// What the rollback did — [`Restored`](Rollback::Restored) /
2237    /// [`SkippedDiverged`](Rollback::SkippedDiverged) / [`Failed`](Rollback::Failed).
2238    pub fn rollback(&self) -> &Rollback {
2239        &self.rollback
2240    }
2241
2242    /// Consume, returning just the closure's error — the drop-in for code that only
2243    /// wants the old "closure error" and does not act on the rollback outcome.
2244    pub fn into_cause(self) -> Error {
2245        self.cause
2246    }
2247}
2248
2249impl std::fmt::Display for TransactionError {
2250    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
2251        write!(f, "transaction failed: {} ({})", self.cause, self.rollback)
2252    }
2253}
2254
2255impl std::error::Error for TransactionError {
2256    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
2257        // The closure's error is the root cause; a rollback failure (if any) is
2258        // reachable structurally through `self.rollback`.
2259        Some(&self.cause)
2260    }
2261}
2262
2263/// The rollback decision derived from the op-log divergence probe (`rows`, newest
2264/// first) and the captured pre-operation `pre`. Walking from the current head toward
2265/// `pre`: a `>= 2`-parent operation seen *before* reaching `pre` is a concurrent
2266/// "reconcile divergent operations" merge (foreign work) → refuse; reaching `pre`
2267/// means the range is our own linear work → restore; not finding `pre` within the
2268/// probed window means the range can't be confirmed safe → refuse (conservative:
2269/// never blindly revert what we can't verify).
2270enum RollbackPlan {
2271    Restore,
2272    SkipDiverged,
2273}
2274
2275fn rollback_plan(rows: &[(String, usize)], pre: &str) -> RollbackPlan {
2276    for (id, parents) in rows {
2277        if id == pre {
2278            // Reached the savepoint with only our own single-parent ops in between.
2279            return RollbackPlan::Restore;
2280        }
2281        if *parents >= 2 {
2282            // A reconcile-divergent-operations merge landed after `pre`: a
2283            // concurrent process advanced the op log. Do not clobber it.
2284            return RollbackPlan::SkipDiverged;
2285        }
2286    }
2287    RollbackPlan::SkipDiverged
2288}
2289
2290impl<R: ProcessRunner> Jj<R> {
2291    /// Run `jj <args>` over string slices — `jj.run_args(&["log", "-r", "@"])`
2292    /// without allocating a `Vec<String>`. Inherent (not on the object-safe
2293    /// trait), so it can take `&[&str]`; forwards to the same path as
2294    /// [`JjApi::run`].
2295    pub async fn run_args(&self, args: &[&str]) -> Result<String> {
2296        self.core.run(args).await
2297    }
2298
2299    /// Resolve several workspaces' root paths in one **bounded fan-out** — one
2300    /// `jj workspace root --name <n>` per name, at most
2301    /// `WORKSPACE_ROOTS_CONCURRENCY` (8) live at a time — instead of awaiting each in
2302    /// turn. Per-name `Ok`/`Err` mirrors [`workspace_root`](JjApi::workspace_root)
2303    /// (a non-zero exit or spawn failure → `Err`); results come back in `names`
2304    /// order. Runs through this client's own runner, so a `ScriptedRunner` test
2305    /// drives it hermetically. Inherent (not on the object-safe trait): it's a
2306    /// throughput shape over the trait method, and the batch primitive isn't a
2307    /// mockable per-call seam.
2308    pub async fn workspace_roots(&self, dir: &Path, names: &[String]) -> Vec<Result<PathBuf>> {
2309        // `--ignore-working-copy`: read-only metadata probe (often on the Drop-cleanup
2310        // path), so it must not snapshot/lock the working copy (M10).
2311        let commands = names.iter().map(|n| {
2312            self.cmd_in(
2313                dir,
2314                [
2315                    "--ignore-working-copy",
2316                    "workspace",
2317                    "root",
2318                    "--name",
2319                    n.as_str(),
2320                ],
2321            )
2322        });
2323        // `output_all_bytes` (not `output_all`): a workspace root path need not be
2324        // valid UTF-8 on Unix, so capture raw stdout and build the `PathBuf` from
2325        // bytes — a lossy `String` decode would flatten a non-UTF-8 root to `U+FFFD`.
2326        processkit::output_all_bytes(commands, WORKSPACE_ROOTS_CONCURRENCY, self.core.runner())
2327            .await
2328            .into_iter()
2329            .map(|r| {
2330                r.and_then(|pr| pr.ensure_success())
2331                    // Raw bytes → `PathBuf`, lossless on Unix — exact parity with the
2332                    // single `workspace_root` (both go through `workspace_root_from_bytes`,
2333                    // which strips only the trailing line terminator jj appends).
2334                    .map(|pr| parse::workspace_root_from_bytes(pr.stdout()))
2335            })
2336            .collect()
2337    }
2338
2339    /// Like [`run_args`](Jj::run_args) but never errors on a non-zero exit
2340    /// (mirrors [`JjApi::run_raw`]).
2341    pub async fn run_raw_args(&self, args: &[&str]) -> Result<ProcessResult<String>> {
2342        self.core.output_string(args).await
2343    }
2344
2345    /// Run `jj <args>` **in `dir`** (the process is spawned with `dir` as its
2346    /// working directory), returning trimmed stdout — the dir-bound twin of the
2347    /// process-cwd [`run`](JjApi::run). This is what [`JjAt::run`] forwards to; call
2348    /// [`run`](JjApi::run) on the client for the process-cwd escape hatch. Argv is
2349    /// forwarded verbatim (the same unguarded escape hatch — only the working
2350    /// directory is bound; unlike the modelled methods it does **not** inject
2351    /// `--color never`).
2352    pub async fn run_in(&self, dir: &Path, args: &[String]) -> Result<String> {
2353        self.core.run(self.core.command_in(dir, args)).await
2354    }
2355
2356    /// Like [`run_in`](Jj::run_in) but never errors on a non-zero exit — the
2357    /// dir-bound twin of [`run_raw`](JjApi::run_raw). What [`JjAt::run_raw`]
2358    /// forwards to.
2359    pub async fn run_raw_in(&self, dir: &Path, args: &[String]) -> Result<ProcessResult<String>> {
2360        self.core
2361            .output_string(self.core.command_in(dir, args))
2362            .await
2363    }
2364
2365    /// Like [`run_args`](Jj::run_args) but **bound to `dir`** — the `&[&str]` twin
2366    /// of [`run_in`](Jj::run_in). What [`JjAt::run_args`] forwards to.
2367    pub async fn run_args_in(&self, dir: &Path, args: &[&str]) -> Result<String> {
2368        self.core.run(self.core.command_in(dir, args)).await
2369    }
2370
2371    /// Like [`run_raw_args`](Jj::run_raw_args) but **bound to `dir`** — the
2372    /// `&[&str]` twin of [`run_raw_in`](Jj::run_raw_in). What [`JjAt::run_raw_args`]
2373    /// forwards to.
2374    pub async fn run_raw_args_in(
2375        &self,
2376        dir: &Path,
2377        args: &[&str],
2378    ) -> Result<ProcessResult<String>> {
2379        self.core
2380            .output_string(self.core.command_in(dir, args))
2381            .await
2382    }
2383
2384    /// Bind this client to `dir`, returning a [`JjAt`] handle whose methods omit
2385    /// the `dir` argument: `jj.at(dir).status()` runs [`status`](JjApi::status)
2386    /// against `dir`. The dir-taking [`JjApi`] methods stay on [`Jj`] for driving
2387    /// many directories (e.g. workspaces) from one client.
2388    pub fn at<'a>(&'a self, dir: &'a Path) -> JjAt<'a, R> {
2389        JjAt { jj: self, dir }
2390    }
2391
2392    /// Build a repo-scoped `jj` command for the rollback **cleanup** that does
2393    /// **not** inherit this client's [`default_cancel_on`](Jj::default_cancel_on)
2394    /// token and carries its own bounded [`ROLLBACK_TIMEOUT`] deadline.
2395    ///
2396    /// [`cmd_in`](Self::cmd_in) gap-fills the client's cancel token; overriding it
2397    /// here with a *fresh, never-fired* token means an already-fired cancellation of
2398    /// the failed operation cannot also short-circuit the cleanup (the defect the old
2399    /// `transaction` documented). The explicit timeout gives the cleanup a full fresh
2400    /// budget even after a cancelled/timed-out main operation.
2401    fn rollback_cmd_in<I, S>(&self, dir: &Path, args: I) -> processkit::Command
2402    where
2403        I: IntoIterator<Item = S>,
2404        S: AsRef<std::ffi::OsStr>,
2405    {
2406        self.cmd_in(dir, args)
2407            .cancel_on(CancellationToken::new())
2408            .timeout(ROLLBACK_TIMEOUT)
2409    }
2410
2411    /// The divergence probe: the recent operation log as `(id, parent-count)` pairs
2412    /// (newest first), read on the detached cleanup context with
2413    /// `--ignore-working-copy` so the *probe itself* records no snapshot operation.
2414    async fn op_log_parents_probe(&self, dir: &Path) -> Result<Vec<(String, usize)>> {
2415        let out = self
2416            .core
2417            .run(self.rollback_cmd_in(
2418                dir,
2419                [
2420                    "op",
2421                    "log",
2422                    "--no-graph",
2423                    "--ignore-working-copy",
2424                    "--limit",
2425                    ROLLBACK_PROBE_LIMIT,
2426                    "-T",
2427                    parse::OP_PARENTS_TEMPLATE,
2428                ],
2429            ))
2430            .await?;
2431        Ok(parse::parse_op_parents(&out))
2432    }
2433
2434    /// `op restore <op_id>` on the detached cleanup context (see
2435    /// [`rollback_cmd_in`](Self::rollback_cmd_in)), keeping the flag-like guard the
2436    /// public [`op_restore`](JjApi::op_restore) applies.
2437    async fn op_restore_detached(&self, dir: &Path, op_id: &str) -> Result<()> {
2438        reject_flag_like("operation id", op_id)?;
2439        self.core
2440            .run_unit(self.rollback_cmd_in(dir, ["op", "restore", op_id]))
2441            .await
2442    }
2443
2444    /// Roll the repo back to `pre` — an operation id captured with
2445    /// [`op_head`](JjApi::op_head) **before** a mutation — after that mutation failed.
2446    /// This is the rollback [`transaction`](Self::transaction) runs, exposed for the
2447    /// non-closure / FFI callers the transaction docs point at.
2448    ///
2449    /// Unlike a bare [`op_restore`](JjApi::op_restore) back to `pre`, it:
2450    /// - runs every cleanup command on a **fresh cancellation context** with its own
2451    ///   `ROLLBACK_TIMEOUT` deadline, so a *cancelled or timed-out* mutation does
2452    ///   not also cancel the cleanup (a fired
2453    ///   [`default_cancel_on`](Jj::default_cancel_on) token is not inherited);
2454    /// - **detects a concurrent op-log divergence** first — if another jj process
2455    ///   advanced the operation log between the capture and now (jj records a
2456    ///   "reconcile divergent operations" merge), reverting to `pre` would silently
2457    ///   discard that foreign work, so it is **refused** and
2458    ///   [`Rollback::SkippedDiverged`] is returned instead of clobbering it.
2459    ///
2460    /// Never returns `Err`: a failure of the probe or the `op restore` is reported as
2461    /// [`Rollback::Failed`], so the caller composes the rollback outcome with the
2462    /// mutation's own error rather than having one mask the other.
2463    pub async fn rollback_to(&self, dir: &Path, pre: &str) -> Rollback {
2464        match self.op_log_parents_probe(dir).await {
2465            Err(err) => Rollback::Failed(err),
2466            Ok(rows) => match rollback_plan(&rows, pre) {
2467                RollbackPlan::SkipDiverged => Rollback::SkippedDiverged,
2468                RollbackPlan::Restore => match self.op_restore_detached(dir, pre).await {
2469                    Ok(()) => Rollback::Restored,
2470                    Err(err) => Rollback::Failed(err),
2471                },
2472            },
2473        }
2474    }
2475
2476    /// Run a mutation sequence with concurrency-safe op-log rollback: capture the
2477    /// current operation ([`op_head`](JjApi::op_head)), run `f` with a [`JjAt`] bound
2478    /// to `dir`, and on `Err` roll the repo back to the captured operation via
2479    /// [`rollback_to`](Self::rollback_to) — reporting what the rollback did on the
2480    /// returned [`TransactionError`].
2481    ///
2482    /// ```no_run
2483    /// # async fn demo(jj: &vcs_jj::Jj) -> Result<(), vcs_jj::TransactionError> {
2484    /// jj.transaction(std::path::Path::new("."), |tx| async move {
2485    ///     tx.describe("wip").await?;
2486    ///     tx.new_change("next").await // an Err here rolls back the describe
2487    /// })
2488    /// .await?;
2489    /// # Ok(()) }
2490    /// ```
2491    ///
2492    /// On the closure's `Err`, the returned [`TransactionError`] preserves that error
2493    /// in [`cause`](TransactionError::cause) **and** carries the
2494    /// [`rollback`](TransactionError::rollback) outcome — so a failed
2495    /// ([`Rollback::Failed`]) or refused ([`Rollback::SkippedDiverged`]) rollback is
2496    /// visible, not swallowed as it was before. Callers wanting only the previous
2497    /// "closure error" behavior use [`TransactionError::into_cause`].
2498    ///
2499    /// Inherent (not on the object-safe trait): the closure parameter is
2500    /// generic, which `mockall` / trait objects can't express.
2501    ///
2502    /// Caveats:
2503    /// - **Single-actor, but no longer silent about it.** The rollback restores the
2504    ///   whole repo view to the captured operation, so it is meant for a span *one*
2505    ///   actor drives. If another jj process advances the op log in the meantime, the
2506    ///   rollback now **detects** the divergence and **refuses** to revert (returning
2507    ///   [`Rollback::SkippedDiverged`]) rather than silently reverting that foreign
2508    ///   work — the caller is told, and must reconcile.
2509    /// - Rollback runs on `Err` only — **not** on panic or cancellation (a
2510    ///   dropped future); there is no async `Drop`. Convert panics to `Err`
2511    ///   inside `f` if you need that safety.
2512    /// - **A cancelled `f` no longer cancels the rollback.** The cleanup runs on a
2513    ///   fresh cancellation context with its own deadline (see
2514    ///   [`rollback_to`](Self::rollback_to)), so a *fired* cancellation of `f` (on a
2515    ///   client built with [`default_cancel_on`](Jj::default_cancel_on)) does not
2516    ///   short-circuit the restore.
2517    /// - If the restore itself fails, the closure's error is still returned as
2518    ///   [`cause`](TransactionError::cause) and the failure is surfaced as
2519    ///   [`Rollback::Failed`] (no longer discarded); the repo may be left
2520    ///   mid-transaction.
2521    ///
2522    /// **Non-closure / FFI callers**: the borrowed [`JjAt`] and the `'a`-bound
2523    /// future this closure form takes don't cross an FFI boundary cleanly, so a
2524    /// language binding replicates the rollback with the public primitives this
2525    /// method wraps — capture [`op_head`](JjApi::op_head) before the mutations, run
2526    /// them (through a [`JjAt`] or the dir-taking methods), then on failure call
2527    /// [`rollback_to`](Self::rollback_to) with the captured id (it applies the same
2528    /// cancellation-safe, divergence-checked protocol). [`op_head`](JjApi::op_head)
2529    /// is on the object-safe [`JjApi`], so the capture also works through
2530    /// `&dyn JjApi`; `rollback_to` is inherent on [`Jj`].
2531    pub async fn transaction<'a, T, F, Fut>(
2532        &'a self,
2533        dir: &'a Path,
2534        f: F,
2535    ) -> std::result::Result<T, TransactionError>
2536    where
2537        F: FnOnce(JjAt<'a, R>) -> Fut,
2538        Fut: Future<Output = Result<T>> + 'a,
2539    {
2540        let pre = match self.op_head(dir).await {
2541            Ok(pre) => pre,
2542            // The savepoint capture failed before `f` ran, so nothing was mutated
2543            // and there is nothing to roll back.
2544            Err(cause) => {
2545                return Err(TransactionError {
2546                    cause,
2547                    rollback: Rollback::NotAttempted,
2548                });
2549            }
2550        };
2551        match f(self.at(dir)).await {
2552            Ok(value) => Ok(value),
2553            Err(cause) => {
2554                let rollback = self.rollback_to(dir, &pre).await;
2555                Err(TransactionError { cause, rollback })
2556            }
2557        }
2558    }
2559}
2560
2561/// A [`Jj`] client with a working directory bound, so calls drop the leading
2562/// `dir` argument — `jj.at(dir).status()` is `jj.status(dir)`. Construct one with
2563/// [`Jj::at`] (or, through the facade, `vcs_core::Repo::jj_at`). Cheap to copy: it
2564/// only borrows the client and the path.
2565pub struct JjAt<'a, R: ProcessRunner = processkit::JobRunner> {
2566    jj: &'a Jj<R>,
2567    dir: &'a Path,
2568}
2569
2570// Hand-written rather than derived: holding only references, the view is `Copy`
2571// for *every* runner. `#[derive(Copy)]` would add a spurious `R: Copy` bound the
2572// default `JobRunner` doesn't satisfy, silently dropping `Copy` on the production
2573// handle.
2574impl<R: ProcessRunner> Clone for JjAt<'_, R> {
2575    fn clone(&self) -> Self {
2576        *self
2577    }
2578}
2579impl<R: ProcessRunner> Copy for JjAt<'_, R> {}
2580
2581// Generate [`JjAt`] forwarders from a method list: `bare` methods forward
2582// verbatim, `dir` methods inject `self.dir` as the first argument. The shared
2583// macro lives in `vcs-cli-support` (see `vcs_cli_support::at_forwarders!`).
2584vcs_cli_support::at_forwarders! {
2585    JjAt, jj, "Jj",
2586    bare {
2587        fn version() -> Result<String>;
2588        fn capabilities() -> Result<JjCapabilities>;
2589        fn git_clone(url: &str, dest: &Path, spec: GitClone) -> Result<()>;
2590    }
2591    dir {
2592        fn status() -> Result<Vec<ChangedPath>>;
2593        fn status_text() -> Result<String>;
2594        fn log(revset: &RevsetExpr, max: usize) -> Result<Vec<Change>>;
2595        fn log_paths(revset: &RevsetExpr, max: usize, filesets: &[JjFileset]) -> Result<Vec<Change>>;
2596        fn current_change() -> Result<Change>;
2597        fn describe(message: &str) -> Result<()>;
2598        fn describe_rev(revset: &RevsetExpr, message: &str) -> Result<()>;
2599        fn new_change(message: &str) -> Result<()>;
2600        fn new_child(parent: &RevsetExpr) -> Result<()>;
2601        fn bookmarks() -> Result<Vec<Bookmark>>;
2602        fn bookmarks_all() -> Result<Vec<BookmarkRef>>;
2603        fn reachable_bookmarks() -> Result<Vec<Bookmark>>;
2604        fn bookmark_track(name: &BookmarkName, remote: &str) -> Result<()>;
2605        fn bookmark_set(name: &BookmarkName, revision: &RevsetExpr) -> Result<()>;
2606        fn git_fetch() -> Result<()>;
2607        fn git_fetch_from(remote: &str) -> Result<()>;
2608        fn git_push(bookmark: Option<BookmarkName>) -> Result<()>;
2609        fn root() -> Result<PathBuf>;
2610        fn current_bookmark() -> Result<Option<String>>;
2611        fn trunk() -> Result<Option<String>>;
2612        fn bookmark_create(name: &BookmarkName, revision: &RevsetExpr) -> Result<()>;
2613        fn bookmark_rename(old: &BookmarkName, new: &BookmarkName) -> Result<()>;
2614        fn bookmark_delete(name: &BookmarkName) -> Result<()>;
2615        fn bookmark_move(spec: BookmarkMove) -> Result<()>;
2616        fn diff_summary(from: &RevsetExpr, to: &RevsetExpr) -> Result<Vec<ChangedPath>>;
2617        fn diff_stat(revset: &RevsetExpr) -> Result<DiffStat>;
2618        fn diff_text(spec: DiffSpec) -> Result<String>;
2619        fn diff(spec: DiffSpec) -> Result<Vec<FileDiff>>;
2620        fn commit_count(revset: &RevsetExpr) -> Result<usize>;
2621        fn is_conflicted(revset: &RevsetExpr) -> Result<bool>;
2622        fn has_workingcopy_conflict() -> Result<bool>;
2623        fn resolve_list(revset: &RevsetExpr) -> Result<Vec<PathBuf>>;
2624        fn template_query(revset: &RevsetExpr, template: &str, limit: Option<usize>) -> Result<String>;
2625        fn description(revset: &RevsetExpr) -> Result<String>;
2626        fn evolog(revset: &RevsetExpr, max: usize) -> Result<Vec<Change>>;
2627        fn file_annotate(path: &str, revset: Option<RevsetExpr>) -> Result<Vec<AnnotationLine>>;
2628        fn file_show(revset: &RevsetExpr, path: &str) -> Result<String>;
2629        fn absorb(from: Option<RevsetExpr>, filesets: &[JjFileset]) -> Result<()>;
2630        fn split_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
2631        fn duplicate(revset: &RevsetExpr) -> Result<()>;
2632        fn rebase(onto: &RevsetExpr) -> Result<()>;
2633        fn rebase_branch(branch: &RevsetExpr, dest: &RevsetExpr) -> Result<()>;
2634        fn edit(revset: &RevsetExpr) -> Result<()>;
2635        fn squash_into(spec: SquashInto) -> Result<()>;
2636        fn commit_paths(filesets: &[JjFileset], message: &str) -> Result<()>;
2637        fn squash_paths(spec: SquashPaths) -> Result<()>;
2638        fn sparse_set(patterns: &[String]) -> Result<()>;
2639        fn new_merge(message: &str, parents: Vec<RevsetExpr>) -> Result<()>;
2640        fn abandon(revset: &RevsetExpr) -> Result<()>;
2641        fn git_fetch_branch(branch: &BookmarkName) -> Result<()>;
2642        fn git_import() -> Result<()>;
2643        fn op_head() -> Result<String>;
2644        fn op_log(limit: usize) -> Result<Vec<Operation>>;
2645        fn op_restore(op_id: &str) -> Result<()>;
2646        fn op_undo() -> Result<()>;
2647        fn workspace_list() -> Result<Vec<Workspace>>;
2648        fn workspace_root(name: Option<String>) -> Result<PathBuf>;
2649        fn workspace_add(spec: WorkspaceAdd) -> Result<()>;
2650        fn workspace_forget(name: &str) -> Result<()>;
2651    }
2652    // Raw escape hatches: bound to `self.dir` (forward to the client's `*_in`
2653    // twins) so `jj.at(dir).run(…)` runs in the bound repo, not the process cwd.
2654    // For the process-cwd hatch call `run`/`run_raw`/… on `Jj` directly.
2655    raw {
2656        fn run(args: &[String]) -> Result<String> => run_in;
2657        fn run_raw(args: &[String]) -> Result<ProcessResult<String>> => run_raw_in;
2658        fn run_args(args: &[&str]) -> Result<String> => run_args_in;
2659        fn run_raw_args(args: &[&str]) -> Result<ProcessResult<String>> => run_raw_args_in;
2660    }
2661}
2662
2663// Manual forwarder: `transaction` takes a generic closure, which the declarative
2664// forwarder macro (fixed argument lists) cannot express.
2665impl<'a, R: ProcessRunner> JjAt<'a, R> {
2666    /// Bound form of [`Jj::transaction`] (with `dir` pre-bound): run `f` with
2667    /// concurrency-safe op-log rollback on `Err`. See [`Jj::transaction`] for the
2668    /// [`TransactionError`] contract and the caveats.
2669    pub async fn transaction<T, F, Fut>(&self, f: F) -> std::result::Result<T, TransactionError>
2670    where
2671        F: FnOnce(JjAt<'a, R>) -> Fut,
2672        Fut: Future<Output = Result<T>> + 'a,
2673    {
2674        self.jj.transaction(self.dir, f).await
2675    }
2676}
2677
2678/// Normalise a path for comparison against jj's `workspace root` output:
2679/// canonicalize (resolve symlinks / macOS case) and strip the Windows
2680/// verbatim prefix (`\\?\…`, which `canonicalize` adds but jj never emits). A
2681/// path that doesn't exist (or otherwise fails to canonicalize — e.g. a
2682/// worktree directory already removed) falls back to its own literal form.
2683///
2684/// Shared by [`workspace_root_matches`] and `vcs-core`'s async worktree-removal
2685/// path, so the two resolvers normalise identically (T-080).
2686pub fn normalize_workspace_root(p: &Path) -> PathBuf {
2687    let canonical = p.canonicalize().unwrap_or_else(|_| p.to_path_buf());
2688    #[cfg(windows)]
2689    {
2690        return strip_windows_verbatim_prefix(canonical);
2691    }
2692    #[cfg(not(windows))]
2693    canonical
2694}
2695
2696/// Convert Windows' verbatim path spelling into the spelling emitted by jj.
2697/// `std::fs::canonicalize` prefixes local paths with `\\?\` and UNC paths with
2698/// `\\?\UNC\`; jj's workspace metadata uses ordinary local/UNC paths instead.
2699#[cfg(windows)]
2700fn strip_windows_verbatim_prefix(path: PathBuf) -> PathBuf {
2701    let path = path.to_string_lossy();
2702    if let Some(rest) = path.strip_prefix(r"\\?\UNC\") {
2703        PathBuf::from(format!(r"\\{rest}"))
2704    } else if let Some(rest) = path.strip_prefix(r"\\?\") {
2705        PathBuf::from(rest.to_string())
2706    } else {
2707        PathBuf::from(path.to_string())
2708    }
2709}
2710
2711/// Whether a workspace whose `jj workspace root` resolved to `root` is the
2712/// workspace requested at `path`. **The single comparison set** shared by both
2713/// jj-workspace-by-path resolvers in this workspace — `vcs-core`'s async
2714/// `Repo::remove_worktree` path (`jj_backend::workspace_name_for_path`) and
2715/// this crate's synchronous [`blocking::workspace_name_for_path`] (the `Drop`
2716/// path) — so "does this path resolve to a workspace" answers the same
2717/// question on both sides. The two used to carry independently-maintained,
2718/// already-diverged comparison sets (T-080); this is their union, so a path
2719/// either side used to resolve still resolves:
2720///
2721/// - the canonicalised `root` against the canonicalised `path` (handles a
2722///   symlink / `.`/`..` detour / case difference in either);
2723/// - the raw `root` against the canonicalised `path` (handles a `root` that
2724///   itself failed to canonicalize but is already in `path`'s resolved form);
2725/// - the raw `root` against the raw `path` (handles a `path` that failed to
2726///   canonicalize — e.g. it no longer exists — falling back to literal
2727///   equality).
2728pub fn workspace_root_matches(root: &Path, path: &Path) -> bool {
2729    let target = normalize_workspace_root(path);
2730    normalize_workspace_root(root) == target || root == target || root == path
2731}
2732
2733/// Synchronous, best-effort helpers for contexts that cannot `.await` — chiefly
2734/// a `Drop` guard. They shell out through `std::process` directly (no async, no
2735/// job-containment), so reserve them for short-lived cleanup.
2736pub mod blocking {
2737    use std::io;
2738    use std::path::{Path, PathBuf};
2739    use std::process::Command;
2740
2741    /// Forget a workspace synchronously (`jj workspace forget <name>`).
2742    pub fn workspace_forget(dir: &Path, name: &str) -> std::io::Result<()> {
2743        let status = Command::new(super::BINARY)
2744            .current_dir(dir)
2745            .args(["workspace", "forget", name])
2746            .status()?;
2747        if status.success() {
2748            Ok(())
2749        } else {
2750            Err(std::io::Error::other(format!(
2751                "`jj workspace forget` exited with {status}"
2752            )))
2753        }
2754    }
2755
2756    /// Resolve the workspace *name* whose root matches `path`, synchronously —
2757    /// for `Drop`, which can't `.await` the typed `workspace_list`/`workspace_root`.
2758    /// Lists workspaces (`workspace list -T name`), then matches each
2759    /// `workspace root --name <n>` against `path` (canonicalised, Windows
2760    /// verbatim-prefix stripped).
2761    ///
2762    /// The three outcomes are kept **distinct** so a `Drop` caller no longer has to
2763    /// treat "the probe failed" as "no such workspace" — the old `Option` return
2764    /// folded both into `None`, silently skipping cleanup that a real failure should
2765    /// have surfaced (and hiding a workspace that *is* registered but couldn't be
2766    /// placed):
2767    /// - `Ok(Some(name))` — a registered workspace's root matched `path`.
2768    /// - `Ok(None)` — jj listed the workspaces cleanly and none matched `path`: a
2769    ///   genuine miss, so the caller safely skips the forget (nothing to clean up).
2770    /// - `Err(_)` — the probe itself could not answer: `jj` was missing / failed to
2771    ///   spawn, `workspace list` exited non-zero, or one or more *registered*
2772    ///   workspaces did not resolve via `workspace root --name` (so `path`'s absence
2773    ///   can't be proven). The caller can report it instead of silently doing nothing.
2774    pub fn workspace_name_for_path(dir: &Path, path: &Path) -> io::Result<Option<String>> {
2775        let out = Command::new(super::BINARY)
2776            .current_dir(dir)
2777            // `--ignore-working-copy`: this is a **read-only** probe run from a Drop
2778            // guard, so it must NOT snapshot the working copy — a plain `workspace
2779            // list` takes the working-copy lock and writes a snapshot op (M10),
2780            // mutating the very repo being cleaned up and failing (→ leak) under lock
2781            // contention. The workspace list/root are static metadata, unaffected.
2782            // `--color never`: this raw probe bypasses `cmd_in`, so pin it here too
2783            // — `ui.color = "always"` would otherwise wrap names in ANSI escapes
2784            // and break the name->root match below (leaking the workspace on Drop).
2785            .args([
2786                "--ignore-working-copy",
2787                "workspace",
2788                "list",
2789                "-T",
2790                "name ++ \"\\n\"",
2791                "--color",
2792                "never",
2793            ])
2794            .output()?;
2795        if !out.status.success() {
2796            return Err(io::Error::other(format!(
2797                "`jj workspace list` exited with {} while resolving the workspace at {}",
2798                out.status,
2799                path.display(),
2800            )));
2801        }
2802        // Registered workspaces whose root did not resolve via `workspace root
2803        // --name` — remembered so a no-match doesn't silently hide a workspace we
2804        // merely failed to place (it may be the very one at `path`).
2805        let mut unresolved: Vec<String> = Vec::new();
2806        for name in String::from_utf8_lossy(&out.stdout).lines() {
2807            let name = name.trim();
2808            if name.is_empty() {
2809                continue;
2810            }
2811            let root = Command::new(super::BINARY)
2812                .current_dir(dir)
2813                .args([
2814                    "--ignore-working-copy",
2815                    "workspace",
2816                    "root",
2817                    "--name",
2818                    name,
2819                    "--color",
2820                    "never",
2821                ])
2822                .output();
2823            match root {
2824                Ok(r) if r.status.success() => {
2825                    let p = PathBuf::from(String::from_utf8_lossy(&r.stdout).trim().to_string());
2826                    if super::workspace_root_matches(&p, path) {
2827                        return Ok(Some(name.to_string()));
2828                    }
2829                }
2830                _ => unresolved.push(name.to_string()),
2831            }
2832        }
2833        if unresolved.is_empty() {
2834            Ok(None)
2835        } else {
2836            Err(io::Error::other(format!(
2837                "could not resolve the workspace at {}: {} registered workspace(s) did not \
2838                 resolve via `jj workspace root --name` ({}); resolve or `jj workspace forget` \
2839                 them manually",
2840                path.display(),
2841                unresolved.len(),
2842                unresolved.join(", "),
2843            )))
2844        }
2845    }
2846}
2847
2848#[cfg(test)]
2849mod tests {
2850    use super::*;
2851    use processkit::testing::{RecordingRunner, Reply, ScriptedRunner};
2852
2853    // Terse constructors for the validated newtypes in test call sites; the
2854    // literals here are always valid, so `unwrap` is fine in tests.
2855    fn rv(s: &str) -> RevsetExpr {
2856        RevsetExpr::new(s).unwrap()
2857    }
2858    fn bn(s: &str) -> BookmarkName {
2859        BookmarkName::new(s).unwrap()
2860    }
2861
2862    #[test]
2863    fn binary_name_is_jj() {
2864        assert_eq!(BINARY, "jj");
2865    }
2866
2867    // T-080: `workspace_root_matches` is the single comparison set shared by
2868    // both jj-workspace-by-path resolvers (`vcs-core`'s async
2869    // `remove_worktree` and this crate's `blocking::workspace_name_for_path`
2870    // Drop path), so a table-driven pin here fixes the unified semantics for
2871    // both call sites at once — table-driven over real directories so the
2872    // canonicalisation checks (not just raw equality) are actually exercised.
2873    #[test]
2874    fn workspace_root_matches_unifies_the_comparison_set() {
2875        use vcs_testkit::TempDir;
2876
2877        let tmp = TempDir::new("t080-workspace-root-matches");
2878        let root = tmp.path().join("ws");
2879        std::fs::create_dir_all(&root).unwrap();
2880        let other = tmp.path().join("elsewhere");
2881        std::fs::create_dir_all(&other).unwrap();
2882
2883        // Raw equality (both sides' `root == path` check).
2884        assert!(
2885            workspace_root_matches(&root, &root),
2886            "identical paths must match"
2887        );
2888
2889        // A `path` that canonicalizes to the same directory via a `.`/`..`
2890        // detour matches through the canonicalised-root-vs-canonicalised-target
2891        // check (`normalize(root) == normalize(path)`).
2892        let detour = root.join(".").join("..").join(root.file_name().unwrap());
2893        assert!(
2894            workspace_root_matches(&root, &detour),
2895            "a `.`/`..` detour resolving to the same directory must match"
2896        );
2897        assert!(
2898            workspace_root_matches(&detour, &root),
2899            "the match must be symmetric in which side carries the detour"
2900        );
2901
2902        // A `path` that does not exist on disk (so it fails to canonicalize and
2903        // falls back to its literal form) still matches an equal-by-value `root`.
2904        let missing = tmp.path().join("gone").join("ws");
2905        assert!(
2906            workspace_root_matches(&missing, &missing),
2907            "a non-existent but literally-equal path/root pair must still match"
2908        );
2909
2910        // An unrelated, non-matching directory must never match.
2911        assert!(
2912            !workspace_root_matches(&root, &other),
2913            "distinct directories must not match"
2914        );
2915        assert!(
2916            !workspace_root_matches(&root, &missing),
2917            "an unrelated non-existent path must not match either"
2918        );
2919    }
2920
2921    // UNC paths are deliberately non-existent here: cleanup commonly reaches this
2922    // fallback after a workspace directory has gone away, so the literal forms must
2923    // still compare as one root when canonicalisation is unavailable.
2924    #[cfg(windows)]
2925    #[test]
2926    fn verbatim_unc_paths_normalize_and_match_ordinary_unc_paths() {
2927        let ordinary = Path::new(r"\\server\share\workspace");
2928        let verbatim = Path::new(r"\\?\UNC\server\share\workspace");
2929
2930        assert_eq!(
2931            strip_windows_verbatim_prefix(verbatim.to_path_buf()),
2932            ordinary,
2933            "a verbatim UNC path must become its ordinary UNC spelling"
2934        );
2935        assert_eq!(
2936            normalize_workspace_root(verbatim),
2937            normalize_workspace_root(ordinary)
2938        );
2939        assert!(
2940            workspace_root_matches(verbatim, ordinary),
2941            "a verbatim workspace root must match an ordinary requested path"
2942        );
2943        assert!(
2944            workspace_root_matches(ordinary, verbatim),
2945            "an ordinary workspace root must match a verbatim requested path"
2946        );
2947    }
2948    // Compile-time guard: the bound view stays `Copy` for the default `JobRunner`.
2949    #[allow(dead_code)]
2950    fn bound_view_is_copy_for_default_runner() {
2951        fn assert_copy<T: Copy>() {}
2952        assert_copy::<JjAt<'static, processkit::JobRunner>>();
2953    }
2954
2955    // The bound view (`jj.at(dir)`) must produce byte-identical argv to the
2956    // dir-taking call — including the forced `--color never`.
2957    #[tokio::test]
2958    async fn bound_view_matches_dir_taking_calls() {
2959        let dir = Path::new("/repo");
2960        let rec = RecordingRunner::replying(Reply::ok(""));
2961        let jj = Jj::with_runner(&rec);
2962
2963        jj.bookmark_move(
2964            dir,
2965            BookmarkMove::new(bn("main"), rv("@")).allow_backwards(),
2966        )
2967        .await
2968        .unwrap();
2969        jj.at(dir)
2970            .bookmark_move(BookmarkMove::new(bn("main"), rv("@")).allow_backwards())
2971            .await
2972            .unwrap();
2973        jj.describe_rev(dir, &rv("feat"), "msg").await.unwrap();
2974        jj.at(dir).describe_rev(&rv("feat"), "msg").await.unwrap();
2975        jj.description(dir, &rv("@-")).await.unwrap();
2976        jj.at(dir).description(&rv("@-")).await.unwrap();
2977        // One of the §4 additions.
2978        jj.duplicate(dir, &rv("@-")).await.unwrap();
2979        jj.at(dir).duplicate(&rv("@-")).await.unwrap();
2980
2981        let calls = rec.calls();
2982        assert_eq!(calls[0].args_str(), calls[1].args_str());
2983        assert_eq!(calls[2].args_str(), calls[3].args_str());
2984        assert_eq!(calls[4].args_str(), calls[5].args_str());
2985        assert_eq!(calls[6].args_str(), calls[7].args_str());
2986        assert_eq!(calls[1].cwd.as_deref(), Some(dir));
2987    }
2988
2989    // T-035: the raw escape hatches reached *through* the bound view
2990    // (`jj.at(dir).run…`) now run in the bound `dir`, while the same-named methods
2991    // on the client stay in the process cwd. The bound raw hatch is verbatim — no
2992    // `--color never` is injected (unlike the modelled methods).
2993    #[tokio::test]
2994    async fn bound_view_raw_hatch_runs_in_bound_dir() {
2995        let dir = Path::new("/repo");
2996        let rec = RecordingRunner::replying(Reply::ok(""));
2997        let jj = Jj::with_runner(&rec);
2998
2999        // Through the bound view: every raw form carries the bound dir as its cwd.
3000        jj.at(dir).run(&["status".to_string()]).await.unwrap();
3001        let _ = jj.at(dir).run_raw(&["status".to_string()]).await.unwrap();
3002        jj.at(dir).run_args(&["status"]).await.unwrap();
3003        let _ = jj.at(dir).run_raw_args(&["status"]).await.unwrap();
3004        // On the client directly: the process-cwd escape hatch (no bound dir).
3005        jj.run(&["status".to_string()]).await.unwrap();
3006        let _ = jj.run_raw(&["status".to_string()]).await.unwrap();
3007        jj.run_args(&["status"]).await.unwrap();
3008        let _ = jj.run_raw_args(&["status"]).await.unwrap();
3009
3010        let calls = rec.calls();
3011        for c in &calls[0..4] {
3012            assert_eq!(
3013                c.cwd.as_deref(),
3014                Some(dir),
3015                "raw call through the bound view runs in the bound dir"
3016            );
3017            // Verbatim argv: no `--color never` appended.
3018            assert_eq!(c.args_str(), ["status"]);
3019        }
3020        for c in &calls[4..8] {
3021            assert_eq!(
3022                c.cwd.as_deref(),
3023                None,
3024                "raw call on the client stays in the process cwd"
3025            );
3026            assert_eq!(c.args_str(), ["status"]);
3027        }
3028    }
3029
3030    // T-038: each read-only query twin issues **exactly** its snapshotting form's
3031    // argv plus the global `--ignore-working-copy` flag — the flag that keeps jj
3032    // from locking + snapshotting the working copy and recording an operation. The
3033    // default forms must NOT carry it (they snapshot, as jj always has). Pinned via
3034    // a `RecordingRunner` so the argv is asserted byte-for-byte, hermetically.
3035    #[tokio::test]
3036    async fn read_only_query_twins_append_ignore_working_copy() {
3037        let dir = Path::new("/repo");
3038        let rec = RecordingRunner::replying(Reply::ok(""));
3039        let jj = Jj::with_runner(&rec);
3040
3041        // `status`/`status_ignoring_working_copy` each resolve the workspace
3042        // root first (T-040), so each form issues *two* calls (`root`, then
3043        // `diff --summary`) rather than one — both carrying (or not) the same
3044        // `--ignore-working-copy` flag.
3045        jj.status(dir).await.unwrap();
3046        jj.status_ignoring_working_copy(dir).await.unwrap();
3047        // The remaining pairs: the default (snapshotting) form, then its
3048        // read-only twin — a single call each.
3049        jj.bookmarks(dir).await.unwrap();
3050        jj.bookmarks_ignoring_working_copy(dir).await.unwrap();
3051        jj.reachable_bookmarks(dir).await.unwrap();
3052        jj.reachable_bookmarks_ignoring_working_copy(dir)
3053            .await
3054            .unwrap();
3055        jj.template_query(dir, &rv("@"), "commit_id", Some(1))
3056            .await
3057            .unwrap();
3058        jj.template_query_ignoring_working_copy(dir, &rv("@"), "commit_id", Some(1))
3059            .await
3060            .unwrap();
3061
3062        let calls = rec.calls();
3063        assert_eq!(
3064            calls.len(),
3065            10,
3066            "status's two-call pairs (root, diff) x2 forms, plus three single-call pairs"
3067        );
3068
3069        // status: [root, diff], then [root+ignore, diff+ignore] — check each
3070        // underlying call gets the flag, not just the pair as a whole.
3071        let (status_calls, rest) = calls.split_at(4);
3072        let (live, read_only) = status_calls.split_at(2);
3073        for (live_call, read_only_call) in live.iter().zip(read_only) {
3074            let live_args = live_call.args_str();
3075            let read_only_args = read_only_call.args_str();
3076            assert!(
3077                !live_args.iter().any(|a| a == "--ignore-working-copy"),
3078                "the default form must snapshot the working copy (no flag): {live_args:?}"
3079            );
3080            let mut expected = live_args.clone();
3081            expected.push("--ignore-working-copy".to_string());
3082            assert_eq!(
3083                read_only_args, expected,
3084                "status's read-only twin must be the default argv + --ignore-working-copy"
3085            );
3086        }
3087
3088        for pair in rest.chunks(2) {
3089            let live = pair[0].args_str();
3090            let read_only = pair[1].args_str();
3091            assert!(
3092                !live.iter().any(|a| a == "--ignore-working-copy"),
3093                "the default form must snapshot the working copy (no flag): {live:?}"
3094            );
3095            let mut expected = live.clone();
3096            expected.push("--ignore-working-copy".to_string());
3097            assert_eq!(
3098                read_only, expected,
3099                "the read-only twin must be the default argv + --ignore-working-copy"
3100            );
3101        }
3102    }
3103
3104    #[tokio::test]
3105    async fn workspace_list_parses_template_rows() {
3106        let jj = Jj::with_runner(ScriptedRunner::new().on(
3107            ["jj", "workspace", "list"],
3108            Reply::ok("\"default\"\te2aa3420\t\"main\"\n\"ws1\"\t12345678\t\n"),
3109        ));
3110        let got = jj.workspace_list(Path::new(".")).await.expect("list");
3111        assert_eq!(got.len(), 2);
3112        assert_eq!(got[0].name, "default");
3113        assert_eq!(got[0].bookmarks, vec!["main".to_string()]);
3114        assert!(got[1].bookmarks.is_empty());
3115    }
3116
3117    // `workspace_roots` fans out one `workspace root --name <n>` per name, returns
3118    // a path per slot in input order, and maps a non-zero exit to `Err` for that
3119    // slot (mirroring the single `workspace_root`). Runs through the scripted
3120    // runner, so it's hermetic.
3121    #[tokio::test]
3122    async fn workspace_roots_batches_per_name_and_maps_errors() {
3123        let rec = RecordingRunner::new(
3124            ScriptedRunner::new()
3125                .on(
3126                    [
3127                        "jj",
3128                        "--ignore-working-copy",
3129                        "workspace",
3130                        "root",
3131                        "--name",
3132                        "default",
3133                    ],
3134                    Reply::ok("/repo\n"),
3135                )
3136                .on(
3137                    [
3138                        "jj",
3139                        "--ignore-working-copy",
3140                        "workspace",
3141                        "root",
3142                        "--name",
3143                        "ws1",
3144                    ],
3145                    Reply::ok("/repo/ws1\n"),
3146                )
3147                .on(
3148                    [
3149                        "jj",
3150                        "--ignore-working-copy",
3151                        "workspace",
3152                        "root",
3153                        "--name",
3154                        "gone",
3155                    ],
3156                    Reply::fail(1, "Error: No such workspace"),
3157                ),
3158        );
3159        let jj = Jj::with_runner(&rec);
3160        let roots = jj
3161            .workspace_roots(
3162                Path::new("/repo"),
3163                &["default".into(), "gone".into(), "ws1".into()],
3164            )
3165            .await;
3166        // Order matches the input, regardless of completion order.
3167        assert_eq!(roots.len(), 3);
3168        assert_eq!(roots[0].as_deref().unwrap(), Path::new("/repo"));
3169        assert!(roots[1].is_err(), "a non-zero `workspace root` is Err");
3170        assert_eq!(roots[2].as_deref().unwrap(), Path::new("/repo/ws1"));
3171        // Exactly one read-only `--ignore-working-copy workspace root --name <n>`
3172        // command per name (M10: the metadata probe must not snapshot the copy).
3173        let calls = rec.calls();
3174        assert_eq!(calls.len(), 3);
3175        assert!(
3176            calls
3177                .iter()
3178                .all(|c| c.args_str()[..3] == ["--ignore-working-copy", "workspace", "root"])
3179        );
3180    }
3181
3182    // `workspace add` must build `--name <n> -r <base> <path>` in order.
3183    #[tokio::test]
3184    async fn workspace_add_builds_name_base_path() {
3185        let rec = RecordingRunner::replying(Reply::ok(""));
3186        let jj = Jj::with_runner(&rec);
3187        jj.workspace_add(
3188            Path::new("/repo"),
3189            WorkspaceAdd::new("ws1", rv("main"), "/wt"),
3190        )
3191        .await
3192        .expect("workspace add");
3193        assert_eq!(
3194            rec.only_call().args_str(),
3195            [
3196                "workspace",
3197                "add",
3198                "--name",
3199                "ws1",
3200                "-r",
3201                "main",
3202                "/wt",
3203                "--color",
3204                "never"
3205            ]
3206        );
3207    }
3208
3209    // `--sparse-patterns <mode>` lands between `-r <base>` and the path.
3210    #[tokio::test]
3211    async fn workspace_add_with_sparse_mode() {
3212        let rec = RecordingRunner::replying(Reply::ok(""));
3213        let jj = Jj::with_runner(&rec);
3214        jj.workspace_add(
3215            Path::new("/repo"),
3216            WorkspaceAdd::new("ws1", rv("main"), "/wt").sparse(SparseMode::Empty),
3217        )
3218        .await
3219        .expect("workspace add");
3220        assert_eq!(
3221            rec.only_call().args_str(),
3222            [
3223                "workspace",
3224                "add",
3225                "--name",
3226                "ws1",
3227                "-r",
3228                "main",
3229                "--sparse-patterns",
3230                "empty",
3231                "/wt",
3232                "--color",
3233                "never"
3234            ]
3235        );
3236    }
3237
3238    #[test]
3239    fn fileset_quotes_metacharacters() {
3240        assert_eq!(
3241            JjFileset::path("src/a(b).rs").as_str(),
3242            "root-file:\"src/a(b).rs\""
3243        );
3244    }
3245
3246    #[test]
3247    fn fileset_escapes_double_quote() {
3248        assert_eq!(JjFileset::path("a\"b").as_str(), "root-file:\"a\\\"b\"");
3249    }
3250
3251    // M2: the fileset uses jj's `root-file:` anchor (workspace-root-relative), NOT the
3252    // cwd-relative `file:` — so a command run from a subdirectory (`dir` ≠ workspace
3253    // root) targets the intended root-relative path rather than a same-named file under
3254    // `dir`. (jj resolves `root-file:"x"` from the workspace root; `file:"x"` from cwd.)
3255    #[test]
3256    fn fileset_is_workspace_root_relative() {
3257        assert!(
3258            JjFileset::path("src/a.rs")
3259                .as_str()
3260                .starts_with("root-file:\"")
3261        );
3262        assert!(!JjFileset::path("src/a.rs").as_str().starts_with("file:"));
3263    }
3264
3265    // M4: the `\`→`/` rewrite is Windows-only. On Windows a `\` is a path separator
3266    // (normalise it so jj matches); on Unix `\` is a legitimate filename byte and must
3267    // be preserved verbatim, else a real path is corrupted.
3268    #[test]
3269    #[cfg(windows)]
3270    fn fileset_normalises_backslash_on_windows() {
3271        assert_eq!(
3272            JjFileset::path("src\\a.rs").as_str(),
3273            "root-file:\"src/a.rs\""
3274        );
3275    }
3276
3277    #[test]
3278    #[cfg(not(windows))]
3279    fn fileset_escapes_backslashes_on_unix() {
3280        assert_eq!(
3281            JjFileset::path("a\\b.txt").as_str(),
3282            "root-file:\"a\\\\b.txt\""
3283        );
3284        assert_eq!(JjFileset::path("a\\").as_str(), "root-file:\"a\\\\\"");
3285        assert_eq!(
3286            JjFileset::path("a\\b\"c.txt").as_str(),
3287            "root-file:\"a\\\\b\\\"c.txt\""
3288        );
3289    }
3290
3291    #[tokio::test]
3292    async fn commit_paths_builds_filesets() {
3293        let rec = RecordingRunner::replying(Reply::ok(""));
3294        let jj = Jj::with_runner(&rec);
3295        jj.commit_paths(
3296            Path::new("."),
3297            &[JjFileset::path("x|y.rs"), JjFileset::path("z.rs")],
3298            "msg",
3299        )
3300        .await
3301        .expect("commit_paths");
3302        assert_eq!(
3303            rec.only_call().args_str(),
3304            [
3305                "commit",
3306                "-m",
3307                "msg",
3308                "root-file:\"x|y.rs\"",
3309                "root-file:\"z.rs\"",
3310                "--color",
3311                "never"
3312            ]
3313        );
3314    }
3315
3316    #[tokio::test]
3317    async fn squash_paths_builds_from_into_filesets() {
3318        let rec = RecordingRunner::replying(Reply::ok(""));
3319        let jj = Jj::with_runner(&rec);
3320        jj.squash_paths(
3321            Path::new("."),
3322            SquashPaths::new(rv("@"), rv("feat")).filesets([JjFileset::path("a.rs")]),
3323        )
3324        .await
3325        .expect("squash_paths");
3326        assert_eq!(
3327            rec.only_call().args_str(),
3328            [
3329                "squash",
3330                "--from",
3331                "@",
3332                "--into",
3333                "feat",
3334                "root-file:\"a.rs\"",
3335                "--color",
3336                "never"
3337            ]
3338        );
3339    }
3340
3341    #[tokio::test]
3342    async fn squash_paths_keeps_destination_message() {
3343        let rec = RecordingRunner::replying(Reply::ok(""));
3344        let jj = Jj::with_runner(&rec);
3345        jj.squash_paths(
3346            Path::new("."),
3347            SquashPaths::new(rv("@"), rv("feat"))
3348                .filesets([JjFileset::path("a.rs")])
3349                .use_destination_message(),
3350        )
3351        .await
3352        .expect("squash_paths");
3353        assert_eq!(
3354            rec.only_call().args_str(),
3355            [
3356                "squash",
3357                "--from",
3358                "@",
3359                "--into",
3360                "feat",
3361                "--use-destination-message",
3362                "root-file:\"a.rs\"",
3363                "--color",
3364                "never"
3365            ]
3366        );
3367    }
3368
3369    #[tokio::test]
3370    async fn jj_new_revision_scoped_ops_build_args() {
3371        let rec = RecordingRunner::replying(Reply::ok(""));
3372        let jj = Jj::with_runner(&rec);
3373        jj.describe_rev(Path::new("."), &rv("feat"), "msg")
3374            .await
3375            .unwrap();
3376        assert_eq!(
3377            rec.only_call().args_str(),
3378            ["describe", "-r", "feat", "-m", "msg", "--color", "never"]
3379        );
3380
3381        let rec = RecordingRunner::replying(Reply::ok(""));
3382        let jj = Jj::with_runner(&rec);
3383        jj.rebase_branch(Path::new("."), &rv("feat"), &rv("main"))
3384            .await
3385            .unwrap();
3386        assert_eq!(
3387            rec.only_call().args_str(),
3388            ["rebase", "-b", "feat", "-d", "main", "--color", "never"]
3389        );
3390
3391        let rec = RecordingRunner::replying(Reply::ok(""));
3392        let jj = Jj::with_runner(&rec);
3393        jj.bookmark_track(Path::new("."), &bn("feat"), "origin")
3394            .await
3395            .unwrap();
3396        assert_eq!(
3397            rec.only_call().args_str(),
3398            ["bookmark", "track", "exact:feat@origin", "--color", "never"]
3399        );
3400    }
3401
3402    #[tokio::test]
3403    async fn bookmark_track_rejects_glob_like_remote() {
3404        // Unlike the bookmark segment, the remote segment of jj's positional
3405        // `<name>@<remote>` pattern isn't itself pattern-syntax — wrapping it
3406        // in `exact:` would silently no-op (see `reject_glob_like`'s doc
3407        // comment) rather than exact-match, so a glob-bearing remote must be
3408        // rejected before spawn instead.
3409        for remote in ["*", "o?igin", "[origin]"] {
3410            let rec = RecordingRunner::replying(Reply::ok(""));
3411            let jj = Jj::with_runner(&rec);
3412            assert!(
3413                jj.bookmark_track(Path::new("."), &bn("main"), remote)
3414                    .await
3415                    .is_err(),
3416                "remote {remote:?} should be rejected before spawn"
3417            );
3418            assert!(
3419                rec.calls().is_empty(),
3420                "must not spawn for remote {remote:?}"
3421            );
3422        }
3423    }
3424
3425    #[tokio::test]
3426    async fn bookmarks_uses_template_and_parses_rows() {
3427        let rec = RecordingRunner::replying(Reply::ok(
3428            "1\t\t\"main\"\tabc123\n1\t\t\"feature\"\tdef456\n",
3429        ));
3430        let jj = Jj::with_runner(&rec);
3431        let marks = jj.bookmarks(Path::new(".")).await.unwrap();
3432        assert_eq!(
3433            rec.only_call().args_str(),
3434            [
3435                "bookmark",
3436                "list",
3437                "-T",
3438                parse::BOOKMARK_LIST_TEMPLATE,
3439                "--color",
3440                "never"
3441            ]
3442        );
3443        assert_eq!(marks.len(), 2);
3444        assert_eq!(marks[0].name, "main");
3445        assert_eq!(marks[0].target, "abc123");
3446        assert_eq!(marks[1].name, "feature");
3447    }
3448
3449    #[tokio::test]
3450    async fn bookmarks_all_parses_local_and_remote() {
3451        let jj = Jj::with_runner(ScriptedRunner::new().on(
3452            ["jj", "bookmark", "list"],
3453            Reply::ok("1\t\"main\"\t\t0\tabc123\n1\t\"main\"\torigin\t1\tabc123\n"),
3454        ));
3455        let refs = jj.bookmarks_all(Path::new(".")).await.unwrap();
3456        assert_eq!(refs.len(), 2);
3457        assert_eq!(refs[0].name, "main");
3458        assert!(refs[0].remote.is_none() && !refs[0].tracked);
3459        assert_eq!(refs[1].remote.as_deref(), Some("origin"));
3460        assert!(refs[1].tracked);
3461    }
3462
3463    #[tokio::test]
3464    async fn sparse_set_clears_then_adds() {
3465        let rec = RecordingRunner::replying(Reply::ok(""));
3466        let jj = Jj::with_runner(&rec);
3467        jj.sparse_set(Path::new("."), &["README.md".into(), "lib".into()])
3468            .await
3469            .expect("sparse_set");
3470        assert_eq!(
3471            rec.only_call().args_str(),
3472            [
3473                "sparse",
3474                "set",
3475                "--clear",
3476                "--add",
3477                "README.md",
3478                "--add",
3479                "lib",
3480                "--color",
3481                "never"
3482            ]
3483        );
3484    }
3485
3486    // Parsed status() is backed by `diff -r @ --summary`, not `jj status`.
3487    #[tokio::test]
3488    async fn status_parses_diff_summary() {
3489        let jj = Jj::with_runner(
3490            ScriptedRunner::new()
3491                .on(["jj", "root"], Reply::ok("/repo\n"))
3492                .on(
3493                    ["jj", "diff", "-r", "@", "--summary"],
3494                    Reply::ok("M a.rs\nA b.rs\n"),
3495                ),
3496        );
3497        let entries = jj.status(Path::new(".")).await.expect("status");
3498        assert_eq!(entries.len(), 2);
3499        assert_eq!(entries[0].status, 'M');
3500        assert_eq!(entries[1].path, Path::new("b.rs"));
3501    }
3502
3503    #[test]
3504    fn summary_paths_normalise_windows_and_reject_workspace_escapes() {
3505        let paths = normalize_changed_paths(vec![ChangedPath {
3506            status: 'R',
3507            path: "src\\.\\new.rs".into(),
3508            old_path: Some("src\\old.rs".into()),
3509        }])
3510        .expect("normalise");
3511        assert_eq!(paths[0].path, Path::new("src/new.rs"));
3512        assert_eq!(paths[0].old_path.as_deref(), Some(Path::new("src/old.rs")));
3513        let err = normalize_changed_paths(vec![ChangedPath {
3514            status: 'M',
3515            path: "../outside.rs".into(),
3516            old_path: None,
3517        }])
3518        .expect_err("must reject a path outside the workspace");
3519        assert!(err.to_string().contains("escapes the workspace root"));
3520    }
3521
3522    #[tokio::test]
3523    async fn status_text_is_raw_jj_status() {
3524        let jj = Jj::with_runner(
3525            ScriptedRunner::new().on(["jj", "status"], Reply::ok("Working copy changes:\n")),
3526        );
3527        assert!(
3528            jj.status_text(Path::new("."))
3529                .await
3530                .expect("status_text")
3531                .contains("Working copy changes")
3532        );
3533    }
3534
3535    #[tokio::test]
3536    async fn run_args_forwards_str_slices() {
3537        let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "root"], Reply::ok("/r\n")));
3538        assert_eq!(jj.run_args(&["root"]).await.unwrap(), "/r");
3539    }
3540
3541    #[tokio::test]
3542    async fn bookmark_move_appends_allow_backwards() {
3543        let rec = RecordingRunner::replying(Reply::ok(""));
3544        let jj = Jj::with_runner(&rec);
3545        jj.bookmark_move(
3546            Path::new("/r"),
3547            BookmarkMove::new(bn("main"), rv("@")).allow_backwards(),
3548        )
3549        .await
3550        .unwrap();
3551        assert_eq!(
3552            rec.only_call().args_str(),
3553            [
3554                "bookmark",
3555                "move",
3556                "exact:main",
3557                "--to",
3558                "@",
3559                "--allow-backwards",
3560                "--color",
3561                "never"
3562            ]
3563        );
3564    }
3565
3566    // The default spec omits `--allow-backwards`.
3567    #[tokio::test]
3568    async fn bookmark_move_default_omits_allow_backwards() {
3569        let rec = RecordingRunner::replying(Reply::ok(""));
3570        let jj = Jj::with_runner(&rec);
3571        jj.bookmark_move(Path::new("/r"), BookmarkMove::new(bn("main"), rv("@")))
3572            .await
3573            .unwrap();
3574        assert_eq!(
3575            rec.only_call().args_str(),
3576            [
3577                "bookmark",
3578                "move",
3579                "exact:main",
3580                "--to",
3581                "@",
3582                "--color",
3583                "never"
3584            ]
3585        );
3586    }
3587
3588    // `squash_into` builds `squash --into <rev>`; the spec's setter appends
3589    // `--use-destination-message` (after the forced `--color never`, which
3590    // `cmd_in` adds before the trailing setter — order is functionally irrelevant
3591    // to jj).
3592    #[tokio::test]
3593    async fn squash_into_builds_args() {
3594        let rec = RecordingRunner::replying(Reply::ok(""));
3595        let jj = Jj::with_runner(&rec);
3596        jj.squash_into(Path::new("/r"), SquashInto::new(rv("@-")))
3597            .await
3598            .unwrap();
3599        assert_eq!(
3600            rec.only_call().args_str(),
3601            ["squash", "--into", "@-", "--color", "never"]
3602        );
3603
3604        let flagged = RecordingRunner::replying(Reply::ok(""));
3605        let jj = Jj::with_runner(&flagged);
3606        jj.squash_into(
3607            Path::new("/r"),
3608            SquashInto::new(rv("@-")).use_destination_message(),
3609        )
3610        .await
3611        .unwrap();
3612        assert_eq!(
3613            flagged.only_call().args_str(),
3614            [
3615                "squash",
3616                "--into",
3617                "@-",
3618                "--color",
3619                "never",
3620                "--use-destination-message"
3621            ]
3622        );
3623    }
3624
3625    #[tokio::test]
3626    async fn new_merge_appends_parents() {
3627        let rec = RecordingRunner::replying(Reply::ok(""));
3628        let jj = Jj::with_runner(&rec);
3629        jj.new_merge(Path::new("/r"), "m", vec![rv("p1"), rv("p2")])
3630            .await
3631            .unwrap();
3632        assert_eq!(
3633            rec.only_call().args_str(),
3634            ["new", "-m", "m", "p1", "p2", "--color", "never"]
3635        );
3636    }
3637
3638    #[tokio::test]
3639    async fn is_conflicted_reads_template_flag() {
3640        let yes = Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("1\n")));
3641        assert!(yes.is_conflicted(Path::new("."), &rv("@")).await.unwrap());
3642        let no = Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("0\n")));
3643        assert!(!no.is_conflicted(Path::new("."), &rv("@")).await.unwrap());
3644    }
3645
3646    #[tokio::test]
3647    async fn commit_count_counts_template_lines() {
3648        let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("a\nb\nc\n")));
3649        assert_eq!(
3650            jj.commit_count(Path::new("."), &rv("::@")).await.unwrap(),
3651            3
3652        );
3653    }
3654
3655    #[tokio::test]
3656    async fn reachable_bookmarks_queries_heads_revset() {
3657        let rec = RecordingRunner::replying(Reply::ok("\"main\"\tabc123\n"));
3658        let jj = Jj::with_runner(&rec);
3659        let got = jj.reachable_bookmarks(Path::new(".")).await.unwrap();
3660        assert_eq!(got.len(), 1);
3661        assert_eq!(got[0].name, "main");
3662        let args = rec.only_call().args_str();
3663        assert_eq!(
3664            &args[..4],
3665            &["log", "-r", "heads(::@ & bookmarks())", "--no-graph"]
3666        );
3667    }
3668
3669    #[tokio::test]
3670    async fn resolve_list_distinguishes_no_conflicts_from_errors() {
3671        // The benign "no conflicts" non-zero exit → empty list.
3672        let none = Jj::with_runner(ScriptedRunner::new().on(
3673            ["jj", "resolve"],
3674            Reply::fail(2, "Error: No conflicts found at this revision"),
3675        ));
3676        assert!(
3677            none.resolve_list(Path::new("."), &rv("@"))
3678                .await
3679                .unwrap()
3680                .is_empty()
3681        );
3682        // A real failure (e.g. bad revset) must surface, not read as "no conflicts".
3683        let bad = Jj::with_runner(ScriptedRunner::new().on(
3684            ["jj", "resolve"],
3685            Reply::fail(1, "Error: Revision `bogus` doesn't exist"),
3686        ));
3687        assert!(
3688            bad.resolve_list(Path::new("."), &rv("bogus"))
3689                .await
3690                .is_err()
3691        );
3692        // Success with conflicts → parsed paths.
3693        let some = Jj::with_runner(
3694            ScriptedRunner::new().on(["jj", "resolve"], Reply::ok("a.rs    2-sided conflict\n")),
3695        );
3696        assert_eq!(
3697            some.resolve_list(Path::new("."), &rv("@")).await.unwrap(),
3698            [PathBuf::from("a.rs")]
3699        );
3700    }
3701
3702    #[tokio::test]
3703    async fn current_bookmark_takes_first_or_none() {
3704        let some =
3705            Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("\"main\"\n")));
3706        assert_eq!(
3707            some.current_bookmark(Path::new("."))
3708                .await
3709                .unwrap()
3710                .as_deref(),
3711            Some("main")
3712        );
3713        let none = Jj::with_runner(ScriptedRunner::new().on(["jj", "log"], Reply::ok("\n")));
3714        assert!(
3715            none.current_bookmark(Path::new("."))
3716                .await
3717                .unwrap()
3718                .is_none()
3719        );
3720    }
3721
3722    // Hermetic: real log() arg-building + template parsing against canned output.
3723    #[tokio::test]
3724    async fn current_change_parses_scripted_output() {
3725        let jj = Jj::with_runner(ScriptedRunner::new().on(
3726            ["jj", "log"],
3727            Reply::ok("kztuxlro\t38e00654\tfalse\t\"hello jj\"\n"),
3728        ));
3729        let change = jj
3730            .current_change(Path::new("."))
3731            .await
3732            .expect("current_change");
3733        assert_eq!(change.change_id, "kztuxlro");
3734        assert!(!change.empty);
3735        assert_eq!(change.description, "hello jj");
3736    }
3737
3738    // With a bookmark, the run must build `git push -b exact:<name>` (the `exact:`
3739    // prefix disables jj's glob so a `*` can't push every bookmark — H1). Only that
3740    // command is scripted (no fallback), so a regression that dropped the flag or
3741    // the `exact:` prefix would match no rule and error.
3742    #[tokio::test]
3743    async fn git_push_appends_bookmark_flag() {
3744        let jj = Jj::with_runner(
3745            ScriptedRunner::new().on(["jj", "git", "push", "-b", "exact:feature"], Reply::ok("")),
3746        );
3747        jj.git_push(Path::new("."), Some(bn("feature")))
3748            .await
3749            .expect("should build `git push -b exact:feature`");
3750    }
3751
3752    // Without a bookmark, the run is a bare `git push`.
3753    #[tokio::test]
3754    async fn git_push_without_bookmark_is_bare() {
3755        let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "git", "push"], Reply::ok("")));
3756        jj.git_push(Path::new("."), None).await.expect("bare push");
3757    }
3758
3759    // H1: `bookmark delete` and `git fetch -b` pass the name through `exact:` so a
3760    // `*` can't mass-delete/fetch. (The other exact: methods are covered by
3761    // git_push/bookmark_move/bookmark_track/git_fetch_from tests.)
3762    #[tokio::test]
3763    async fn bookmark_delete_and_fetch_branch_use_exact() {
3764        let rec = RecordingRunner::replying(Reply::ok(""));
3765        let jj = Jj::with_runner(&rec);
3766        jj.bookmark_delete(Path::new("."), &bn("foo"))
3767            .await
3768            .unwrap();
3769        assert_eq!(
3770            &rec.only_call().args_str()[..3],
3771            &["bookmark", "delete", "exact:foo"]
3772        );
3773
3774        let rec2 = RecordingRunner::replying(Reply::ok(""));
3775        let jj2 = Jj::with_runner(&rec2);
3776        jj2.git_fetch_branch(Path::new("."), &bn("foo"))
3777            .await
3778            .unwrap();
3779        assert_eq!(
3780            &rec2.only_call().args_str()[..6],
3781            &["git", "fetch", "--remote", "origin", "-b", "exact:foo"]
3782        );
3783        // M28: pinned C locale so a localized transient marker still classifies.
3784        assert!(
3785            rec2.only_call().envs.iter().any(|(k, v)| {
3786                k.to_str() == Some("LC_ALL") && v.as_deref().and_then(|s| s.to_str()) == Some("C")
3787            }),
3788            "git_fetch_branch must pin LC_ALL=C"
3789        );
3790    }
3791
3792    // `git_fetch` retries a transient (network) failure up to FETCH_ATTEMPTS times.
3793    #[tokio::test]
3794    async fn git_fetch_retries_transient_failures() {
3795        let rec = RecordingRunner::replying(Reply::fail(1, "Error: Could not resolve host: x"));
3796        let jj = Jj::with_runner(&rec);
3797        assert!(jj.git_fetch(Path::new(".")).await.is_err());
3798        assert_eq!(rec.calls().len(), FETCH_ATTEMPTS as usize);
3799        // M28: the fetch runs under LC_ALL=C, so a localized libc/gai transient marker
3800        // ("Temporary failure in name resolution") still classifies as retryable.
3801        assert!(
3802            rec.calls()[0].envs.iter().any(|(k, v)| {
3803                k.to_str() == Some("LC_ALL") && v.as_deref().and_then(|s| s.to_str()) == Some("C")
3804            }),
3805            "git fetch must pin LC_ALL=C"
3806        );
3807    }
3808
3809    // Opt-in lock-contention retry mirrors `vcs-git`: a mutation that fails on jj's
3810    // working-copy lock is retried and succeeds; off by default. (Zero backoff → no
3811    // sleep in the test.)
3812    #[tokio::test]
3813    async fn with_retry_retries_lock_contention_on_a_mutation() {
3814        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3815            ["jj", "abandon"],
3816            [
3817                Reply::fail(1, "Error: Failed to lock working copy"),
3818                Reply::ok(""),
3819            ],
3820        ));
3821        let jj = Jj::with_runner(&rec).with_retry(RetryPolicy::none().attempts(3));
3822        jj.abandon(Path::new("."), &rv("@-"))
3823            .await
3824            .expect("retried past the lock");
3825        assert_eq!(rec.calls().len(), 2, "one retry after the lock failure");
3826
3827        // Off by default.
3828        let rec = RecordingRunner::new(ScriptedRunner::new().on_sequence(
3829            ["jj", "abandon"],
3830            [
3831                Reply::fail(1, "Error: Failed to lock working copy"),
3832                Reply::ok(""),
3833            ],
3834        ));
3835        let jj = Jj::with_runner(&rec);
3836        assert!(jj.abandon(Path::new("."), &rv("@-")).await.is_err());
3837        assert_eq!(rec.calls().len(), 1, "no retry without with_retry");
3838    }
3839
3840    // `git_fetch_from` names the remote and shares `git_fetch`'s transient retry.
3841    #[tokio::test]
3842    async fn git_fetch_from_builds_args_and_retries() {
3843        let rec = RecordingRunner::replying(Reply::ok(""));
3844        let jj = Jj::with_runner(&rec);
3845        jj.git_fetch_from(Path::new("."), "upstream")
3846            .await
3847            .expect("git_fetch_from");
3848        assert_eq!(
3849            rec.only_call().args_str(),
3850            [
3851                "git",
3852                "fetch",
3853                "--remote",
3854                "exact:upstream",
3855                "--color",
3856                "never"
3857            ]
3858        );
3859        // M28: pinned C locale so a localized transient marker still classifies.
3860        assert!(
3861            rec.only_call().envs.iter().any(|(k, v)| {
3862                k.to_str() == Some("LC_ALL") && v.as_deref().and_then(|s| s.to_str()) == Some("C")
3863            }),
3864            "git_fetch_from must pin LC_ALL=C"
3865        );
3866
3867        let failing = RecordingRunner::replying(Reply::fail(1, "Error: Connection timed out"));
3868        let jj = Jj::with_runner(&failing);
3869        assert!(jj.git_fetch_from(Path::new("."), "upstream").await.is_err());
3870        assert_eq!(failing.calls().len(), FETCH_ATTEMPTS as usize);
3871    }
3872
3873    // `transaction` captures the op head and restores it when the closure errors —
3874    // the closure error surfaces as `cause`, and the rollback ran (`Restored`). The
3875    // first `op log` is the savepoint capture; the second is the divergence probe
3876    // (which sees a clean single-parent chain back to the captured op), then restore.
3877    #[tokio::test]
3878    async fn transaction_restores_op_head_on_error() {
3879        let rec = RecordingRunner::new(
3880            ScriptedRunner::new()
3881                .on_sequence(
3882                    ["jj", "op", "log"],
3883                    [
3884                        Reply::ok("abc123\n"),               // capture → pre
3885                        Reply::ok("def456\t1\nabc123\t1\n"), // probe → clean chain to pre
3886                    ],
3887                )
3888                .on(["jj", "op", "restore"], Reply::ok(""))
3889                .on(["jj", "describe"], Reply::fail(1, "boom")),
3890        );
3891        let jj = Jj::with_runner(&rec);
3892        let res = jj
3893            .transaction(
3894                Path::new("/r"),
3895                |tx| async move { tx.describe("wip").await },
3896            )
3897            .await;
3898        let err = res.expect_err("closure error must surface");
3899        assert!(
3900            matches!(err.cause, Error::Exit { .. }),
3901            "cause: {:?}",
3902            err.cause
3903        );
3904        assert!(
3905            matches!(err.rollback, Rollback::Restored),
3906            "rollback: {:?}",
3907            err.rollback
3908        );
3909        let calls = rec.calls();
3910        assert_eq!(
3911            calls.len(),
3912            4,
3913            "capture, mutation, divergence probe, restore: {calls:?}"
3914        );
3915        assert_eq!(calls[0].args_str()[..2], ["op", "log"]);
3916        assert_eq!(calls[1].args_str()[0], "describe");
3917        assert_eq!(calls[2].args_str()[..2], ["op", "log"]);
3918        assert!(
3919            calls[2]
3920                .args_str()
3921                .iter()
3922                .any(|a| a == "--ignore-working-copy"),
3923            "the divergence probe must not snapshot the working copy: {:?}",
3924            calls[2].args_str()
3925        );
3926        assert_eq!(calls[3].args_str()[..3], ["op", "restore", "abc123"]);
3927    }
3928
3929    // A successful transaction must NOT roll back (that would undo the work) — and
3930    // never even runs the divergence probe.
3931    #[tokio::test]
3932    async fn transaction_keeps_changes_on_success() {
3933        let rec = RecordingRunner::new(
3934            ScriptedRunner::new()
3935                .on(["jj", "op", "log"], Reply::ok("abc123\n"))
3936                .on(["jj", "describe"], Reply::ok("")),
3937        );
3938        let jj = Jj::with_runner(&rec);
3939        jj.transaction(
3940            Path::new("/r"),
3941            |tx| async move { tx.describe("wip").await },
3942        )
3943        .await
3944        .expect("transaction");
3945        let calls = rec.calls();
3946        assert_eq!(calls.len(), 2, "capture + mutation only: {calls:?}");
3947        assert!(
3948            calls.iter().all(|c| c.args_str()[..2] != ["op", "restore"]),
3949            "no restore on success: {calls:?}"
3950        );
3951    }
3952
3953    // The bound view forwards `transaction` with `dir` pre-bound.
3954    #[tokio::test]
3955    async fn bound_view_forwards_transaction() {
3956        let dir = Path::new("/repo");
3957        let rec = RecordingRunner::new(
3958            ScriptedRunner::new()
3959                .on(["jj", "op", "log"], Reply::ok("op9\n"))
3960                .on(["jj", "new"], Reply::ok("")),
3961        );
3962        let jj = Jj::with_runner(&rec);
3963        jj.at(dir)
3964            .transaction(|tx| async move { tx.new_change("x").await })
3965            .await
3966            .expect("transaction");
3967        assert_eq!(rec.calls()[1].cwd.as_deref(), Some(dir));
3968    }
3969
3970    // The rollback must survive an already-fired client cancellation token: the
3971    // probe and the `op restore` run on a fresh cancellation context, while a bare
3972    // `op_restore` on the same client short-circuits as `Cancelled`. This is the
3973    // core defect T-036 fixes — a cancelled operation no longer disables its cleanup.
3974    #[tokio::test]
3975    async fn rollback_to_survives_fired_cancellation() {
3976        let token = CancellationToken::new();
3977        token.cancel(); // as a cancelled/deadline-hit main op would leave it
3978        let rec = RecordingRunner::new(
3979            ScriptedRunner::new()
3980                // probe: head `post` (single parent) → clean chain back to `pre`.
3981                .on(["jj", "op", "log"], Reply::ok("post\t1\npre\t1\n"))
3982                .on(["jj", "op", "restore"], Reply::ok("")),
3983        );
3984        let jj = Jj::with_runner(&rec).default_cancel_on(token);
3985        let dir = Path::new("/r");
3986
3987        let outcome = jj.rollback_to(dir, "pre").await;
3988        assert!(
3989            matches!(outcome, Rollback::Restored),
3990            "rollback must run despite the fired token: {outcome:?}"
3991        );
3992        assert!(
3993            rec.calls()
3994                .iter()
3995                .any(|c| c.args_str()[..2] == ["op", "restore"]),
3996            "the detached restore must have run: {:?}",
3997            rec.calls()
3998        );
3999
4000        // Sanity: a bare `op_restore` on this same (cancelled) client IS cancelled,
4001        // proving the client token really is fired and the rollback deliberately
4002        // side-steps it rather than the token being inert.
4003        let bare = jj.op_restore(dir, "pre").await;
4004        assert!(
4005            bare.as_ref().is_err_and(|e| e.is_cancelled()),
4006            "a bare op_restore must inherit the fired token: {bare:?}"
4007        );
4008    }
4009
4010    // Through `transaction`: a closure whose error is a *fired* cancellation still
4011    // gets a working rollback (the savepoint was captured before the token fired).
4012    #[tokio::test]
4013    async fn transaction_rolls_back_after_cancelled_closure() {
4014        let token = CancellationToken::new();
4015        let rec = RecordingRunner::new(
4016            ScriptedRunner::new()
4017                .on_sequence(
4018                    ["jj", "op", "log"],
4019                    [
4020                        Reply::ok("pre\n"),             // capture (token not yet fired)
4021                        Reply::ok("post\t1\npre\t1\n"), // probe → clean chain
4022                    ],
4023                )
4024                .on(["jj", "op", "restore"], Reply::ok("")),
4025        );
4026        let jj = Jj::with_runner(&rec).default_cancel_on(token.clone());
4027        let dir = Path::new("/r");
4028        let res = jj
4029            .transaction(dir, |_tx| async move {
4030                // The main operation's cancellation fires mid-transaction.
4031                token.cancel();
4032                Err::<(), _>(Error::Cancelled {
4033                    program: "jj".to_string(),
4034                })
4035            })
4036            .await;
4037        let err = res.expect_err("cancelled closure");
4038        assert!(err.cause.is_cancelled(), "cause: {:?}", err.cause);
4039        assert!(
4040            matches!(err.rollback, Rollback::Restored),
4041            "the cleanup must survive the fired token: {:?}",
4042            err.rollback
4043        );
4044        assert!(
4045            rec.calls()
4046                .iter()
4047                .any(|c| c.args_str()[..2] == ["op", "restore"]),
4048            "restore must have run: {:?}",
4049            rec.calls()
4050        );
4051    }
4052
4053    // A failing `op restore` is no longer swallowed: the closure error is preserved
4054    // as `cause`, and the restore failure is surfaced as `Rollback::Failed`.
4055    #[tokio::test]
4056    async fn transaction_reports_restore_failure() {
4057        let rec = RecordingRunner::new(
4058            ScriptedRunner::new()
4059                .on_sequence(
4060                    ["jj", "op", "log"],
4061                    [Reply::ok("pre\n"), Reply::ok("post\t1\npre\t1\n")],
4062                )
4063                .on(["jj", "op", "restore"], Reply::fail(1, "op not found"))
4064                .on(["jj", "describe"], Reply::fail(1, "boom")),
4065        );
4066        let jj = Jj::with_runner(&rec);
4067        let res = jj
4068            .transaction(
4069                Path::new("/r"),
4070                |tx| async move { tx.describe("wip").await },
4071            )
4072            .await;
4073        let err = res.expect_err("closure error");
4074        assert!(
4075            matches!(err.cause, Error::Exit { .. }),
4076            "cause: {:?}",
4077            err.cause
4078        );
4079        match err.rollback {
4080            Rollback::Failed(e) => {
4081                assert!(matches!(e, Error::Exit { .. }), "rollback error: {e:?}");
4082            }
4083            other => panic!("expected Rollback::Failed, got {other:?}"),
4084        }
4085    }
4086
4087    // A concurrent op landing between capture and restore (jj records a `>= 2`-parent
4088    // "reconcile divergent operations" merge) must be DETECTED: the rollback is
4089    // skipped, signalled via `Rollback::SkippedDiverged`, and NO `op restore` runs —
4090    // the foreign work is not clobbered.
4091    #[tokio::test]
4092    async fn transaction_skips_rollback_on_concurrent_divergence() {
4093        let rec = RecordingRunner::new(
4094            ScriptedRunner::new()
4095                .on_sequence(
4096                    ["jj", "op", "log"],
4097                    [
4098                        Reply::ok("pre\n"),
4099                        // head `merge` has 2 parents (a foreign op was reconciled)
4100                        // before the walk reaches `pre`.
4101                        Reply::ok("merge\t2\nmine\t1\npre\t1\n"),
4102                    ],
4103                )
4104                .on(["jj", "op", "restore"], Reply::ok(""))
4105                .on(["jj", "describe"], Reply::fail(1, "boom")),
4106        );
4107        let jj = Jj::with_runner(&rec);
4108        let res = jj
4109            .transaction(
4110                Path::new("/r"),
4111                |tx| async move { tx.describe("wip").await },
4112            )
4113            .await;
4114        let err = res.expect_err("closure error");
4115        assert!(
4116            matches!(err.cause, Error::Exit { .. }),
4117            "cause: {:?}",
4118            err.cause
4119        );
4120        assert!(
4121            matches!(err.rollback, Rollback::SkippedDiverged),
4122            "rollback: {:?}",
4123            err.rollback
4124        );
4125        assert!(
4126            rec.calls()
4127                .iter()
4128                .all(|c| c.args_str()[..2] != ["op", "restore"]),
4129            "must not revert across a divergence: {:?}",
4130            rec.calls()
4131        );
4132    }
4133
4134    // If the captured savepoint is not within the probed window, the range can't be
4135    // confirmed safe to revert — the rollback is refused (conservative), not blindly
4136    // applied.
4137    #[tokio::test]
4138    async fn rollback_to_refuses_when_pre_not_in_window() {
4139        let rec = RecordingRunner::new(
4140            ScriptedRunner::new()
4141                // `pre` (the captured op) is absent from the probed rows.
4142                .on(["jj", "op", "log"], Reply::ok("a\t1\nb\t1\nc\t1\n"))
4143                .on(["jj", "op", "restore"], Reply::ok("")),
4144        );
4145        let jj = Jj::with_runner(&rec);
4146        let outcome = jj.rollback_to(Path::new("/r"), "pre").await;
4147        assert!(
4148            matches!(outcome, Rollback::SkippedDiverged),
4149            "unverifiable range must be refused: {outcome:?}"
4150        );
4151        assert!(
4152            rec.calls()
4153                .iter()
4154                .all(|c| c.args_str()[..2] != ["op", "restore"]),
4155            "no restore when the savepoint can't be located: {:?}",
4156            rec.calls()
4157        );
4158    }
4159
4160    // The injection barrier now has two tiers:
4161    //  1. bookmark names and revsets are validated NEWTYPES, so a flag-like or
4162    //     malformed value is rejected at *construction* — it can never reach an
4163    //     argv slot (migration test below); and
4164    //  2. the remaining bare-positional `&str` inputs that are not
4165    //     bookmarks/revsets (operation ids, workspace names, URLs) keep the
4166    //     internal guard, refused before anything spawns.
4167
4168    // Tier 1 — the newtypes reject the flag-like / malformed values the typed ops
4169    // would otherwise have received, as a classifiable invalid-input error.
4170    #[test]
4171    fn validated_bookmark_and_revset_newtypes_reject_bad_values() {
4172        for bad in ["", "-evil", "--all", "-bad", "--config=x", "-r"] {
4173            let b = BookmarkName::new(bad).expect_err("bookmark name must be rejected");
4174            assert!(vcs_cli_support::is_invalid_input(&b), "bookmark {bad:?}");
4175            let r = RevsetExpr::new(bad).expect_err("revset must be rejected");
4176            assert!(vcs_cli_support::is_invalid_input(&r), "revset {bad:?}");
4177        }
4178        // Legitimate values construct fine.
4179        assert!(BookmarkName::new("feature/x").is_ok());
4180        assert!(RevsetExpr::new("heads(::@ & bookmarks())").is_ok());
4181    }
4182
4183    // Tier 2 — the ops that still take a bare `&str` (operation ids, workspace
4184    // names, URLs) refuse a flag-like value BEFORE anything spawns.
4185    #[tokio::test]
4186    async fn str_positionals_are_rejected_before_spawning() {
4187        let rec = RecordingRunner::replying(Reply::ok(""));
4188        let jj = Jj::with_runner(&rec);
4189        let dir = Path::new("/r");
4190
4191        assert!(jj.op_restore(dir, "--help").await.is_err());
4192        assert!(jj.workspace_forget(dir, "-evil").await.is_err());
4193        assert!(
4194            jj.git_clone("-evil", dir, GitClone::separate())
4195                .await
4196                .is_err()
4197        );
4198
4199        assert!(
4200            rec.calls().is_empty(),
4201            "nothing may spawn: {:?}",
4202            rec.calls()
4203        );
4204    }
4205
4206    // A legitimate revset still flows through the typed path unchanged.
4207    #[tokio::test]
4208    async fn typed_edit_passes_through() {
4209        let rec = RecordingRunner::replying(Reply::ok(""));
4210        let jj = Jj::with_runner(&rec);
4211        jj.edit(Path::new("/r"), &rv("abc123")).await.expect("edit");
4212        assert_eq!(
4213            rec.only_call().args_str(),
4214            ["edit", "abc123", "--color", "never"]
4215        );
4216    }
4217
4218    #[test]
4219    fn revset_expr_validates() {
4220        assert!(RevsetExpr::new("heads(::@ & bookmarks())").is_ok());
4221        assert_eq!(RevsetExpr::new("@-").unwrap().as_str(), "@-");
4222        assert!(RevsetExpr::new("-evil").is_err());
4223        assert!(RevsetExpr::new("").is_err());
4224    }
4225
4226    // capabilities parses jj's version line (incl. dev-build suffixes) and
4227    // gates precisely on the validated 0.38 floor.
4228    #[tokio::test]
4229    async fn capabilities_parse_and_gate_versions() {
4230        let jj = Jj::with_runner(
4231            ScriptedRunner::new().on(["jj", "--version"], Reply::ok("jj 0.38.0\n")),
4232        );
4233        let caps = jj.capabilities().await.expect("capabilities");
4234        assert!(caps.is_supported());
4235        caps.ensure_supported().expect("supported");
4236
4237        // A dev-build suffix parses; an older release fails the precise gate.
4238        let dev = Jj::with_runner(
4239            ScriptedRunner::new().on(["jj", "--version"], Reply::ok("jj 0.39.0-dev+abc123\n")),
4240        );
4241        assert!(dev.capabilities().await.unwrap().is_supported());
4242
4243        let old = Jj::with_runner(
4244            ScriptedRunner::new().on(["jj", "--version"], Reply::ok("jj 0.35.0\n")),
4245        );
4246        let caps = old.capabilities().await.expect("capabilities");
4247        assert!(!caps.is_supported());
4248        let err = caps.ensure_supported().expect_err("unsupported");
4249        // The message must name both the floor and the found version.
4250        let Error::Spawn { source, .. } = &err else {
4251            panic!("expected Spawn, got {err:?}");
4252        };
4253        let message = source.to_string();
4254        assert!(message.contains("0.38.0"), "names the floor: {message}");
4255        assert!(
4256            message.contains("0.35.0"),
4257            "names the found version: {message}"
4258        );
4259
4260        let garbage =
4261            Jj::with_runner(ScriptedRunner::new().on(["jj", "--version"], Reply::ok("nope")));
4262        assert!(matches!(
4263            garbage.capabilities().await.unwrap_err(),
4264            Error::Parse { .. }
4265        ));
4266    }
4267
4268    // git_clone is dir-less; the colocate flag is ALWAYS explicit (jj's default
4269    // varies by version/config) and `--color never` still lands at the very end.
4270    #[tokio::test]
4271    async fn git_clone_builds_dirless_args() {
4272        let rec = RecordingRunner::replying(Reply::ok(""));
4273        let jj = Jj::with_runner(&rec);
4274        jj.git_clone("https://x/r.git", Path::new("/dest"), GitClone::colocated())
4275            .await
4276            .expect("clone");
4277        let call = rec.only_call();
4278        assert_eq!(
4279            call.args_str(),
4280            [
4281                "git",
4282                "clone",
4283                "https://x/r.git",
4284                "/dest",
4285                "--colocate",
4286                "--color",
4287                "never"
4288            ]
4289        );
4290        assert_eq!(call.cwd, None, "clone runs without a working directory");
4291
4292        let plain = RecordingRunner::replying(Reply::ok(""));
4293        let jj = Jj::with_runner(&plain);
4294        jj.git_clone("u", Path::new("/d"), GitClone::separate())
4295            .await
4296            .unwrap();
4297        let call = plain.only_call();
4298        assert!(call.has_flag("--no-colocate"), "explicit either way");
4299        assert!(!call.has_flag("--colocate"));
4300    }
4301
4302    // R7 (mirrors vcs-git): a failed `git_clone` cleans a `dest` it could have created
4303    // (absent/empty) so a retry isn't blocked, but never a non-empty pre-existing dir
4304    // (the caller's data). Scripted-fail clone + real temp dirs.
4305    #[tokio::test]
4306    async fn git_clone_failure_cleans_only_a_dest_it_could_have_created() {
4307        use vcs_testkit::TempDir;
4308        let tmp = TempDir::new("r7-jj-clone");
4309        let jj = Jj::with_runner(ScriptedRunner::new().on(
4310            ["jj", "git", "clone"],
4311            Reply::fail(1, "Error: fetch failed"),
4312        ));
4313
4314        // A non-empty caller dir must survive.
4315        let occupied = tmp.path().join("occupied");
4316        std::fs::create_dir(&occupied).unwrap();
4317        std::fs::write(occupied.join("keep.txt"), b"caller data").unwrap();
4318        assert!(
4319            jj.git_clone("https://x/r", &occupied, GitClone::separate())
4320                .await
4321                .is_err()
4322        );
4323        assert!(
4324            occupied.join("keep.txt").exists(),
4325            "a non-empty caller dir must survive a failed jj clone"
4326        );
4327
4328        // An empty dest we could have populated is removed on failure.
4329        let empty = tmp.path().join("empty");
4330        std::fs::create_dir(&empty).unwrap();
4331        assert!(
4332            jj.git_clone("https://x/r", &empty, GitClone::separate())
4333                .await
4334                .is_err()
4335        );
4336        assert!(
4337            !empty.exists(),
4338            "an empty dest is cleaned so a retry isn't blocked"
4339        );
4340    }
4341
4342    #[tokio::test]
4343    async fn absorb_and_split_build_args() {
4344        let rec = RecordingRunner::replying(Reply::ok(""));
4345        let jj = Jj::with_runner(&rec);
4346        jj.absorb(Path::new("/r"), None, &[]).await.unwrap();
4347        jj.absorb(
4348            Path::new("/r"),
4349            Some(rv("@-")),
4350            &[JjFileset::path("src/a.rs")],
4351        )
4352        .await
4353        .unwrap();
4354        jj.split_paths(Path::new("/r"), &[JjFileset::path("b.rs")], "split out b")
4355            .await
4356            .unwrap();
4357        jj.duplicate(Path::new("/r"), &rv("@-")).await.unwrap();
4358        let calls = rec.calls();
4359        assert_eq!(calls[0].args_str(), ["absorb", "--color", "never"]);
4360        assert_eq!(
4361            calls[1].args_str(),
4362            [
4363                "absorb",
4364                "--from",
4365                "@-",
4366                "root-file:\"src/a.rs\"",
4367                "--color",
4368                "never"
4369            ]
4370        );
4371        assert_eq!(
4372            calls[2].args_str(),
4373            [
4374                "split",
4375                "-m",
4376                "split out b",
4377                "root-file:\"b.rs\"",
4378                "--color",
4379                "never"
4380            ]
4381        );
4382        assert_eq!(calls[3].args_str(), ["duplicate", "@-", "--color", "never"]);
4383    }
4384
4385    // An empty split would open jj's interactive diff editor and hang headless —
4386    // it must be refused BEFORE any process spawns.
4387    #[tokio::test]
4388    async fn split_paths_refuses_empty_filesets_without_spawning() {
4389        let rec = RecordingRunner::replying(Reply::ok(""));
4390        let jj = Jj::with_runner(&rec);
4391        let err = jj
4392            .split_paths(Path::new("/r"), &[], "msg")
4393            .await
4394            .expect_err("empty filesets must be refused");
4395        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4396        assert!(rec.calls().is_empty(), "nothing may spawn");
4397    }
4398
4399    // M7: an empty fileset slice must NOT degrade to a bare `jj commit` (which would
4400    // commit the whole working copy) — it's refused before any spawn.
4401    #[tokio::test]
4402    async fn commit_paths_refuses_empty_filesets_without_spawning() {
4403        let rec = RecordingRunner::replying(Reply::ok(""));
4404        let jj = Jj::with_runner(&rec);
4405        let err = jj
4406            .commit_paths(Path::new("/r"), &[], "msg")
4407            .await
4408            .expect_err("empty filesets must be refused");
4409        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4410        assert!(rec.calls().is_empty(), "nothing may spawn");
4411    }
4412
4413    #[tokio::test]
4414    async fn log_paths_builds_revset_template_and_filesets() {
4415        let rec = RecordingRunner::replying(Reply::ok(""));
4416        let jj = Jj::with_runner(&rec);
4417        jj.log_paths(
4418            Path::new("."),
4419            &rv("main..@"),
4420            5,
4421            &[JjFileset::path("x|y.rs"), JjFileset::path("z.rs")],
4422        )
4423        .await
4424        .expect("log_paths");
4425        assert_eq!(
4426            rec.only_call().args_str(),
4427            [
4428                "log",
4429                "-r",
4430                "main..@",
4431                "-n5",
4432                "--no-graph",
4433                "-T",
4434                parse::CHANGE_TEMPLATE,
4435                "root-file:\"x|y.rs\"",
4436                "root-file:\"z.rs\"",
4437                "--color",
4438                "never"
4439            ]
4440        );
4441    }
4442
4443    // An empty fileset slice must NOT degrade to a bare `jj log -r <revset>`
4444    // (unrestricted history) — it's refused before any spawn, mirroring
4445    // `commit_paths_refuses_empty_filesets_without_spawning`.
4446    #[tokio::test]
4447    async fn log_paths_refuses_empty_filesets_without_spawning() {
4448        let rec = RecordingRunner::replying(Reply::ok(""));
4449        let jj = Jj::with_runner(&rec);
4450        let err = jj
4451            .log_paths(Path::new("."), &rv("@"), 5, &[])
4452            .await
4453            .expect_err("empty filesets must be refused");
4454        assert!(matches!(err, Error::Spawn { .. }), "got {err:?}");
4455        assert!(rec.calls().is_empty(), "nothing may spawn");
4456    }
4457
4458    #[tokio::test]
4459    async fn op_log_parses_template_rows() {
4460        let rec = RecordingRunner::new(ScriptedRunner::new().on(
4461            ["jj", "op", "log"],
4462            Reply::ok("abc\t\"u@h\"\t2026-06-05T10:00:00+0200\t\"new empty commit\"\n"),
4463        ));
4464        let jj = Jj::with_runner(&rec);
4465        let ops = jj.op_log(Path::new("."), 5).await.expect("op_log");
4466        assert_eq!(ops.len(), 1);
4467        assert_eq!(ops[0].id, "abc");
4468        assert_eq!(ops[0].description, "new empty commit");
4469        let args = rec.only_call().args_str();
4470        assert_eq!(&args[..5], &["op", "log", "--no-graph", "--limit", "5"]);
4471    }
4472
4473    // evolog must use the commit-context template (bare `change_id` doesn't
4474    // exist there) but flows through the same Change parser.
4475    #[tokio::test]
4476    async fn evolog_uses_commit_context_template() {
4477        let rec = RecordingRunner::new(
4478            ScriptedRunner::new().on(["jj", "evolog"], Reply::ok("kz\t38\tfalse\t\"wip\"\n")),
4479        );
4480        let jj = Jj::with_runner(&rec);
4481        let rows = jj
4482            .evolog(Path::new("."), &rv("@"), 10)
4483            .await
4484            .expect("evolog");
4485        assert_eq!(rows.len(), 1);
4486        assert_eq!(rows[0].description, "wip");
4487        let args = rec.only_call().args_str();
4488        assert_eq!(
4489            &args[..6],
4490            &["evolog", "-r", "@", "--no-graph", "--limit", "10"]
4491        );
4492        let template = &args[7];
4493        assert!(
4494            template.contains("commit.change_id()"),
4495            "commit-context form required, got {template}"
4496        );
4497    }
4498
4499    #[tokio::test]
4500    async fn file_annotate_and_show_build_args() {
4501        let rec = RecordingRunner::new(
4502            ScriptedRunner::new()
4503                .on(
4504                    ["jj", "file", "annotate"],
4505                    Reply::ok("kz\tline one\nkz\tline two"),
4506                )
4507                .on(["jj", "file", "show"], Reply::ok("content\n")),
4508        );
4509        let jj = Jj::with_runner(&rec);
4510        let lines = jj
4511            .file_annotate(Path::new("."), "src/a.rs", Some(rv("@-")))
4512            .await
4513            .expect("annotate");
4514        assert_eq!(lines.len(), 2);
4515        assert_eq!(lines[0].change_id, "kz");
4516        assert_eq!(lines[1].line, 2);
4517        // H7: the file's trailing newline is preserved verbatim, not trimmed.
4518        assert_eq!(
4519            jj.file_show(Path::new("."), &rv("@-"), "src/a.rs")
4520                .await
4521                .unwrap(),
4522            "content\n"
4523        );
4524        let calls = rec.calls();
4525        // The path follows a `--` separator (a leading-`-` filename stays safe);
4526        // `--color never` must precede `--`, not trail it.
4527        assert_eq!(
4528            calls[0].args_str(),
4529            [
4530                "file",
4531                "annotate",
4532                "-r",
4533                "@-",
4534                "-T",
4535                parse::ANNOTATE_TEMPLATE,
4536                "--color",
4537                "never",
4538                "--",
4539                "src/a.rs"
4540            ]
4541        );
4542        // file_show wraps the path as an exact-path fileset (metacharacters in
4543        // the name must stay literal); annotate takes a PLAIN path — quoting
4544        // it would break jj's path lookup.
4545        assert_eq!(
4546            calls[1].args_str(),
4547            [
4548                "file",
4549                "show",
4550                "-r",
4551                "@-",
4552                "root-file:\"src/a.rs\"",
4553                "--color",
4554                "never"
4555            ]
4556        );
4557    }
4558
4559    // `description` is a fixed template query: first match only, raw description.
4560    #[tokio::test]
4561    async fn description_builds_single_commit_template_query() {
4562        let rec = RecordingRunner::replying(Reply::ok("feat: parser\n\nbody\n"));
4563        let jj = Jj::with_runner(&rec);
4564        let text = jj
4565            .description(Path::new("."), &rv("abc123"))
4566            .await
4567            .expect("description");
4568        assert_eq!(text, "feat: parser\n\nbody");
4569        assert_eq!(
4570            rec.only_call().args_str(),
4571            [
4572                "log",
4573                "-r",
4574                "abc123",
4575                "--no-graph",
4576                "--limit",
4577                "1",
4578                "-T",
4579                "description",
4580                "--color",
4581                "never"
4582            ]
4583        );
4584    }
4585
4586    // H7: content verbs return jj's output byte-for-byte — the round-trip-corrupting
4587    // cases are multiple trailing newlines, a missing final newline, and a diff whose
4588    // last hunk ends in a blank context line.
4589    #[tokio::test]
4590    async fn content_verbs_preserve_exact_trailing_bytes() {
4591        for raw in ["a\nb\n\n", "no-final-newline", "trailing   \n"] {
4592            let rec = RecordingRunner::replying(Reply::ok(raw));
4593            let jj = Jj::with_runner(&rec);
4594            assert_eq!(
4595                jj.file_show(Path::new("."), &rv("@"), "f.txt")
4596                    .await
4597                    .expect("file_show"),
4598                raw
4599            );
4600        }
4601        let diff = "diff --git a/f b/f\n@@ -1,2 +1,2 @@\n-x\n+y\n \n";
4602        let rec = RecordingRunner::replying(Reply::ok(diff));
4603        let jj = Jj::with_runner(&rec);
4604        assert_eq!(
4605            jj.diff_text(Path::new("."), DiffSpec::Rev("@".into()))
4606                .await
4607                .expect("diff_text"),
4608            diff
4609        );
4610    }
4611
4612    // `diff_text` for the working copy must build `diff -r @ --git`.
4613    #[tokio::test]
4614    async fn diff_text_builds_working_copy_args() {
4615        let rec = RecordingRunner::replying(Reply::ok(""));
4616        let jj = Jj::with_runner(&rec);
4617        jj.diff_text(Path::new("."), DiffSpec::WorkingTree)
4618            .await
4619            .expect("diff_text");
4620        assert_eq!(
4621            rec.only_call().args_str(),
4622            ["diff", "-r", "@", "--git", "--color", "never"]
4623        );
4624    }
4625
4626    // Every repo-scoped command forces `--color never` so a user's
4627    // `ui.color = "always"` config can't wrap parsed output in ANSI escapes.
4628    #[tokio::test]
4629    async fn commands_force_color_off() {
4630        let rec = RecordingRunner::replying(Reply::ok("x\n"));
4631        let jj = Jj::with_runner(&rec);
4632        jj.status_text(Path::new(".")).await.expect("status_text");
4633        let args = rec.only_call().args_str();
4634        let pos = args.iter().position(|a| a == "--color");
4635        assert_eq!(
4636            pos.map(|p| args.get(p + 1).map(String::as_str)),
4637            Some(Some("never"))
4638        );
4639    }
4640
4641    // Hermetic: real diff() arg-building (`Rev`) + the ported parser against
4642    // canned git-format output.
4643    #[tokio::test]
4644    async fn diff_parses_scripted_output() {
4645        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
4646        let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "diff"], Reply::ok(out)));
4647        let files = jj
4648            .diff(Path::new("."), DiffSpec::Rev("@-".into()))
4649            .await
4650            .expect("diff");
4651        assert_eq!(files.len(), 1);
4652        assert_eq!(files[0].path, Path::new("m"));
4653        assert_eq!(files[0].change, ChangeKind::Modified);
4654    }
4655
4656    // T-049: a content read (`diff_text`) over the client's default OutputBudget is
4657    // refused with `OutputTooLarge` (actual `total_bytes` + allowed `max_bytes`),
4658    // never a silently truncated diff. The oversized output is drained but not
4659    // retained (the error carries only counts): the bounded-memory contract.
4660    #[tokio::test]
4661    async fn diff_text_over_budget_errors_output_too_large() {
4662        let big = "diff --git a/m b/m\n".to_string() + &"+line\n".repeat(20_000);
4663        assert!(big.len() > 64 * 1024, "fixture must exceed the budget");
4664        let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "diff"], Reply::ok(&big)))
4665            .default_output_budget(OutputBudget::bytes(64 * 1024));
4666        match jj
4667            .diff_text(Path::new("."), DiffSpec::Rev("@-".into()))
4668            .await
4669        {
4670            Err(Error::OutputTooLarge {
4671                program,
4672                max_bytes,
4673                total_bytes,
4674                ..
4675            }) => {
4676                assert_eq!(program, "jj");
4677                assert_eq!(max_bytes, Some(64 * 1024));
4678                assert!(total_bytes > 64 * 1024, "actual exceeds allowed");
4679            }
4680            other => panic!("expected OutputTooLarge, got {other:?}"),
4681        }
4682    }
4683
4684    // Below the budget the diff parses in full — the ceiling only fires over-cap.
4685    #[tokio::test]
4686    async fn diff_under_budget_parses_full_output() {
4687        let out = "diff --git a/m b/m\n--- a/m\n+++ b/m\n@@ -1 +1 @@\n-a\n+b\n";
4688        let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "diff"], Reply::ok(out)))
4689            .default_output_budget(OutputBudget::bytes(64 * 1024));
4690        let files = jj
4691            .diff(Path::new("."), DiffSpec::Rev("@-".into()))
4692            .await
4693            .expect("under-budget diff");
4694        assert_eq!(files.len(), 1);
4695        assert_eq!(files[0].path, Path::new("m"));
4696    }
4697
4698    // A blob read (`file_show`) honours the same budget, and its per-call override
4699    // reads a legitimately large file the default budget would refuse.
4700    #[tokio::test]
4701    async fn file_show_over_budget_errors_and_override_reads() {
4702        let big = "x".repeat(200_000);
4703        let jj = Jj::with_runner(ScriptedRunner::new().on(["jj", "file", "show"], Reply::ok(&big)))
4704            .default_output_budget(OutputBudget::bytes(64 * 1024));
4705        assert!(matches!(
4706            jj.file_show(Path::new("."), &rv("@"), "big.bin").await,
4707            Err(Error::OutputTooLarge { .. })
4708        ));
4709        let got = jj
4710            .file_show_within(
4711                Path::new("."),
4712                &rv("@"),
4713                "big.bin",
4714                OutputBudget::unlimited(),
4715            )
4716            .await
4717            .expect("override reads the large file");
4718        assert_eq!(got, big);
4719    }
4720
4721    #[cfg(feature = "mock")]
4722    #[tokio::test]
4723    async fn consumer_mocks_the_interface() {
4724        let mut mock = MockJjApi::new();
4725        mock.expect_describe().returning(|_, _| Ok(()));
4726        assert!(mock.describe(Path::new("."), "msg").await.is_ok());
4727    }
4728}
4729
4730// Long-form how-to guides, rendered from this crate's docs/*.md on docs.rs.
4731#[doc = include_str!("../docs/jj.md")]
4732#[allow(rustdoc::broken_intra_doc_links)]
4733pub mod guide {}