Skip to main content

Module guide

Module guide 

Source
Expand description

§vcs-jj — Jujutsu CLI guide

Typed, repo-scoped, async commands over the jj binary, behind a mockable interface. Every method runs jj inside an OS job (via processkit) so a subprocess is never orphaned, returns the structured Error, and honours an optional timeout.

There is deliberately no Jj::hardened() — jj has no repo-local hooks, and its config comes from the user/repo TOML files jj itself trusts. In a colocated repo the risk lives on the git side (git hooks fire when git commands run there), so harden the Git client you point at it instead.

§Construction & configuration

use vcs_jj::Jj;

let jj = Jj::new();                                       // real, job-backed runner
let jj = Jj::new().default_timeout(Duration::from_secs(10)); // every cmd → Error::Timeout past 10s
  • Jj::new() — the production client over the real job-backed runner.
  • Jj::with_runner(runner) — inject a fake ProcessRunner (e.g. processkit::ScriptedRunner) for hermetic tests; see Testing & mocking.
  • default_timeout(Duration) — builder; arms a per-command timeout.

All three come from the processkit::cli_client! macro that defines Jj.

§The cwd-bound view (JjAt)

Most JjApi methods take a leading dir: &Path. When you drive one directory repeatedly, bind it once with jj.at(&path) — the returned JjAt drops that argument:

let jj = Jj::new();
let at = jj.at(repo);          // JjAt — Copy, borrows the client + path
let head = at.current_change().await?;   // == jj.current_change(repo)
at.describe("feat: thing").await?;        // == jj.describe(repo, "…")

JjAt is Copy for every runner (it holds only references). The dir-taking JjApi methods stay on Jj so one client can drive many directories (e.g. workspaces). Through the facade, vcs_core::Repo::jj_at yields the same handle.

§Inherent run_args / run_raw_args

The object-safe JjApi trait can’t take &[&str], so two inherent helpers do — no Vec<String> allocation:

let out = jj.run_args(&["log", "-r", "@"]).await?;          // String, errors on non-zero exit
let res = jj.run_raw_args(&["status"]).await?;              // ProcessResult<String>, never errors on exit

§transaction — op-log rollback

Run a closure with op-log rollback: capture the current operation (op_head), run f against a bound JjAt, and on Err restore the repo to that operation (op_restore) before propagating the error.

jj.transaction(repo, |tx| async move {
    tx.describe("wip").await?;
    tx.new_change("next").await        // an Err here rolls back the describe
})
.await?;

Signature:

