In short: jj works out which file versions each commit changed, deduplicates them, runs each distinct version through the matching tools exactly once and in parallel, and substitutes the new file contents into every affected commit's tree. Because every descendant is fixed directly instead of being rebased onto a fixed parent, no merge ever happens, so no conflict can appear.
The work is split across two crates. The command-line half (cli/src/commands/fix.rs)
parses arguments and configuration, and knows how to spawn tools. The library half
(lib/src/fix.rs) decides which files to fix and rewrites history,
and knows nothing about subprocesses. It only sees the FileFixer trait.
1. End-to-end flow
-
cmd_fixloads the workspace and parses thefix.toolstable first, so a broken configuration fails before any history is examined. -
It resolves the target revset: the union of every
-s/--sourceargument, or therevsets.fixsetting when none is given. It then callscheck_rewritable_expr, which refuses to continue if any target is immutable (source). -
It evaluates the targets' descendants and passes all their commit IDs to the library as
root_commits, and builds a path matcher from the positionalFILESETS. With no paths given,parse_file_patternsreturnsFilesetExpression::all()(source). -
It starts a transaction and wraps
fix_one_filein aParallelFileFixer. Each rayon worker drives the asyncfix_one_fileto completion withpollster'sblock_on(source). -
fix_filesin jj-lib does all the history work: it picks files, calls the fixer once with the whole deduplicated set, then rewrites commits (source). -
The CLI prints
Fixed N commits of M checked.and commits the transaction as a single operation described asfixed N commits(source).
2. Command-line surface
The arguments are defined by FixArgs:
| Argument | Field | Meaning |
|---|---|---|
-s, --source <REVSETS> |
source: Vec<RevisionArg> |
Fix these revisions and their descendants. Without it, the target is the
revsets.fix setting, which defaults to reachable(@, mutable())
(default config).
|
[FILESETS]… |
paths: Vec<String> |
Only fix paths matching these filesets. Empty means every path. |
--include-unchanged-files |
include_unchanged_files: bool |
Also fix files the commit didn't change. jj diffs against the empty tree instead of the base tree, so every matching file in the commit is a candidate (§4). |
-a, --all-lines |
all_lines: bool |
Ignore changed-line ranges and format the whole file. Has no effect on tools without
line-range-args, which always see the whole file anyway (§6).
|
3. Tool configuration
Each entry of the fix.tools table deserializes into
RawToolConfig (kebab-case keys), then becomes a
ToolConfig with a compiled path matcher:
| Key | Type | Default | Meaning |
|---|---|---|---|
command | CommandNameAndArgs | required |
Program and arguments. $path is replaced with the repo-relative path (internal
form) and $root with the workspace root, if the root is valid UTF-8
(source).
|
patterns | Vec<String> | required | Filesets. They're parsed and unioned into one matcher, so a file matching several patterns still goes through the tool once. An empty list matches nothing. |
enabled | bool | true |
Disabled tools are dropped after parsing (default_tool_enabled). |
line-range-args | Vec<String> | [] |
Argument templates using $first and $last (1-based, inclusive). When
non-empty, the tool is told which lines changed (§6).
|
run-tool-if-zero-line-ranges | bool | false |
Run the tool even when no line changed. Only valid together with line-range-args.
Setting it alone is a config error.
|
Order and validation
get_tools_config reads the table keys and
sorts them by name before parsing (source). That
makes errors deterministic, and it's also the execution order when several tools match the same
file: the docs recommend numeric prefixes such as 1-sort, 2-truncate
(docs). The order is fixed in
ToolsConfig.tools. There are three config errors:
run-tool-if-zero-line-ranges can only be set when line-range-args is set(source)No `fix.tools` are configured(source)At least one entry of `fix.tools` must be enabled., checked after disabled tools are removed (source)
[fix.tools.clang-format]
command = ["/usr/bin/clang-format", "--assume-filename=$path"]
patterns = ["glob:'**/*.cc'", "glob:'**/*.h'"]
line-range-args = ["--lines=$first:$last"]
[fix.tools.biome]
command = ["$root/node_modules/@biomejs/biome/bin/biome", "format", "--stdin-file-path=$path"]
patterns = ["glob:'**/*.ts'", "glob:'**/*.tsx'"]
Adapted from docs/config.md, "Code formatting and other file content transformations".
4. Choosing which files to fix
This is the core of fix_files. It runs over the
set S = descendants(root_commits), visiting commits parents-first.
Base commits: the nearest ancestors outside the set
A commit isn't diffed against its parent. It's diffed against its base commits:
its closest ancestors that are not in S.
get_base_commit_map computes them in two passes.
It first collects every parent of a commit in S that is itself outside S. Then,
walking parents before children, each commit inherits its in-set parents' bases and adds any
parent that is itself a base. The result is an IndexSet<CommitId> per commit.
The base tree is merge_commit_trees
of the base commits: the commit's own tree when there is one base, otherwise a merge that jj
tries to resolve. Diffing against an out-of-set base makes each commit's diff
cumulative. B's diff against P includes everything A changed, so B re-fixes A's
files with B's own versions of them. That's how fixes reach descendants without rebasing. The
library's comment explains why: rebasing risks merge conflicts and "wouldn't reliably produce well
formatted code anyway" (source).
Diffing each commit
For each commit, parents first (source):
-
The diff's left side is the base tree, or the empty tree with
--include-unchanged-files, so every matching file counts as added. The right side is the commit's tree, filtered by the path matcher (source). Copy tracking isn't handled yet; the code carries aTODO. - Paths whose new value is absent (deletions) are skipped (source).
-
The "before" value is the first term of the diff's left side. With
--include-unchanged-filesit's the base tree's value at that path. If it's a regular file, itsFileIdis remembered inbase_files[(commit, path)]; it's later used for changed-line ranges (source). -
Every term on the right side that's a regular file becomes a
FileToFix, so all sides of a conflicted file are fixed. All sides share the base's first-sideFileId(source, rationale). Symlinks, directories and submodules are never passed to tools. - The path is recorded in
commit_paths[commit], the list the rewrite phase revisits.
FileToFix and deduplication
| Field | Type | Meaning |
|---|---|---|
file_id | FileId | The content to fix. |
base_file_id | Option<FileId> | The base commit's content at the same path, used to compute changed lines. None when the base had no regular file there. |
repo_path | RepoPathBuf | Lets tools behave differently by name or extension, and selects which tools match. |
All candidates go into one HashSet<FileToFix>, so the same content at the same
path with the same base is formatted once, however many commits contain it. In a
long stack where each commit touches different files, this stops the number of tool runs from
growing quadratically. It's also why tools must be deterministic and mustn't
depend on anything but their input: an output is reused wherever its input appears
(source, help text).
5. Running the tools
fix_one_file handles one FileToFix:
- It filters the configured tools to those whose matcher accepts the path. None →
Ok(None)(source). - It reads the content from the store. An empty file is never passed to any tool (source).
-
It loads the base content only when
--all-linesis off, abase_file_idexists, and at least one matching tool hasline-range-args(source). - It chains the tools with a fold: the first gets the committed content, and each later one gets the previous tool's output. Line ranges are recomputed for each tool against its own input (source).
-
If the final content differs from the original, it's written to the store as a new file
object, and the new
FileIdis returned. Otherwise the result isNone(source).
The subprocess
run_tool builds and runs one command:
-
$pathand$rootare substituted into the configured command. Line-range arguments are appended after the configured arguments. - The working directory is the workspace root. stdin, stdout and stderr are all piped (source).
-
The content is written to stdin from a scoped thread while the main thread collects output with
wait_with_output, so a tool that writes before it finishes reading can't deadlock on a full pipe (source). - Any stderr is passed through to the user, after a line naming the file (source).
-
Exit 0 → stdout becomes the new content. A spawn failure (
Failed to start) or a non-zero exit (Fix tool `…` exited with non-zero exit code for `…`) prints a warning and returnsErr(()). The fold then keeps the previous content and moves on to the next tool. To the rest of the pipeline, a failed tool looks the same as one that made no change (source).
6. Changed-line ranges
For tools with line-range-args,
compute_regions_to_format decides which lines
to send:
- Empty current content → no ranges.
-
A base exists →
compute_changed_ranges: a line diff (ContentDiff::by_line) where each different hunk with at least one line on the current side becomes an inclusive 1-basedLineRange. Pure deletions leave no lines on the current side, so they produce no range. -
No base (new file,
--all-lines, or no tool needs it) → one range covering the whole file, from 1 tocompute_file_line_count.
If the ranges are empty and run-tool-if-zero-line-ranges is false, the tool is
skipped for that file. Otherwise every range is expanded through every template in order, so
ranges 2–4 and 10–10 with ["--lines=$first:$last"] become
--lines=2:4 --lines=10:10 (source).
One interaction worth knowing: --include-unchanged-files still loads the base
version of unchanged files, so they get zero ranges. A line-range tool therefore skips them
unless run-tool-if-zero-line-ranges = true.
Test vectors from the source
| Base | Current | Ranges |
|---|---|---|
| none | a\nb\nc\n | 1–3 |
"" | a\nb\nc\n | 1–3 |
a\nB\nc\n | a\nb\nc\n | 2–2 |
a\nb\nc\nd\n | a\nb\nc\n | none (deletion) |
A\nb\nC\n | a\nb\nc\n | 1–1, 3–3 |
a\nb\nc\n | "" | none |
test_compute_regions_to_format_default
| Text | compute_file_line_count |
|---|---|
"" | 0 |
a | 1 |
a\n | 1 |
a\nb | 2 |
a\nb\n | 2 |
test_compute_file_line_count. A final line without a trailing newline still counts.
7. Parallelism
The library only sees the FileFixer trait: a
set of FileToFix in, a map of only the changed ones to their new FileId
out. ParallelFileFixer implements it with
rayon: into_par_iter().try_for_each_init gives each worker a clone of an mpsc sender,
each fixed file is sent as (file_to_fix, new_file_id), and the receiver drains into
a HashMap<&FileToFix, FileId> once all workers finish. The first
FixError (store I/O, not tool failure) aborts the whole run. A comment in the source
questions whether threads are needed at all, since workers mostly wait on pipes.
8. Rewriting history
With all fixed FileIds known, fix_files calls
MutableRepo::transform_descendants
on the same roots. Its callback gets a CommitRewriter for each commit, parents first,
with the new parents already filled in (source):
- It starts a
MergedTreeBuilderfrom the commit's old tree. -
For each path in
commit_paths, it maps every term of the path's merged value: a file whose(file_id, base_file_id, path)was fixed is swapped for the newFileId, keeping itsexecutablebit andcopy_id. Anything else is kept as-is. -
If anything changed, it writes the tree and does
rewriter.reparent().set_tree(new_tree).write(), and counts the commit as fixed. -
If nothing changed but an ancestor was rewritten
(
parents_changed()), it does a plainreparent().write().
reparent() moves the commit onto its new parents without changing its
contents. Nothing is ever merged, so jj fix can't create a new
conflict. Fixes can't delete or rename files, change the executable bit, or touch the
description (source). Existing conflicts stay conflicts,
with every side fixed. The help text notes this can increase or decrease the number of conflict
markers.
FixSummary
| Field | Type | Meaning |
|---|---|---|
rewrites | HashMap<CommitId, CommitId> | Old → new commit, for both fixed and merely reparented commits. |
num_checked_commits | i32 | Incremented once per commit the callback visits. |
num_fixed_commits | i32 | Commits that got a new tree. Reparent-only rewrites aren't counted. |
9. Output and review
Fixed N commits of M checked.on the status stream (source).-
One operation in the op log, described as
fixed N commits. Because it's a single transaction,jj undoreverts the whole fix, andjj op show -pshows exactly what changed (help text). - Tool stderr and the warnings for tools that failed to start or exited non-zero.
- Explicit paths that matched no file in any target commit are reported by
print_unmatched_explicit_paths.
10. Worked example
This is the example from the command's own help (source),
with clang-format configured for **/*.cc and **/*.h, and
black for **/*.py. X is immutable; A, B (the working copy) and C are
mutable, so reachable(@, mutable()) selects A, B and C, and their only base is X.
If A, B and C all contain an identical src/bar.h and share the same base version, it's
one FileToFix, and clang-format runs on it once. If B changed
bar.h again, B's version is a different FileId and gets its own run. B′
keeps its own content, with fixed files substituted in; it isn't rebased onto A′'s.