jj / How jj interdiff works

How jj interdiff works

jj interdiff compares what two revsets change, not what they contain. It moves the combined change of the first revset onto the parents of the second, then diffs the result against the second, so differences between their parents drop out.

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. Moving --from's combined change onto --to's parents
  8. Why gaps are rejected
  9. The description pseudo-file
  10. Relation to evolog and op log
  11. 7. Errors and edge cases
  12. 8. Example output

In short: jj loads the workspace, which snapshots it, and resolves --from and --to to a revset each (whichever is missing defaults to @), rejecting an empty set or one with gaps. Each side is treated as if its revisions were squashed into one: jj merges the trees of its heads and of its roots' parents. It moves --from's combined change onto --to's parents with one in-memory tree merge, warns about explicit paths found in neither tree compared, diffs the concatenated descriptions as a pseudo-file, and diffs the two trees. Nothing is written.

The command is cmd_interdiff. The help text: "Show differences between the diffs of two revsets". It is "like running jj diff -r on each side, then comparing those results", and the typical use is checking how a change moved since it was pushed, jj interdiff --from push-xyz@origin --to push-xyz (source). Unlike jj diff --from A --to B, which includes everything that differs between A's and B's parents, interdiff leaves that out. Either side may be a range: jj interdiff --from A::B --to C::D compares two stacks as if each were one commit (source).

1. Command-line surface

