jj / How jj diff works

How jj diff works

jj diff compares two trees and prints the difference. By default the trees are @'s parents, merged, and @ itself. -r widens that to a whole range of revisions; --from and --to pick any two revisions directly.

On this page
  1. 1. Command-line surface
  2. 2. Configuration
  3. 3. End-to-end flow
  4. 4. Core architecture touchpoints
  5. 5. What it reads, and snapshotting
  6. 6. Algorithm and invariants
  7. Diffing a set of revisions
  8. --from and --to
  9. -T and the formats
  10. 7. Errors and edge cases
  11. 8. Example output

In short: jj loads the workspace, which snapshots it, and turns the paths into a matcher. With --from/--to it resolves each to exactly one commit (default @) and compares their trees. Otherwise it evaluates -r (default @), refuses a set with gaps, and compares the merged trees of the roots' parents with the merged trees of the heads. Copy records join the two sides, then a template or the diff formats print the result. Nothing is written.

The command is cmd_diff. The help text: "Compare file contents between two revisions", defaulting to -r @ (source). Unlike jj show, it prints no commit header and its result is always a single diff, however many revisions are selected.

1. Command-line surface

ArgumentTypeDefaultMeaning
-r, --revisions REVSETS (alias --revision, repeatable)revset@"Show changes in these revisions". Several revisions give their total diff: in a chain A..D, -r B::D equals --from A --to D. Gaps are not supported (source).
-f, --from REVSETrevset, one commit@ if only --to is given"Show changes from this revision". Conflicts with -r (source).
-t, --to REVSETrevset, one commit@ if only --from is given"Show changes to this revision". Conflicts with -r (source).
FILESETS (positional, repeatable)filesetall paths"Restrict the diff to these paths" (source, source).
-T, --template TEMPLATEtemplatenoneRender each changed path with a template; the keywords are the methods of TreeDiffEntry. Conflicts with every format flag and --tool (source).
Short formats: -s/--summary, --stat, --types, --name-onlyflags, at most onenonePer-path summary, histogram, type letters, or path only (source).
Long formats: --git, --color-wordsflags, at most onenoneA Git-format or word-level diff. One short and one long format can be combined (source).
--tool NAMEstringnoneAn external diff command, or :name for a built-in format (source).
--context Nintegerdiff.git.context / diff.color-words.contextLines of context (source).
-w/--ignore-all-space, -b/--ignore-space-changeflagsoffWhitespace handling when comparing lines; diff gives them their short names (source, source).

Global flags such as --at-op and --ignore-working-copy are on the CLI page.

2. Configuration

KeyDefaultEffect
ui.diff-formatter:color-wordsThe format when no format flag and no -T is given (source). A :name picks a built-in; anything else is an external tool (source).
diff.color-words.*conflict = "materialize", max-inline-alternation = 3, context = 3Options for the default format (source).
diff.git.*context = 3, show-path-prefix = trueOptions for --git (source).
ui.conflict-marker-stylediffHow conflicted files are written out when diffed (source, source).
ui.pager, ui.paginateless -FRXK, autoOutput goes through the pager (source).

3. End-to-end flow

jj diff: call flow After the shared lifecycle, cmd_diff parses the paths into a matcher. If --from or --to is given, each is resolved to a single commit, defaulting to @, and their trees are compared, with copy records from one to the other. Otherwise the -r revsets, defaulting to @, are checked for gaps; the roots' parents' trees are merged into the from tree, the heads' trees into the to tree, and copy records are gathered for every parent and head. Then a -T template or the format renderer is chosen, the pager is requested, the diff is printed, and explicit paths found in neither tree get a warning. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) parse_file_patterns → matcher (no paths: everything) --from / --to given resolve_single_rev each, else @ from tree, to tree as they are copy records from → to otherwise: -r, else @ gaps = roots..heads minus the set any gap: error merge_commit_trees from: roots' parents to: heads copies: each parent × head -T: template, renderer with no formats else diff_renderer_for: flags, else ui.diff-formatter request the pager; with -T, show_templated over diff_with_copies show_diff: one pass per format over the tree diff (none after -T) warn about explicit paths absent from both trees; no transaction, no operation

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Revset enginereadParses -r, --from and --to; evaluates the set, its roots, heads and gaps (source, source).revsets
FilesetsreadThe positional paths become the matcher that limits the diff and the copy records.revsets
Commit indexreadAnswers the ancestry questions behind roots, heads and the gap check.index
CommitsreadThe resolved commits and the roots' parents.commits
TreesreadThe from and to trees, diffed path by path.trees
Copy recordsreadFrom the backend between the two sides; shown as (old => new) (source).trees
ConflictsreadSeveral parents or heads are merged into one tree; conflicted files are materialized for the diff.conflicts
Operation loguntouchedNo transaction is started.operations

