jj / How jj interdiff works

How jj interdiff works

jj interdiff compares what two revisions change, not what they contain. It moves the first revision's changes onto the second revision's parents, then diffs the result against the second revision, 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. Rebasing --from onto --to's parents
  8. The description pseudo-file
  9. Shared with evolog and op log
  10. 7. Errors and edge cases
  11. 8. Example output

In short: jj loads the workspace, which snapshots it, and resolves --from and --to to one commit each (whichever is missing defaults to @). It warns about explicit paths found in none of the four trees involved. Then it rebases --from's tree onto --to's parents in memory, diffs the two descriptions as a pseudo-file, and diffs the rebased tree against --to's tree. Nothing is written.

The command is cmd_interdiff. The help text: "Show differences between the diffs of two revisions". It is "like running jj diff -r on each change, 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.

1. Command-line surface

ArgumentTypeDefaultMeaning
-f, --from REVSETrevset, one commit@ if only --to is given"The first revision to compare" (source).
-t, --to REVSETrevset, one commit@ if only --from is given"The second revision 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, each side is exactly one revision and the output is always a rendered diff.

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 to one commit each, defaulting to @, and parses the paths into a matcher. It warns about explicit paths found in none of from's parent tree, from's tree, to's parent tree and to's tree. It picks the renderer from the format flags and requests the pager. show_inter_diff then diffs the two descriptions as the pseudo-file JJ-COMMIT-DESCRIPTION, rebases from's tree onto to's parents with rebase_to_dest_parent, and diffs that tree against to's tree, without copy records. No operation is recorded. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) resolve_single_rev: --from, --to the missing one is @ parse_file_patterns → matcher no paths: everything warn about explicit paths absent from all four trees from's parents, from, to's parents, to: a file deleted on one side still counts diff_renderer_for: format flags, else ui.diff-formatter; request the pager description diff as JJ-COMMIT-DESCRIPTION --git and --color-words only skipped when equal rebase_to_dest_parent from's changes onto to's parents, in memory may produce conflicts show_diff_trees rebased tree → to's 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 enginereadResolves --from and --to to one commit each (source).revsets
FilesetsreadThe positional paths become the matcher that limits the tree diff.revsets
CommitsreadBoth commits, their parents and their descriptions.commits
TreesreadFour trees are loaded: each commit's and each commit's parent tree.trees
ConflictsreadThe rebase 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, the two commits and their parents, the four trees, and file contents for the matched paths.
  • Output: the unmatched-paths warning goes to stderr before the pager is requested (source); the diff goes through the pager.

6. Algorithm and invariants

Rebasing --from onto --to's parents

The "from" side of the diff is not --from's tree. It is what --from's tree would be if --from sat on --to's parents: rebase_to_dest_parent takes --to's parent tree and applies the change from --from's parent tree to --from's tree, as one three-way merge (source). The "to" side is --to's own tree (source). Whatever the two parents disagree on is therefore on both sides and cancels out.

The two trees jj interdiff compares from sits on parent P, to sits on parent Q. The from side of the diff is Q's tree plus the change from P's tree to from's tree, merged in memory; the to side is to's tree. If from and to have the same parents, from's tree is used as it is. The merge labels its sides "(new parents)", "(original parents)" and "(original revision)", which appear in any conflict it produces. P original parents --from original revision Q new parents --to from side Q + (P → --from) MergedTree::merge, in memory same parents: --from's tree to side --to's tree as it is tree diff from side → to side P vs Q drops out

There is a fast path: if --from has exactly the same parents as --to, its tree is used unchanged and no merge runs (source). Then 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).

The description pseudo-file

Before the files, the two commit descriptions are diffed under the dummy path JJ-COMMIT-DESCRIPTION (source). show_diff_bytes 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.

Shared with evolog and op log

The same show_inter_diff renders the patches of jj evolog -p, where each entry is compared with its predecessors (source), and of jj op log -p / jj op show -p for rewritten commits, which use only the first predecessor (source). interdiff always passes exactly one "from" commit (source).

Invariants: exactly two trees are compared. Neither 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 rebase always goes 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 `<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" 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 none of the four trees. A file deleted by either commit still 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.diff_util.rs
(empty output)Both commits make the same change and have the same description, or the paths match nothing.diff_util.rs

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

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