ArgumentTypeDefaultMeaning
-f, --from REVSETSrevsets, repeatable, unioned@ if only --to is given"The first revision(s) to compare" (source).
-t, --to REVSETSrevsets, repeatable, unioned@ if only --from is given"The second revision(s) to compare" (source).
At least one of --from and --to is required: they form a clap group that is both multiple and required (source).
FILESETS (positional, repeatable)filesetall paths"Restrict the diff to these paths" (source, source).
Short formats: -s/--summary, --stat, --types, --name-onlyflags, at most onenoneThe shared diff format flags, flattened in (source, 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; interdiff gives them their short names (source, source).

There is no -r and no -T: unlike jj diff, the two sides are always named by --from and --to, and the output is always a rendered diff. Repeating --from (or --to) unions the revsets, the same way -r does elsewhere (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 is given (source, 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 a conflicted file, including one the rebase produced, is written out when diffed (source, source).
ui.pager, ui.paginateless -FRXK, autoOutput goes through the pager (source).

3. End-to-end flow

jj interdiff: call flow After the shared lifecycle, cmd_interdiff resolves --from and --to with resolve_revset, defaulting to @: each becomes its commits, roots and heads, and an empty set or a set with gaps is an error. It parses the paths into a matcher. get_from_to_trees merges each side's head trees and its roots' parent trees, and moves the from side's combined change onto to's parents with one tree merge. It warns about explicit paths found in neither tree, picks the renderer and requests the pager, diffs the concatenated descriptions as the pseudo-file JJ-COMMIT-DESCRIPTION, and diffs the two trees without copy records. No operation is recorded. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) resolve_revset: --from, --to → commits, roots, heads missing side is @; empty or gapped sets fail parse_file_patterns → matcher no paths: everything get_from_to_trees: merge each side's heads and roots' parents, then one MergedTree::merge from's combined change lands on to's parents, in memory; it may conflict warn about explicit paths in neither tree; diff_renderer_for; request the pager show_text_diff: description diff concatenated descriptions, as JJ-COMMIT-DESCRIPTION --git and --color-words only skipped when equal show_diff rebased from tree → to's merged head tree matcher applied no copy records no transaction, no operation: the rebased tree is never stored as a commit

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Revset enginereadEach side is parsed as a union of revsets, checked for gaps, evaluated, and evaluated again for its roots() and heads() (source).revsets
FilesetsreadThe positional paths become the matcher that limits the tree diff.revsets
CommitsreadEvery commit in both sets for the descriptions; the roots' parents and the heads for the trees.commits
TreesreadFour merged trees: each side's heads and each side's roots' parents, merged with merge_commit_trees when there is more than one (source).trees
ConflictsreadMoving the change is a three-way tree merge; a clash becomes a conflicted file in the "from" side of the diff (source).conflicts
Copy recordsnot usedAn empty set is passed, marked // TODO, so renames show as a delete plus an add (source).trees
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 --to @ sees current edits. That snapshot may record a snapshot working copy operation; interdiff itself records nothing.
  • Reads: the view at the current (or --at-op) operation, every commit in both sets with its roots, heads and the roots' parents, their trees, and file contents for the matched paths. A code comment notes each revset is evaluated four times: for the commits, the gap check, the roots and the heads (source).
  • Output: the unmatched-paths warning goes to stderr before the pager is requested (source); the diff goes through the pager.

6. Algorithm and invariants

Moving --from's combined change onto --to's parents

Each side is first reduced to roots and heads (source). The "to" side of the diff is the merged tree of --to's heads. The "from" side is what --from's combined change would produce on --to's parents: get_from_to_trees collects the unique parents of each side's roots in encounter order (source), merges each set of parents and each set of heads into one tree (source), then runs one three-way merge: --to's parent tree plus the change from --from's parent tree to --from's head tree (source). Whatever the two sets of parents disagree on is on both sides and cancels out.

The two trees jj interdiff compares --from's roots sit on parents P, --to's roots on parents Q. The from side of the diff is Q's merged tree plus the change from P's merged tree to the merged tree of --from's heads, computed in memory as one merge; the to side is the merged tree of --to's heads. The merge labels its sides "(new parents)", "(original parents)" and "(original revision)", which appear in any conflict it produces. P original parents --from heads original revision Q new parents --to heads from side Q + (P → --from heads) MergedTree::merge, in memory one merge for the whole range to side --to's head tree as it is tree diff from side → to side P vs Q drops out

The doc comment contrasts this with rebase_to_dest_parent: it "rebases the combined diff rather than each commit's diff separately" (source). That matters for a range containing a merge. In upstream's test, two branches both add shared as x and their merge changes it to y; adding the commits' diffs one by one would count x twice and leave a spurious add/add conflict even when a range is compared with itself, while the combined change is simply "absent → y" (source). There is no special case when both sides have the same parents: the merge then resolves trivially to --from's head tree, and interdiff shows the same file changes as jj diff --from A --to B, except that renames are not detected. When the parents differ, the merge can conflict; the conflict is materialized on the "from" side, labelled with the three roles above, and the diff shows it being replaced by --to's content (see the example). A label names one commit in detail, or several commits by short label joined with ", " (source).

Why gaps are rejected

Before evaluating a side, resolve_revset calls check_diff_revset_has_no_gaps, shared with jj diff -r. It computes roots()..heads() minus the set and fails if anything is left: a revision between the roots and heads but outside the set would have its change silently folded in, because only the roots' parents and the heads' trees are used. Several heads and several roots are fine (source).

The description pseudo-file

Before the files, the descriptions are diffed under the dummy path JJ-COMMIT-DESCRIPTION (source). Each side's text is the descriptions of all its commits joined with a newline, in the order the revset yields them; a TODO notes this won't show reordered commits nicely (source). show_text_diff hands both to show_diff_bytes, which prints nothing if they are equal, and nothing for the short formats, where a dummy path "wouldn't be useful"; it prints a hunk for --git and --color-words, and nothing yet for an external --tool (source). So interdiff -s lists only real files.

Relation to evolog and op log

jj evolog -p and jj op log -p / jj op show -p render their patches with show_inter_diff, which rebases each predecessor with rebase_to_dest_parent and diffs against a single commit (evolog, op diff). interdiff no longer goes through it: since it compares whole revsets, it computes its own trees and calls show_text_diff and show_diff directly (source). The two paths produce the same output for one commit on each side.

Invariants: exactly two trees are compared. No commit is rewritten and nothing is stored; the rebased tree exists only for the diff. Swapping --from and --to generally does not just reverse the output, since the change always moves onto --to's parents.

7. Errors and edge cases

MessageWhenSource
A clap "required arguments were not provided" errorNeither --from nor --to is given.interdiff.rs
Revset `<revs>` didn't resolve to any revisions--from or --to is empty; repeated arguments are shown joined with | .interdiff.rs, test
Cannot diff revsets with gaps in., hint Revision <id> would need to be in the set.A side leaves out a revision between its roots and heads, such as --from 'base | old' with a commit between them.diff_util.rs, test
A clap "cannot be used with" errorTwo short or two long formats, or -w with -b.diff_util.rs
Warning: No matching entries for paths: <paths>An explicit path exists in neither tree compared. A file deleted by --to is still on the "from" side, so it counts as found. The diff is still printed.cli_util.rs, test
Conflict markers in the "from" sideThe parents differ and --from's change does not apply cleanly to --to's parents.test
A file shown as removed and another as addedA rename: no copy records are passed.interdiff.rs
(empty output)Both sides make the same change and have the same descriptions, or the paths match nothing.diff_util.rs, test

8. Example output

From upstream's test_interdiff_basic: left adds file2 containing foo on top of a commit adding file1; right, with @ on it, adds file2 containing foo and bar on top of a different commit adding file3. The parents' difference (file1 against file3) does not appear:

$ jj interdiff --from left
Modified JJ-COMMIT-DESCRIPTION:
   1     : add file2 left
        1: add file2 right
Modified regular file file2:
   1    1: foo
        2: bar

The same pair with -s, then --git (source):

$ jj interdiff --from left --to right -s
M file2
$ jj interdiff --from left --to right --git
diff --git a/JJ-COMMIT-DESCRIPTION b/JJ-COMMIT-DESCRIPTION
--- JJ-COMMIT-DESCRIPTION
+++ JJ-COMMIT-DESCRIPTION
@@ -1,1 +1,1 @@
-add file2 left
+add file2 right
diff --git a/file2 b/file2
index 257cc5642c..3bd1f0e297 100644
--- a/file2
+++ b/file2
@@ -1,1 +1,2 @@ foo
 foo
+bar

Comparing two stacks, from test_interdiff_revset_ranges: old is a range add foo, add bar on base, and new is the same two commits, edited, on base2. The bases also differ in a file context, which is in neither range and so does not appear. The descriptions match, so no description hunk is printed:

$ jj interdiff --from base..old --to base2..new
Modified regular file bar:
   1    1: x
        2: y
Modified regular file foo:
   1    1: 1
   2    2: 2
        3: 3

When the rebase conflicts, from test_interdiff_conflicting: left changes file from foo to bar, while right's parent has abc and right has def. Moving left's change onto abc conflicts, and the diff replaces that conflict with def. The descriptions are both empty, so no description hunk is printed:

$ jj interdiff --from left --to right --git
diff --git a/file b/file
index 0000000000..24c5735c3e 100644
--- a/file
+++ b/file
@@ -1,8 +1,1 @@ def
-<<<<<<< conflict 1 of 1
-%%%%%%% diff from: qpvuntsm d0c049cd (original parents)
-\\\\\\\        to: zsuskuln 0b2c304e (new parents)
--foo
-+abc
-+++++++ rlvkpnrz b23f92c3 (original revision)
-bar
->>>>>>> conflict 1 of 1 ends
+def