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 10sJj::new()— the production client over the real job-backed runner.Jj::with_runner(runner)— inject a fakeProcessRunner(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); richerBookmarkRefrows.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);Nonewhen@carries none.trunk— the trunk bookmark (log -r 'trunk()');Nonewhen 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 atrevision(bookmark set <name> -r).bookmark_move— move toto; passallow_backwardsto 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 forDiffSpec(layered ondiff_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 templatedjj logquery 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 intointo(squash --into). Withuse_destination_message, keep the destination’s description (--use-destination-message) instead of combining the two.commit_paths— finalise a commit from exactly theseJjFilesets (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 throughSquashPaths.split_paths— split exactly these filesets out of@into their own commit (split -m <message> <filesets>).filesetsmust be non-empty — a fileset-less split opens jj’s interactive diff editor (a headless hang), so it is refused with anError::Spawnbefore spawning.absorb— fold working-copy edits into the mutable ancestors that introduced the touched lines (absorb [--from <revset>] [<filesets>…]); an emptyfilesetsabsorbs 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_fetch—jj git fetch. Transient (network) failures are retried: 3 attempts, 500 ms backoff (DNS, timeout, dropped connection — seeis_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_push—jj git push, optionally-b <bookmark>. The bookmark is owned (Option<String>) to keep the traitmockall-friendly.git_import— import git refs into jj (jj git import) — colocated-repo sync.git_clone— clone intodest(git clone <url> <dest> --colocate|--no-colocate). Runs without a working directory — pass an absolutedest. The colocate flag is always passed explicitly: whether colocation is jj’s default depends on the jj version and the user’sgit.colocateconfig, socolocatedecides deterministically.urlis 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::blockingmodule mirrorsworkspace_forget(andworkspace_name_for_path) forDropguards that cannot.await. It shells out viastd::processdirectly — 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.
| Field | Type | Notes |
|---|---|---|
change_id | String | Short change id (change_id.short()). |
commit_id | String | Short commit id. |
empty | bool | true when the change makes no file modifications. |
description | String | First line of the description (empty if undescribed). |
§Bookmark
| Field | Type | Notes |
|---|---|---|
name | String | Bookmark name. |
target | String | Short id of the commit it points at. |
§BookmarkRef
From bookmark list -a — local or remote-tracking.
| Field | Type | Notes |
|---|---|---|
name | String | Bookmark name. |
remote | Option<String> | Remote (e.g. origin/git); None for a local. |
target | String | Short commit id (empty for a conflicted bookmark). |
tracked | bool | Whether this remote-tracking bookmark is tracked (false for locals). |
§Workspace
| Field | Type | Notes |
|---|---|---|
name | String | Workspace name (default for the main one). |
commit | String | Short commit id of the working-copy commit. |
bookmarks | Vec<String> | Local bookmarks at that commit (empty when none). |
§ChangedPath
One jj diff --summary entry.
| Field | Type | Notes |
|---|---|---|
status | char | M modified, A added, D deleted, R renamed, C copied. |
path | String | The path the status applies to — the new path for a rename/copy (forward-slash normalised). |
old_path | Option<String> | For R/C, the original path; None otherwise. |
§DiffStat
Aggregate counts from the diff --stat footer (Copy, Default).
| Field | Type |
|---|---|
files_changed | usize |
insertions | usize |
deletions | usize |
§FileDiff
One file’s entry in a parsed git-format unified diff.
| Field | Type | Notes |
|---|---|---|
change | ChangeKind | How the file changed. |
path | String | Path — the new path for a rename — forward-slash normalised. |
old_path | Option<String> | For a rename, the original path; None otherwise. |
hunks | Vec<Hunk> | The @@ hunks; empty for a binary file or pure rename. |
raw | String | The verbatim diff --git … section, for callers that display raw text. |
§Hunk
| Field | Type | Notes |
|---|---|---|
old_start | usize | Start line in the old file. |
old_lines | usize | Old-file line count (defaults to 1 when ,<count> omitted). |
new_start | usize | Start line in the new file. |
new_lines | usize | New-file line count (defaults to 1 when omitted). |
section | String | Text after the closing @@ (function/section heading); empty when none. |
lines | Vec<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)
Added—new file mode ….Modified— contents changed.Deleted—deleted file mode ….Renamed—rename from …/rename to ….
§Operation
One jj op log row.
| Field | Type | Notes |
|---|---|---|
id | String | Short operation id — what op restore/op undo take. |
user | String | OS-level user@host that ran the operation (not the jj author). |
time | String | Start timestamp, ISO 8601 with offset. |
description | String | First line of the operation description (e.g. new empty commit). |
§AnnotationLine
One jj file annotate line.
| Field | Type | Notes |
|---|---|---|
change_id | String | Short change id that introduced the line. |
line | u32 | 1-based line number in the annotated file. |
content | String | The 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]).
| Field | Type |
|---|---|
version | JjVersion |
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.@-ormain..@(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.
| Field | Type | Notes |
|---|---|---|
name | String | Name for the new workspace. |
base | String | Revision the working copy starts at (-r <base>). |
path | PathBuf | Filesystem path for the new workspace. |
sparse_patterns | Option<SparseMode> | --sparse-patterns; None leaves jj’s default. |
let spec = WorkspaceAdd::new("feature", "@", "/tmp/feature")
.sparse(SparseMode::Empty); // start empty, then sparse_set laterWorkspaceAdd::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.
| Field | Type | Notes |
|---|---|---|
from | String | Source revision the filesets are squashed out of (--from). |
into | String | Destination revision they squash into (--into). |
filesets | Vec<JjFileset> | The exact filesets to move; empty squashes the whole from change. |
use_destination_message | bool | Keep 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 flagRevsetExpr::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
- Conflict resolution — the
vcs_jj::conflictmodule (parse / render / resolve materialized jj conflict markers). - Testing & mocking —
MockJjApiandScriptedRunner. - Security & hardening — why there is no
Jj::hardened(), and the injection-guard model. - Process model & errors — job containment, timeouts, the
Errorvariants. - crate docs