jj / How jj fix works

How jj fix works

jj fix runs formatters (or any stdin-to-stdout tool) over the files changed in a set of commits, then rewrites those commits and all their descendants with the results, without touching the working copy and without ever creating a conflict.

On this page
  1. 1. End-to-end flow
  2. 2. Command-line surface
  3. 3. Tool configuration
  4. Order and validation
  5. 4. Choosing which files to fix
  6. Base commits: the nearest ancestors outside the set
  7. Diffing each commit
  8. FileToFix and deduplication
  9. 5. Running the tools
  10. The subprocess
  11. 6. Changed-line ranges
  12. Test vectors from the source
  13. 7. Parallelism
  14. 8. Rewriting history
  15. FixSummary
  16. 9. Output and review
  17. 10. Worked example

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

jj fix: end-to-end call flow The CLI's cmd_fix loads the fix.tools configuration, resolves the target revset and checks the commits are rewritable, then starts a transaction and calls jj-lib's fix_files. fix_files collects unique FileToFix values, hands them to a ParallelFileFixer that calls fix_one_file for each one on a rayon thread pool, and fix_one_file pipes the content through each matching tool with run_tool. fix_files then calls transform_descendants to write new trees and commits, and cmd_fix finishes the transaction as one operation. jj-cli · cli/src/commands/fix.rs jj-lib · lib/src/fix.rs cmd_fix(args) get_tools_config fix.tools.* → ToolsConfig resolve -s / revsets.fix check_rewritable_expr start_transaction ParallelFileFixer::new(fix_one_file) fix_files: collect base commits · tree diffs · HashSet<FileToFix> ParallelFileFixer::fix_files rayon into_par_iter · mpsc channel fix_one_file × N chain matching tools run_tool stdin → subprocess → stdout fix_files: rewrite transform_descendants · MergedTreeBuilder tx.finish "fixed N commits" operation per unique file new FileIds
  1. cmd_fix loads the workspace and parses the fix.tools table first, so a broken configuration fails before any history is examined.
  2. It resolves the target revset: the union of every -s/--source argument, or the revsets.fix setting when none is given. It then calls check_rewritable_expr, which refuses to continue if any target is immutable (source).
  3. It evaluates the targets' descendants and passes all their commit IDs to the library as root_commits, and builds a path matcher from the positional FILESETS. With no paths given, parse_file_patterns returns FilesetExpression::all() (source).
  4. It starts a transaction and wraps fix_one_file in a ParallelFileFixer. Each rayon worker drives the async fix_one_file to completion with pollster's block_on (source).
  5. fix_files in jj-lib does all the history work: it picks files, calls the fixer once with the whole deduplicated set, then rewrites commits (source).
  6. The CLI prints Fixed N commits of M checked. and commits the transaction as a single operation described as fixed N commits (source).

2. Command-line surface

The arguments are defined by FixArgs:

ArgumentFieldMeaning
-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:

KeyTypeDefaultMeaning
commandCommandNameAndArgsrequired 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).
patternsVec<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.
enabledbooltrue Disabled tools are dropped after parsing (default_tool_enabled).
line-range-argsVec<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-rangesboolfalse 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.

Worked example: base commits Commits P and Q are outside the fixed set. A's parent is P, B's parent is A, C's parent is Q, and M is a merge of B and C. The bases are: A gets P, B gets P (inherited from A), C gets Q, and M gets both P and Q, so M is diffed against the merge of P's and Q's trees. fixed set S = descendants(roots) A B C M P Q bases(A) = {P} bases(B) = {P} bases(C) = {Q} bases(M) = {P, Q} arrows point parent → child M is diffed against merge_commit_trees([P, Q])

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 a TODO.
  • 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-files it's the base tree's value at that path. If it's a regular file, its FileId is remembered in base_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-side FileId (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

FieldTypeMeaning
file_idFileIdThe content to fix.
base_file_idOption<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_pathRepoPathBufLets tools behave differently by name or extension, and selects which tools match.

FileToFix, lib/src/fix.rs

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:

  1. It filters the configured tools to those whose matcher accepts the path. None → Ok(None) (source).
  2. It reads the content from the store. An empty file is never passed to any tool (source).
  3. It loads the base content only when --all-lines is off, a base_file_id exists, and at least one matching tool has line-range-args (source).
  4. 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).
  5. If the final content differs from the original, it's written to the store as a new file object, and the new FileId is returned. Otherwise the result is None (source).

The subprocess

run_tool builds and runs one command:

  • $path and $root are 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 returns Err(()). 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-based LineRange. 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 to compute_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

BaseCurrentRanges
nonea\nb\nc\n1–3
""a\nb\nc\n1–3
a\nB\nc\na\nb\nc\n2–2
a\nb\nc\nd\na\nb\nc\nnone (deletion)
A\nb\nC\na\nb\nc\n1–1, 3–3
a\nb\nc\n""none

test_compute_regions_to_format_default

Textcompute_file_line_count
""0
a1
a\n1
a\nb2
a\nb\n2

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):

  1. It starts a MergedTreeBuilder from the commit's old tree.
  2. 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 new FileId, keeping its executable bit and copy_id. Anything else is kept as-is.
  3. If anything changed, it writes the tree and does rewriter.reparent().set_tree(new_tree).write(), and counts the commit as fixed.
  4. If nothing changed but an ancestor was rewritten (parents_changed()), it does a plain reparent().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

FieldTypeMeaning
rewritesHashMap<CommitId, CommitId>Old → new commit, for both fixed and merely reparented commits.
num_checked_commitsi32Incremented once per commit the callback visits.
num_fixed_commitsi32Commits that got a new tree. Reparent-only rewrites aren't counted.

FixSummary · counting

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 undo reverts the whole fix, and jj op show -p shows 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.

Worked example: A, B and C fixed into A', B' and C' A modifies src/bar.cc and src/bar.h; B modifies README.md; C modifies foo.py. Every commit is diffed against X, so A sends bar.cc and bar.h to clang-format; B sends the same two files (cumulative diff) and leaves README.md untouched because no tool matches it; C sends bar.cc and bar.h to clang-format and foo.py to black. Each produces a rewritten commit: A', B' (still the working copy) and C'. C · modifies foo.py vs X: bar.cc, bar.h, foo.py, README.md B @ · modifies README.md vs X: bar.cc, bar.h, README.md A · modifies bar.cc, bar.h vs X: bar.cc, bar.h X · immutable (the base) clang-format + black clang-format clang-format C′ bar.cc, bar.h, foo.py fixed B′ @ bar.cc, bar.h fixed · README.md as-is A′ bar.cc, bar.h fixed X (unchanged)

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.