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 {}