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
| Argument | Type | Default | Meaning |
|---|---|---|---|
-f, --from REVSETS | revsets, repeatable, unioned | @ if only --to is given | "The first revision(s) to compare" (source). |
-t, --to REVSETS | revsets, 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) | fileset | all paths | "Restrict the diff to these paths" (source, source). |
Short formats: -s/--summary, --stat, --types, --name-only | flags, at most one | none | The shared diff format flags, flattened in (source, source). |
Long formats: --git, --color-words | flags, at most one | none | A Git-format or word-level diff. One short and one long format can be combined (source). |
--tool NAME | string | none | An external diff command, or :name for a built-in format (source). |
--context N | integer | diff.git.context / diff.color-words.context | Lines of context (source). |
-w/--ignore-all-space, -b/--ignore-space-change | flags | off | Whitespace 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
| Key | Default | Effect |
|---|---|---|
ui.diff-formatter | :color-words | The format when no format flag is given (source, source). |
diff.color-words.* | conflict = "materialize", max-inline-alternation = 3, context = 3 | Options for the default format (source). |
diff.git.* | context = 3, show-path-prefix = true | Options for --git (source). |
ui.conflict-marker-style | diff | How a conflicted file, including one the rebase produced, is written out when diffed (source, source). |
ui.pager, ui.paginate | less -FRXK, auto | Output goes through the pager (source). |
3. End-to-end flow
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Revset engine | read | Each side is parsed as a union of revsets, checked for gaps, evaluated, and evaluated again for its roots() and heads() (source). | revsets |
| Filesets | read | The positional paths become the matcher that limits the tree diff. | revsets |
| Commits | read | Every commit in both sets for the descriptions; the roots' parents and the heads for the trees. | commits |
| Trees | read | Four 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 |
| Conflicts | read | Moving the change is a three-way tree merge; a clash becomes a conflicted file in the "from" side of the diff (source). | conflicts |
| Copy records | not used | An empty set is passed, marked // TODO, so renames show as a delete plus an add (source). | trees |
| Operation log | untouched | No 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 asnapshot working copyoperation;interdiffitself 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 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
| Message | When | Source |
|---|---|---|
| A clap "required arguments were not provided" error | Neither --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" error | Two 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" side | The parents differ and --from's change does not apply cleanly to --to's parents. | test |
| A file shown as removed and another as added | A 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