In short: jj resolves the revisions (default revsets.run) and orders them parents first.
Unless --ignore-changes is given, it refuses immutable ones. It then runs the command on up
to --jobs commits at once, each in a reusable slot under .jj/run/default/,
with JJ_CHANGE_ID, JJ_COMMIT_ID and JJ_WORKSPACE_ROOT set.
After each command it snapshots the slot. A failure stops the run and rewrites nothing, unless
--ignore-errors is given. At the end one transaction rewrites every commit whose tree
changed and rebases the rest of their descendants. The operation is
run: rewrite <n> commits.
The command is cmd_run. Its help text names the
environment variables and gives jj run -j 4 -- pre-commit run .github/pre-commit.yaml
as an example (source). Unlike
jj fix, which pipes single files through
formatters in memory, jj run materializes whole trees on disk, so it can run builds and
test suites.
1. Command-line surface
| Flag | Type | Default | Meaning |
|---|---|---|---|
COMMAND (positional) | string | required | The program to run in each commit (source). |
ARGS (positional) | strings | none | Its arguments. Put -- first to pass arguments starting with - (source). |
-r, --revision, --revisions REVSETS | revsets | revsets.run | The commits to run on (source). |
-x | bool | hidden | Does nothing. It exists to match git rebase -x (source). |
-j, --jobs N | integer | run.jobs, else 1 | How many commands run at once, which is also the number of slots (source). |
--root | bool | false | Run from each slot's root instead of the subdirectory jj run was started in (source). |
--clean | bool | false | Wipe each slot before checking out, so no build artifacts are reused (source). |
--restore-descendants | bool | false | Keep descendants' content, not their diffs (source). |
--passthrough | bool | false | Connect the command's stdout and stderr to the terminal instead of capturing them. Only one job is allowed (source). |
--ignore-changes | bool | false | Run without rewriting anything, which also allows immutable commits. Conflicts with --restore-descendants (source). |
--ignore-errors | bool | false | Keep going after a failing command. Failed commits aren't changed, successful ones still are, and jj run itself still succeeds (source). |
--sparse-patterns | copy | full | empty | copy | Which sparse patterns the slots use: the current workspace's, everything, or nothing (source, values). |
2. Configuration
| Key | Default | Effect |
|---|---|---|
revsets.run | reachable(@, mutable()) | The commits used when no -r is given (default, use). |
run.jobs | unset, meaning 1 | Number of parallel jobs when --jobs isn't given. Zero or a negative number is an error (source). |
snapshot.auto-track | all() | Which new files a command creates get added to the commit (field, matcher). |
revset-aliases."immutable_heads()" | built in | Decides which commits may be rewritten, unless --ignore-changes is given. |
snapshot.max-new-file-size does not apply: slots use a fixed limit of 64,000 bytes, so a
larger new file stays untracked. The code comment beside the value says "64 MB"
(source). Gitignore rules come from the workspace's base
ignores plus the .gitignore files in each checked-out tree.
3. End-to-end flow
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Selected commits | read, maybe rewritten | Each one whose snapshot differs from its tree gets a new tree. It keeps its change ID and description, and you become the committer. | commits |
Slot TreeStates | created, loaded, saved | One per slot in .jj/run/default/N/state/, kept between runs so a checkout only touches changed files (source). | working-copy |
| Slot lock files | locked | .jj/run/default/N.lock, held for one job. Concurrent jj run processes share the pool this way (source). | — |
| Trees and files | read, written | Checked out into slots; snapshots write new file and tree objects. Conflicted trees are kept as they are. | trees |
| Descendants | rewritten | Commits outside the run set whose parents changed are rebased or, with --restore-descendants, reparented. | transactions |
View: heads, bookmarks, wc_commit_ids | write | Follow the rewritten commits. | view |
| Operation | write | One operation, only if something was rewritten. | operations |
| Your working copy | read, then maybe write | Snapshotted first but never used to run the command. If @ was rewritten, its new tree is checked out. | working-copy |
5. Transaction and operation
- Transaction: one, started before any command runs and committed only after all of them have finished, so the result is all or nothing (start).
- Operation description:
run: rewrite <n> commits, wherencounts the commits whose own tree changed (source). - No changes: with
--ignore-changes, or when no tree changed, jj finishes the transaction asrun: nothing changed. Since the repo is untouched,tx.finishjust printsNothing changed.and records no operation (source, finish). - Failure: without
--ignore-errors, the first failing command returns an error before the rewrite step. The transaction is dropped, so even commands that already succeeded change nothing (source). - Slots live outside the operation log. Their files and tree state persist after the run, whatever the outcome.
jj undorestores the previous view, bringing back the original commits.
6. Algorithm and invariants
The workspace pool
WorkspacePool has --jobs slots,
numbered from 1, under .jj/run/default/, next to .jj/repo rather than inside
it. Every slot is checked out and snapshotted with the same settings: your
ui.conflict-marker-style, no end-of-line conversion, auto executable-bit
handling and no fsmonitor (source, marker style).
acquire does this:
- It tries each slot's lock without blocking, sleeping from 10 ms, doubling up to 250 ms, until one is free (source).
- If the slot has a saved
tree_stateand--cleanwasn't given, it loads it and deletes the file. The missing file marks the slot as dirty until the job saves it again, so a crash mid-job makes the next user wipe the slot (source). - Otherwise it deletes the slot's working copy and state and starts from an empty in-memory tree state (source).
- It sets the sparse patterns, or the root path for
full, and checks the commit's tree out. Because it diffs against the saved state, only changed files are touched and ignored build artifacts survive (source). - For a reused slot, it snapshots once more to find files that a previous commit ignored but this one doesn't, deletes them, and checks out again (source).
One job
rewrite_commit runs the command in the slot, or in
the same subdirectory you ran jj run from unless --root is given. If that
subdirectory doesn't exist in the commit, the commit is skipped
(source, subdir).
The command gets JJ_WORKSPACE_ROOT (the slot directory), JJ_CHANGE_ID,
JJ_COMMIT_ID, and a null stdin. It is killed if its task is dropped. Output is buffered
and printed whole when the job finishes, so parallel jobs never interleave
(source).
Then jj snapshots the slot using the snapshot.auto-track matcher. If the command failed,
it deletes the untracked, non-ignored files it left behind and discards the result. Either way it
saves the slot's tree state. A successful snapshot becomes the job's new tree, conflicts included
(source).
Scheduling
run_inner spawns jobs in parents-first order on a
multi-threaded tokio runtime, with at most jobs in flight
(runtime). Results come back in completion order through a
channel to a consumer that runs at the same time and prints each one as it arrives
(source). On a failure without --ignore-errors,
the producer shuts down the remaining tasks, which kills their processes, and the consumer returns the
error. Every job checks out its commit's original tree, so a commit never sees a command's
changes to its ancestors.
Rewriting
A job's tree counts as changed only if its trees and conflict labels differ from the original
(source). jj then walks the selected commits and their
descendants with transform_descendants, parents first
(source):
- Changed, default: rebase the commit onto its rewritten parents, then 3-way merge
rebased + (command result − original). That way the command's diff and any ancestors' changes both carry through (source). - Changed,
--restore-descendants: rebase it and set its tree to the command result exactly, ignoring what changed in its ancestors (source). - Unchanged, or outside the run set: if its parents changed, rebase it, or reparent it with
--restore-descendants. These count as rebased descendants (source).
Invariants: nothing is rewritten unless every command succeeded or
--ignore-errors was given. Commands never run in your own working copy. Change IDs and
descriptions are kept. Only trees change.
7. Errors and edge cases
| Message | When | Source |
|---|---|---|
the command '<command and args>' failed with <status>, with hint Failed revision: … | A command exited unsuccessfully, without --ignore-errors. Nothing is rewritten. | run.rs |
Skipped commit <id>: directory does not exist: <subdir> | The commit doesn't contain the directory jj run was started from. | run.rs |
cannot use --passthrough with more than one job | --passthrough with --jobs or run.jobs above 1. | run.rs |
invalid value for `run.jobs`: <n> (must be a positive integer) | --jobs 0, or run.jobs zero or negative. | run.rs |
failed to checkout the commit …, failed to snapshot the working copy for commit …, failed to create path …, failed to delete path … | Slot setup or snapshot failed. | run.rs |
Commit … is immutable (or The root commit … is immutable) | A selected commit is immutable and --ignore-changes wasn't given. | cli_util.rs |
Nothing changed. | No command changed a tree, or --ignore-changes. | cli_util.rs |
Rewrote <n> commits. then Rebased <m> descendant commits[ (while preserving their content)]. | Status after rewriting. | run.rs |
clap: --ignore-changes cannot be used with --restore-descendants | Both given. | run.rs |
8. Worked example
From upstream's test_run_parallel_changes_A, B and C each add one file, and @ is empty on
top. Running jj run --jobs=3 -r=..@ -- sh -c 'touch newfile-$JJ_CHANGE_ID.txt' runs the
command on the four commits, at most three at a time. Each job sees only its commit's original tree,
so each adds exactly one new file named after its own change ID:
Upstream then lists @- (that is, C):
a.txt
b.txt
c.txt
newfile-kkmpptxzrspxrzommnulwmwkkqwworpl.txt
newfile-qpvuntsmwlqtpsluzzsnyyzlmlwvmlnu.txt
newfile-rlvkpnrzqnoowoytxnquwvuryrwnrmlp.txt
B and C are rebased onto their rewritten parents, and the 3-way merge adds
their own command result on top, so every descendant ends up with all its ancestors' new files. With
--restore-descendants, C's tree would instead be exactly what its command
produced, without A's and B's new files.
Two failure cases from the same file:
test_run_failure_B only, and
the log afterwards is identical to before, even though the other commands succeeded.
test_run_failure_Error: the command 'fake-formatter --stdout hello stdout …' failed with exit status: 1
and Hint: Failed revision: qpvuntsm 26d8ff9b A.