pub async fn transaction<'a, T, F, Fut>(&'a self, dir: &'a Path, f: F) -> Result<T>
where
    F: FnOnce(JjAt<'a, R>) -> Fut,
    Fut: Future<Output = Result<T>> + 'a;

Inherent (not on the object-safe trait): the closure parameter is generic, which mockall/trait objects can’t express. JjAt::transaction(f) is the bound form.

Caveats (verbatim from source): rollback runs on Err only — not on panic or a dropped future (no async Drop); convert panics to Err inside f if you need that. If the restore itself fails, the original error is returned and the repo may be left mid-transaction — re-probe op_head to detect that.


§Status & changes

async fn status(&self, dir: &Path) -> Result<Vec<ChangedPath>>;
async fn status_text(&self, dir: &Path) -> Result<String>;
async fn current_change(&self, dir: &Path) -> Result<Change>;

status is the machine-stable form of jj status — it runs diff -r @ --summary and parses one ChangedPath per <letter> <path> line (mirrors vcs_git::status). status_text is the raw human-readable jj status text. current_change is log -r @ reduced to one Change.

for c in jj.status(repo).await? {                  // Vec<ChangedPath>
    println!("{} {}", c.status, c.path);           // e.g. 'M' src/lib.rs
}
let head = jj.current_change(repo).await?;         // Change { change_id, commit_id, empty, description }

§Log

async fn log(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>>;
async fn evolog(&self, dir: &Path, revset: &str, max: usize) -> Result<Vec<Change>>;

log returns changes matching revset, newest first, up to max (jj log). evolog returns how the commit revset resolves to evolved — newest snapshot first, one Change per recorded predecessor (jj evolog).

for c in jj.log(repo, "::@", 10).await? {          // Vec<Change>
    println!("{} {}{}", c.change_id, if c.empty { "(empty) " } else { "" }, c.description);
}
let history = jj.evolog(repo, "@", 5).await?;      // Vec<Change>

§Descriptions

async fn describe(&self, dir: &Path, message: &str) -> Result<()>;
async fn describe_rev(&self, dir: &Path, revset: &str, message: &str) -> Result<()>;
async fn new_change(&self, dir: &Path, message: &str) -> Result<()>;
async fn description(&self, dir: &Path, revset: &str) -> Result<String>;

describe sets @’s description (describe -m); describe_rev an arbitrary revision (describe -r <revset> -m). new_change starts a fresh change on top (new -m). description returns the full (possibly multiline) description of the commit revset resolves to, trailing whitespace trimmed — empty for an undescribed change or for a revset matching no commit (an invalid revset still errors); a multi-commit revset yields only the newest commit’s description.

jj.describe(repo, "feat: parser").await?;
jj.new_change(repo, "wip: follow-up").await?;
let msg = jj.description(repo, "@-").await?;        // String (empty if undescribed)

§Bookmarks

async fn bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
async fn bookmarks_all(&self, dir: &Path) -> Result<Vec<BookmarkRef>>;
async fn reachable_bookmarks(&self, dir: &Path) -> Result<Vec<Bookmark>>;
async fn current_bookmark(&self, dir: &Path) -> Result<Option<String>>;
async fn trunk(&self, dir: &Path) -> Result<Option<String>>;
async fn bookmark_create(&self, dir: &Path, name: &str, revision: &str) -> Result<()>;
async fn bookmark_delete(&self, dir: &Path, name: &str) -> Result<()>;
async fn bookmark_rename(&self, dir: &Path, old: &str, new: &str) -> Result<()>;
async fn bookmark_track(&self, dir: &Path, name: &str, remote: &str) -> Result<()>;
async fn bookmark_set(&self, dir: &Path, name: &str, revision: &str) -> Result<()>;
async fn bookmark_move(&self, dir: &Path, name: &str, to: &str, allow_backwards: bool) -> Result<()>;
  • bookmarks — local bookmarks (bookmark list).
  • bookmarks_all — local and remote-tracking (bookmark list -a); richer BookmarkRef rows.
  • reachable_bookmarks — local bookmarks on the nearest commits reachable from @ (log -r 'heads(::@ & bookmarks())'); the candidate targets a commit “belongs to”. A commit carrying several bookmarks yields one entry each.
  • current_bookmark — the single bookmark on @ (or the first of several); None when @ carries none.
  • trunk — the trunk bookmark (log -r 'trunk()'); None when unresolved.
  • bookmark_create / bookmark_delete / bookmark_rename — at/by name.
  • bookmark_track — track a remote bookmark (bookmark track <name>@<remote>).
  • bookmark_set — point a bookmark at revision (bookmark set <name> -r).
  • bookmark_move — move to to; pass allow_backwards to append --allow-backwards.

Every name-taking method rejects an empty or leading-- name before spawning (see Validating newtypes).

jj.bookmark_set(repo, "main", "@").await?;           // point `main` at @
for b in jj.bookmarks(repo).await? {                 // Vec<Bookmark>
    println!("{} -> {}", b.name, b.target);
}
if let Some(trunk) = jj.trunk(repo).await? {          // Option<String>
    println!("trunk = {trunk}");
}

§Diff & query

async fn diff(&self, dir: &Path, spec: DiffSpec) -> Result<Vec<FileDiff>>;
async fn diff_text(&self, dir: &Path, spec: DiffSpec) -> Result<String>;
async fn diff_summary(&self, dir: &Path, from: &str, to: &str) -> Result<Vec<ChangedPath>>;
async fn diff_stat(&self, dir: &Path, revset: &str) -> Result<DiffStat>;
async fn commit_count(&self, dir: &Path, revset: &str) -> Result<usize>;
async fn template_query(&self, dir: &Path, revset: &str, template: &str, limit: Option<usize>) -> Result<String>;
  • diff — parsed per-file unified diff for DiffSpec (layered on diff_text).
  • diff_text — raw git-format unified diff (diff -r <spec> --git); stable machine output.
  • diff_summary — per-file change summary for a range; the endpoints are parenthesised internally ((from)..(to)) so a compound revset keeps its meaning.
  • diff_stat — aggregate counts for a revset (diff -r <revset> --stat).
  • commit_count — number of commits in a revset (one id per line, counted).
  • template_query — run an arbitrary templated jj log query and return raw stdout (log -r <revset> --no-graph [--limit n] -T <template>); the escape hatch the typed queries are built on.
let files = jj.diff(repo, DiffSpec::WorkingTree).await?;     // Vec<FileDiff>
let text  = jj.diff_text(repo, DiffSpec::Rev("@-".into())).await?; // String (git format)
let stat  = jj.diff_stat(repo, "@").await?;                  // DiffStat
let n     = jj.commit_count(repo, "main..@").await?;         // usize
let raw   = jj.template_query(repo, "@", "change_id.short()", Some(1)).await?; // String

§File inspection

async fn file_show(&self, dir: &Path, revset: &str, path: &str) -> Result<String>;
async fn file_annotate(&self, dir: &Path, path: &str, revset: Option<String>) -> Result<Vec<AnnotationLine>>;

file_show returns a file’s content at a revision. path is wrapped as an exact-path fileset (file:"<path>") so fileset metacharacters in the name stay literal; content is decoded lossily — a binary file comes back mangled rather than erroring.

file_annotate returns per-line authorship (file annotate; revset: None = @): which change introduced each line. Here path is a plain PATH (jj’s file annotate rejects the file:"…" form), passed after a -- separator so a -dash.txt stays literal.

let src = jj.file_show(repo, "@", "src/lib.rs").await?;           // String
for line in jj.file_annotate(repo, "src/lib.rs", None).await? {   // Vec<AnnotationLine>
    println!("{:>4} {} {}", line.line, line.change_id, line.content);
}

§Conflict probing

async fn is_conflicted(&self, dir: &Path, revset: &str) -> Result<bool>;
async fn has_workingcopy_conflict(&self, dir: &Path) -> Result<bool>;
async fn resolve_list(&self, dir: &Path, revset: &str) -> Result<Vec<String>>;

is_conflicted asks the template engine whether the commit a revset resolves to has a conflict (no localized-prose matching). has_workingcopy_conflict is is_conflicted(dir, "@"). resolve_list returns the paths with unresolved conflicts in revset (resolve --list -r <revset>), forward-slash normalised — empty when there are none. Parsing the materialized markers in a conflicted file is a separate, pure module: see Conflict resolution.

if jj.has_workingcopy_conflict(repo).await? {
    for p in jj.resolve_list(repo, "@").await? {     // Vec<String>
        eprintln!("conflict: {p}");
    }
}

§Rebasing & editing

async fn rebase(&self, dir: &Path, onto: &str) -> Result<()>;
async fn rebase_branch(&self, dir: &Path, branch: &str, dest: &str) -> Result<()>;
async fn edit(&self, dir: &Path, revset: &str) -> Result<()>;

rebase moves the working copy onto a destination (rebase -d <onto>); rebase_branch a whole branch (rebase -b <branch> -d <dest>); edit moves the working copy to a revision (edit <rev>). edit’s revset is guarded against a leading-- value.

jj.rebase(repo, "main").await?;
jj.edit(repo, "@-").await?;

§Squash & split

async fn squash_into(&self, dir: &Path, into: &str, use_destination_message: bool) -> Result<()>;
async fn commit_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
async fn squash_paths(&self, dir: &Path, spec: SquashPaths) -> Result<()>;
async fn split_paths(&self, dir: &Path, filesets: &[JjFileset], message: &str) -> Result<()>;
async fn absorb(&self, dir: &Path, from: Option<String>, filesets: &[JjFileset]) -> Result<()>;
  • squash_into — squash the working copy into into (squash --into). With use_destination_message, keep the destination’s description (--use-destination-message) instead of combining the two.
  • commit_paths — finalise a commit from exactly these JjFilesets (commit -m <message> <filesets>); the rest stay in the new working-copy change.
  • squash_paths — squash exactly the spec’s filesets from one revision into another (squash --from <from> --into <into> [--use-destination-message] <filesets>); built through SquashPaths.
  • split_paths — split exactly these filesets out of @ into their own commit (split -m <message> <filesets>). filesets must be non-empty — a fileset-less split opens jj’s interactive diff editor (a headless hang), so it is refused with an Error::Spawn before spawning.
  • absorb — fold working-copy edits into the mutable ancestors that introduced the touched lines (absorb [--from <revset>] [<filesets>…]); an empty filesets absorbs everything.
let only = [JjFileset::path("src/parser.rs")];
jj.split_paths(repo, &only, "feat: parser").await?;
jj.commit_paths(repo, &only, "feat: parser").await?;
jj.squash_into(repo, "@-", false).await?;
jj.squash_paths(repo, SquashPaths::new("@", "@-").filesets(only)).await?;
jj.absorb(repo, None, &[]).await?;            // absorb everything into ancestors

§Sparse

async fn sparse_set(&self, dir: &Path, patterns: &[String]) -> Result<()>;

Set the working copy’s sparse patterns to exactly patterns (sparse set --clear --add <p>…): --clear empties first, then each --add reinstates one pattern — an empty list clears the working copy.

jj.sparse_set(repo, &["src".into(), "Cargo.toml".into()]).await?;

§Merging

async fn new_merge(&self, dir: &Path, message: &str, parents: Vec<String>) -> Result<()>;
async fn duplicate(&self, dir: &Path, revset: &str) -> Result<()>;
async fn abandon(&self, dir: &Path, revset: &str) -> Result<()>;

new_merge creates a new change with the given parents (new -m <msg> <p1> <p2> …); each parent is a bare positional and is guarded against a leading-- value. duplicate duplicates the commits a revset resolves to. abandon abandons a revision; its revset is guarded too.

jj.new_merge(repo, "merge: a + b", vec!["feature-a".into(), "feature-b".into()]).await?;
jj.duplicate(repo, "abc123").await?;
jj.abandon(repo, "@-").await?;

§Git integration

async fn git_fetch(&self, dir: &Path) -> Result<()>;
async fn git_fetch_from(&self, dir: &Path, remote: &str) -> Result<()>;
async fn git_fetch_branch(&self, dir: &Path, branch: &str) -> Result<()>;
async fn git_push(&self, dir: &Path, bookmark: Option<String>) -> Result<()>;
async fn git_import(&self, dir: &Path) -> Result<()>;
async fn git_clone(&self, url: &str, dest: &Path, colocate: bool) -> Result<()>;
  • git_fetchjj git fetch. Transient (network) failures are retried: 3 attempts, 500 ms backoff (DNS, timeout, dropped connection — see is_transient_fetch_error).
  • git_fetch_from — fetch a named remote (git fetch --remote <remote>); same retry policy.
  • git_fetch_branch — fetch a single bookmark from origin (git fetch --remote origin -b <branch>); same retry policy.
  • git_pushjj git push, optionally -b <bookmark>. The bookmark is owned (Option<String>) to keep the trait mockall-friendly.
  • git_import — import git refs into jj (jj git import) — colocated-repo sync.
  • git_clone — clone into dest (git clone <url> <dest> --colocate|--no-colocate). Runs without a working directory — pass an absolute dest. The colocate flag is always passed explicitly: whether colocation is jj’s default depends on the jj version and the user’s git.colocate config, so colocate decides deterministically. url is guarded against a leading-- value.
jj.git_fetch(repo).await?;
jj.git_push(repo, Some("main".to_string())).await?;     // `jj git push -b main`
jj.git_clone("https://example.com/r.git", Path::new("/abs/dest"), true).await?;

§Workspaces

async fn workspace_list(&self, dir: &Path) -> Result<Vec<Workspace>>;
async fn workspace_root(&self, dir: &Path, name: Option<String>) -> Result<PathBuf>;
async fn workspace_add(&self, dir: &Path, spec: WorkspaceAdd) -> Result<()>;
async fn workspace_forget(&self, dir: &Path, name: &str) -> Result<()>;

jj’s worktrees, with structured results. workspace_list returns Workspace rows; workspace_root resolves a workspace’s root path (workspace root [--name <name>]); workspace_add adds one from a WorkspaceAdd spec; workspace_forget forgets one by name.

jj.workspace_add(repo, WorkspaceAdd::new("feature", "@", "/tmp/feature")).await?;
for ws in jj.workspace_list(repo).await? {              // Vec<Workspace>
    println!("{} @ {} {:?}", ws.name, ws.commit, ws.bookmarks);
}
jj.workspace_forget(repo, "feature").await?;

A synchronous, best-effort vcs_jj::blocking module mirrors workspace_forget (and workspace_name_for_path) for Drop guards that cannot .await. It shells out via std::process directly — no async, no job containment — so reserve it for short-lived cleanup.

§Operation log

async fn op_head(&self, dir: &Path) -> Result<String>;
async fn op_log(&self, dir: &Path, limit: usize) -> Result<Vec<Operation>>;
async fn op_restore(&self, dir: &Path, op_id: &str) -> Result<()>;
async fn op_undo(&self, dir: &Path) -> Result<()>;

op_head returns the current operation id (op log --no-graph --limit 1) — capture it before a risky sequence to roll back to. op_log returns the newest limit Operations, newest first. op_restore restores the repo to an operation (op restore <id>; the id is guarded). op_undo undoes the latest operation. (transaction is the higher-level wrapper around capture + restore.)

let head = jj.op_head(repo).await?;            // String — capture before mutating
// … risky work …
jj.op_restore(repo, &head).await?;             // roll back
for op in jj.op_log(repo, 5).await? {          // Vec<Operation>
    println!("{} {} {}", op.id, op.time, op.description);
}

§Discovery

async fn root(&self, dir: &Path) -> Result<PathBuf>;
async fn version(&self) -> Result<String>;
async fn capabilities(&self) -> Result<JjCapabilities>;

root is the working-copy root of the current workspace (jj root). version is the raw jj --version string. capabilities parses that into JjCapabilities — a value type; probe once and keep the result. The crate’s validated floor is jj ≥ 0.38 (JjCapabilities::is_supported); an unrecognisable version string is an Error::Parse.

let caps = jj.capabilities().await?;           // JjCapabilities
caps.ensure_supported()?;                      // clear "needs jj >= 0.38, found 0.35.0"
println!("jj {} (root {})", caps.version, jj.root(repo).await?.display());

§Raw escape hatches

async fn run(&self, args: &[String]) -> Result<String>;
async fn run_raw(&self, args: &[String]) -> Result<ProcessResult<String>>;

run executes jj <args> and returns trimmed stdout (errors on a non-zero exit). run_raw never errors on a non-zero exit — it returns the captured ProcessResult so the caller inspects code()/stdout()/stderr(). These are not injection-guarded; the inherent run_args/run_raw_args are the &[&str] siblings.

let out = jj.run(&["log".into(), "-r".into(), "@".into()]).await?;   // String
let res = jj.run_raw(&["status".into()]).await?;                    // ProcessResult<String>

§Result types

The diff types (ChangeKind, DiffLine, Hunk, FileDiff, DiffStat, parse_diff) and JjVersion actually live in the shared vcs-diff crate — jj diff --git and git diff are byte-identical, so vcs-jj and vcs-git share one parser. They’re re-exported here, so vcs_jj::FileDiff etc. still resolve (JjVersion is an alias of vcs_diff::Version).

§Change

A jj change, parsed from a tab-delimited template row.

FieldTypeNotes
change_idStringShort change id (change_id.short()).
commit_idStringShort commit id.
emptybooltrue when the change makes no file modifications.
descriptionStringFirst line of the description (empty if undescribed).

§Bookmark

FieldTypeNotes
nameStringBookmark name.
targetStringShort id of the commit it points at.

§BookmarkRef

From bookmark list -a — local or remote-tracking.

FieldTypeNotes
nameStringBookmark name.
remoteOption<String>Remote (e.g. origin/git); None for a local.
targetStringShort commit id (empty for a conflicted bookmark).
trackedboolWhether this remote-tracking bookmark is tracked (false for locals).

§Workspace

FieldTypeNotes
nameStringWorkspace name (default for the main one).
commitStringShort commit id of the working-copy commit.
bookmarksVec<String>Local bookmarks at that commit (empty when none).

§ChangedPath

One jj diff --summary entry.

FieldTypeNotes
statuscharM modified, A added, D deleted, R renamed, C copied.
pathStringThe path the status applies to — the new path for a rename/copy (forward-slash normalised).
old_pathOption<String>For R/C, the original path; None otherwise.

§DiffStat

Aggregate counts from the diff --stat footer (Copy, Default).

FieldType
files_changedusize
insertionsusize
deletionsusize

§FileDiff

One file’s entry in a parsed git-format unified diff.

FieldTypeNotes
changeChangeKindHow the file changed.
pathStringPath — the new path for a rename — forward-slash normalised.
old_pathOption<String>For a rename, the original path; None otherwise.
hunksVec<Hunk>The @@ hunks; empty for a binary file or pure rename.
rawStringThe verbatim diff --git … section, for callers that display raw text.
§Hunk
FieldTypeNotes
old_startusizeStart line in the old file.
old_linesusizeOld-file line count (defaults to 1 when ,<count> omitted).
new_startusizeStart line in the new file.
new_linesusizeNew-file line count (defaults to 1 when omitted).
sectionStringText after the closing @@ (function/section heading); empty when none.
linesVec<DiffLine>One entry per +/-/ line.
§DiffLine (enum)

The stored text excludes the leading /+/- marker.

  • Context(String) — unchanged context line.
  • Added(String) — added line.
  • Removed(String) — removed line.
§ChangeKind (enum, Copy)
  • Addednew file mode ….
  • Modified — contents changed.
  • Deleteddeleted file mode ….
  • Renamedrename from … / rename to ….

§Operation

One jj op log row.

FieldTypeNotes
idStringShort operation id — what op restore/op undo take.
userStringOS-level user@host that ran the operation (not the jj author).
timeStringStart timestamp, ISO 8601 with offset.
descriptionStringFirst line of the operation description (e.g. new empty commit).

§AnnotationLine

One jj file annotate line.

FieldTypeNotes
change_idStringShort change id that introduced the line.
lineu321-based line number in the annotated file.
contentStringThe line’s content (no trailing newline).

§JjVersion

Parsed jj --version (Copy, Ord). Fields: major: u64, minor: u64, patch: u64 (patch reads 0 when the binary reports only major.minor). Display renders major.minor.patch.

§JjCapabilities

What the installed binary supports (Copy, #[non_exhaustive]).

FieldType
versionJjVersion

Methods: is_supported() -> bool (jj ≥ 0.38) and ensure_supported() -> Result<()> (a clear “needs jj >= 0.38, found …” error otherwise).


§Config & builder types

§DiffSpec (enum, #[non_exhaustive])

What diff / diff_text compares.

  • WorkingTree — the working-copy change’s diff (jj diff -r @).
  • Rev(String) — a specific revset, e.g. @- or main..@ (jj diff -r <revset>).

§SparseMode (enum, Copy, #[non_exhaustive])

How a new workspace inherits sparse patterns (--sparse-patterns <mode>).

  • Copy — copy all patterns from the current workspace (jj’s default).
  • Full — include every file.
  • Empty — start with no files; the caller sets patterns afterwards (CoW flow).

§WorkspaceAdd (#[non_exhaustive])

Options for workspace_add; build through WorkspaceAdd::new.

FieldTypeNotes
nameStringName for the new workspace.
baseStringRevision the working copy starts at (-r <base>).
pathPathBufFilesystem path for the new workspace.
sparse_patternsOption<SparseMode>--sparse-patterns; None leaves jj’s default.
let spec = WorkspaceAdd::new("feature", "@", "/tmp/feature")
    .sparse(SparseMode::Empty);    // start empty, then sparse_set later

WorkspaceAdd::new(name, base, path) takes impl Into<String> / impl Into<String> / impl Into<PathBuf>; .sparse(mode) is the builder for sparse_patterns.

§SquashPaths (#[non_exhaustive])

Options for squash_paths; build through SquashPaths::new and the chained setters.

FieldTypeNotes
fromStringSource revision the filesets are squashed out of (--from).
intoStringDestination revision they squash into (--into).
filesetsVec<JjFileset>The exact filesets to move; empty squashes the whole from change.
use_destination_messageboolKeep the destination’s description (--use-destination-message).
let spec = SquashPaths::new("@", "@-")
    .filesets([JjFileset::path("src/parser.rs")])
    .use_destination_message();

SquashPaths::new(from, into) takes impl Into<String> / impl Into<String> (no filesets selected yet); .filesets(impl IntoIterator<Item = JjFileset>) sets them (replacing any already added), and .use_destination_message() keeps the destination’s description instead of combining the two.


§Validating newtypes & filesets

§RevsetExpr

Optional up-front validation for callers that accept revsets from untrusted input (UIs, bots, agents) and want to fail early. Deliberately minimal — jj’s revset grammar is too rich to validate here — it only guarantees the expression is non-empty and cannot be parsed as a flag (no leading -), matching the internal guard the positional-revset methods apply anyway. The dir-taking methods stay &str; this type is optional validation, not a required wrapper.

let r = RevsetExpr::new("main..@")?;       // Ok
assert!(RevsetExpr::new("").is_err());     // empty
assert!(RevsetExpr::new("-x").is_err());   // leading `-` → would parse as a flag

RevsetExpr::new(impl Into<String>) -> Result<Self>; .as_str() -> &str; implements Display.

§JjFileset

An exact-path jj fileset (file:"<path>"), so path metacharacters like (, ), |, * are treated literally rather than as fileset operators. Build it with JjFileset::path(path) (repo-root-relative); a Windows backslash separator is normalised to / (jj filesets are forward-slash — a literal-backslash path would match nothing), and a " is escaped for the file:"…" literal.

let fs = JjFileset::path(r#"src/a (copy).rs"#);
assert_eq!(fs.as_str(), r#"file:"src/a (copy).rs""#);

JjFileset::path(impl AsRef<str>) -> Self; .as_str() -> &str.

§Why injection guards, and why filesets

Every method that places a caller-supplied bookmark name, revset, parent, url, or operation id in a bare positional argv slot refuses an empty or leading-- value with an Error::Spawn before spawning (verified: jj edit -evil → “unexpected argument”). Flag-value slots (-r <revset>, -m <msg>) and the run/run_raw escape hatches are not guarded — jj itself rejects dash-values there with a clear error rather than misparsing them.

split_paths/commit_paths/squash_paths/absorb take &[JjFileset] rather than raw strings so path metacharacters can never be reinterpreted as fileset operators. For split_paths this is load-bearing for a different reason: an empty fileset list makes jj split open its interactive diff editor, which would hang a headless run indefinitely — so split_paths refuses an empty slice before spawning.


§See also