5. What it reads, and snapshotting

  • Snapshot: yes. It loads the workspace with workspace_helper (source), which snapshots the working copy first (source), so jj diff shows current edits. That snapshot may record a snapshot working copy operation; diff itself records nothing.
  • Reads: the view at the current (or --at-op) operation, the index, the selected commits and the roots' parents, their trees, file contents for the matched paths, and copy records.
  • Output: through the pager, requested once both trees and the renderer are ready (source). The unmatched-paths warning goes to stderr after the diff (source).

6. Algorithm and invariants

Diffing a set of revisions

With -r, jj does not diff each revision in turn. It takes the set's roots and heads, and first checks that nothing between them is missing: roots..heads, the commits reachable from a head but not from a root (source), minus the set itself, must be empty (source). Then the parents of the roots, kept in order and without duplicates, are merged into the "from" tree, and the heads are merged into the "to" tree (source). One commit gives its own tree; several are merged with their merge bases and resolved where possible (merge_commit_trees). So -r X for a single commit is the same diff jj show X prints, and a set is one combined diff.

The two trees jj diff -r B::E compares For jj diff -r B::E over the history A, B, C and D on B, E merging C and D: the root is B and the head is E, so the from tree is A's and the to tree is E's, the same as --from A --to E. The set A or C leaves out B, a gap, and is refused. A B (root) C D E (head) from tree merge of the roots' parents here: A's tree to tree merge of the heads here: E's tree one diff from tree → to tree = --from A --to E the set B::E

Upstream's test_diff_revisions exercises the cases. A single revision, a merge, a chain (A::C), a chain with an internal merge (B::E), several roots (C|D|E: both roots have the parent B) and several heads (B|C|D: the to tree merges C and D) all work. A|C is refused because B is missing, and C|E because D, E's other parent, is reachable from the head but not from the root.

--from and --to

Here no merging happens: each side must resolve to exactly one commit, and its tree is used as it is (source). The two commits need not be related, so jj diff --from main compares main with @ directly, whatever lies between them. Copy records are fetched for that one pair (source).

-T and the formats

Without -T, the formats are chosen as for jj show: at most one short and one long format from the flags, else ui.diff-formatter (source; see jj show). With -T, the renderer gets an empty format list, so it prints nothing, and the template is applied to each entry of the tree diff instead (source, source). The template sees the copy records too, so a rename is one entry with status renamed.

Invariants: exactly two trees are compared, whatever the arguments. The matcher limits both the diff and the copy records. Nothing in the repository changes.

7. Errors and edge cases

MessageWhenSource
Cannot diff revsets with gaps in., hint Revision <id> would need to be in the set.The -r set leaves out a commit between its roots and heads, e.g. -r 'A|C'.diff.rs, test
Revset `<rev>` didn't resolve to any revisions--from or --to is empty.revset_util.rs
Revset `<rev>` resolved to more than one revision, listing them--from or --to names several commits.revset_util.rs
A clap "cannot be used with" error-r with --from/--to; -T with a format flag or --tool; two short or two long formats; -w with -b.diff.rs, test
--tool=<name> cannot be used with --<format>--tool asks for a format whose slot a flag already filled.diff_util.rs
Invalid builtin diff format: <name>--tool=:name or ui.diff-formatter names no built-in format.diff_util.rs
The tool `<name>` cannot be used for diff formatting--tool or ui.diff-formatter names a configured tool whose diff-args is empty.diff_util.rs
Failed to generate diffAn external diff tool failed.diff_util.rs
Warning: No matching entries for paths: <paths>An explicit path exists in neither tree. A path in only one of them is fine. The diff is still printed.cli_util.rs, test
(empty output)The two trees match, or the paths match nothing.diff_util.rs

8. Example output

From upstream's test_diff_basic: @ deletes file1, edits file2, and adds file3 and file4 with the old contents of file1 and file2, which the Git backend reports as a rename and a copy. The default :color-words format:

$ jj diff
Modified regular file file2:
   1    1: 1
   2     : 2
        2: 5
   3    3: 3
   4     : 4
Modified regular file file3 (file1 => file3):
Modified regular file file4 (file2 => file4):

The same diff as a summary (source):

$ jj diff -s
M file2
R {file1 => file3}
C {file2 => file4}

And through the template source.path() ++ ' => ' ++ target.path() ++ ' (' ++ status ++ ")\n" passed with -T (source):

file2 => file2 (modified)
file1 => file3 (renamed)
file2 => file4 (copied)

A set with a gap, from test_diff_revisions:

$ jj diff --name-only -r 'A|C'
Error: Cannot diff revsets with gaps in.
Hint: Revision 2378873cd201 would need to be in the set.