jj / How jj diffedit works

How jj diffedit works

jj diffedit opens a diff editor on one commit's changes and makes whatever you leave on the right-hand side that commit's new tree. It is the general way to touch up a commit's content by hand, without checking it 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. Transaction and operation
  6. 6. Algorithm and invariants
  7. Choosing the two sides
  8. Loading the editor
  9. An external editor
  10. The built-in editor
  11. Descendants
  12. 7. Errors and edge cases
  13. 8. Worked example

In short: jj picks a target commit and a base. That is -r (default @) against the merge of its parents, or --to against --from, each defaulting to @. It refuses an immutable target. The diff editor gets the base on the left and the target's tree on the right, and returns the right side as edited. If that tree equals the target's, jj prints Nothing changed. and records nothing. Otherwise it rewrites the target, keeping its change ID and description, then rebases its descendants, which can conflict, or reparents them with --restore-descendants. The operation is edit commit <id>.

The command is cmd_diffedit. Its help text points to jj restore for moving whole files between revisions and to jj squash -i for moving changes between them (source). Unlike those commands and jj split, it calls the diff editor directly rather than through the shared DiffSelector.

1. Command-line surface

FlagTypeDefaultMeaning
FILESETS (positional)filesetsall filesEdit only these paths. Unmatched paths stay unchanged (source).
-r, --revision REVSETrevset@ if neither --from nor --toThe commit to touch up, compared with its parents. Conflicts with --from and --to (source).
-f, --from REVSETrevset@ if --to is givenThe commit shown on the left (source).
-t, --to REVSETrevset@ if --from is givenThe commit to edit, shown on the right (source).
--tool NAMEstringui.diff-editorThe diff editor to use (source).
--restore-descendantsboolfalseKeep descendants' content, not their diffs, when moving them onto the edited commit (source).

DiffeditArgs

2. Configuration

KeyDefaultEffect
ui.diff-editorunset, meaning :builtinThe editor used without --tool. When it's unset jj prints a hint and uses the built-in one. A plain name is looked up as a tool; anything with arguments is run as a command whose arguments are the edit arguments (editor_args_from_settings, from_settings).
merge-tools.<name>.edit-args["$left", "$right"]Arguments for an external editor. A tool whose edit-args is empty can't be a diff editor. If the arguments mention $output, jj prepares three directories instead of two (fields, defaults, check).
merge-tools.<name>.edit-invocation-modedirdir runs the tool once on the two directories; file-by-file runs it once per changed file (source).
merge-tools.<name>.programthe tool's nameThe executable to run (source).
merge-tools.<name>.conflict-marker-styleunsetOverrides how conflicted files are written into the temporary directories (field, use).
ui.diff-instructionstrueWhether an external editor gets a JJ-INSTRUCTIONS file. The built-in editor never does (default, use).
revset-aliases."immutable_heads()"built inDecides whether the target may be rewritten.

3. End-to-end flow

jj diffedit: call flow cmd_diffedit loads the workspace through the shared lifecycle, which snapshots the working copy, and parses the path arguments into a matcher. With --from or --to it resolves the target from --to and the base from --from, each defaulting to @; otherwise the target is -r (default @) and the base is its parents. It checks the target is rewritable, loads the diff editor, and starts a transaction. The base tree is the merge of the base commits' trees. The diff editor edits the diff from the base tree to the target's tree and returns the edited right side. It returns early with Nothing changed if that equals the target's tree. Otherwise it rewrites the target with the new tree, rebases or reparents descendants, and tx.finish records "edit commit". Finally it warns about unmatched paths. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) --from / --to given target = --to or @ base = [--from or @] otherwise: -r (default @) target = that commit base = its parents check_rewritable(target); diff_editor(--tool); start_transaction; base tree = merge_commit_trees(base) DiffEditor::edit(base tree → target tree, matcher) :builtin: the terminal UI, keep or drop each change external: check out temp dirs, run the tool, snapshot the right (or output) directory edited tree == target tree? "Nothing changed." — the transaction is dropped, no operation rewrite_commit(target).set_tree(edited tree).write() then rebase_descendants() — or reparent_descendants() with --restore-descendants — "Rebased N descendant commits …" tx.finish(ui, "edit commit <id>"): write the operation, update the working copy (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Target commitrewrittenA new commit with the edited tree. It keeps the parents, change ID, description and author, and you become the committer.commits
Base commitsreadOnly their trees: the target's parents merged, or the one --from commit (merge_commit_trees).trees
Trees and filesread, writtenChanged files are checked out for the editor; the result is written back as new file and tree objects.trees
Temporary TreeStatescreated, snapshottedFor an external editor: one working copy per side in a jj-diff- temp directory, deleted afterwards (source).working-copy
DescendantsrewrittenRebased, applying their diffs to the edited commit, or reparented with their trees unchanged.transactions
View: heads, bookmarks, wc_commit_idswriteFollow the rewritten and rebased commits.view
OperationwriteOne operation, unless nothing changed.operations
Working copyread, then maybe writeSnapshotted first. If @ was edited or rebased, its new tree is written to disk.working-copy

5. Transaction and operation

  • Transaction: started before the editor opens, so the instructions can format the target's summary, but committed only if the edited tree differs from the target's. Otherwise jj prints Nothing changed. and the transaction is dropped without recording an operation (source).
  • Operation description: edit commit <full hex id of the target before the rewrite> (source).
  • Commits created or rewritten: the target, plus every descendant. Descendants are handled straight after the write because, as the source comment says, the new commit would otherwise "always have a conflicted change id at this point" (source).
  • Working copy: snapshotted at the start. If @ changed, its new tree is checked out.
  • jj undo restores the previous view, bringing back the target's old tree and the old descendants.

6. Algorithm and invariants

Choosing the two sides

There are two shapes (source). With --from or --to, the target is --to and the base is the single --from commit, each defaulting to @, so jj diffedit --to @- shows @ on the left and edits its parent. Otherwise the target is -r and the base is all of its parents. merge_commit_trees returns a single parent's tree as is and merges several, resolving what it can, so for a merge commit you edit its changes relative to the merged parents (source). Either way only the target is rewritten; the base commits are only read. The instructions say which commit you are editing and, for --from/--to, what it is compared with (source).

Loading the editor

diff_editor uses --tool by name if given, and ui.diff-editor otherwise. :builtin is the only built-in diff editor. Any other name is looked up in [merge-tools.<name>], and an unknown name is run as a program with the default arguments (DiffEditTool, with_name). An external tool without edit arguments is rejected before anything opens (source).

An external editor

edit_diff_external does this:

  1. It lists the paths that differ between the two trees under the matcher. Only those are checked out, as sparse patterns, so unchanged files never appear (source).
  2. It checks the base tree out into left and the target tree into right, each a temporary TreeState under a jj-diff- directory. If the edit arguments mention $output, it also checks the target tree out into output (source, 3-pane choice).
  3. It makes left read-only, and right too in 3-pane mode (source).
  4. If instructions are enabled and no such path already exists, it writes JJ-INSTRUCTIONS into the editable side. In 3-pane mode each pane gets a note saying whether to edit it (source).
  5. It runs the tool once, or once per checked-out file in file-by-file mode. A non-zero exit aborts the edit (source).
  6. It deletes JJ-INSTRUCTIONS and snapshots the editable directory. New files are tracked with no size limit, apart from those the base ignore rules exclude. The snapshot's tree is the result (source).

Because the editable side starts as the target's full tree with only changed paths materialized, everything you didn't see, including unmatched paths, keeps the target's value.

The built-in editor

edit_diff_builtin shows the same diff in a terminal UI where you keep or drop each change. Copy tracking isn't handled yet; the code passes empty copy records. apply_diff_builtin then sorts each kept value by the tree it came from (select_changes). It applies each value on top of its own tree, and takes the base's value for any changed path that got nothing from the target side by merging in the target-to-base diff at those paths. Because this is a tree merge, a conflict restored from the base keeps its labels. Unmatched paths keep the target's value, which matches the external tool's behaviour. No instructions file is involved.

Descendants

rebase_descendants applies each descendant's own diff on top of the edited commit, which can conflict. reparent_descendants instead gives each descendant its new parent with its tree untouched, so its content stays the same and its diff absorbs your edit.

Invariants: only the target's tree changes, never its parents, change ID or description. The base is never rewritten. Paths outside the matcher keep the target's value. A no-op edit records nothing. The editor opens even when the two sides are identical: the Empty diff - won't run diff editor. check belongs to DiffSelector, which diffedit doesn't use (source).

7. Errors and edge cases

MessageWhenSource
Nothing changed.The edited tree equals the target's, including when the editor is closed without edits. No operation is recorded.diffedit.rs
Error: Failed to edit diff, caused by Tool exited with <status> (run with --debug to see the exact invocation)The external tool exited non-zero. Nothing is changed.command_error.rs, external.rs
Error executing '<program>' (run with --debug …)The tool couldn't be started.external.rs
The tool `<name>` cannot be used as a diff editorThe tool's edit-args is empty.mod.rs
Hint: Using default editor ':builtin'; run `jj config set --user ui.diff-editor :builtin` to disable this message.ui.diff-editor is unset and no --tool was given.mod.rs
Commit … is immutable (or The root commit … is immutable)The target is immutable.cli_util.rs
Revset `…` resolved to more than one revision (or to none)-r, --from or --to isn't exactly one commit.revset_util.rs
clap: --revision cannot be used with --from/--toBoth forms given.diffedit.rs
Warning: No matching entries for paths: …An explicit path exists in neither tree. It is printed after the editor, even when nothing changed.diffedit.rs, cli_util.rs
Rebased <n> descendant commits[ (while preserving their content)].Status after rewriting descendants.cli_util.rs

8. Worked example

From upstream's test_diffedit, which drives a fake external editor. A adds file1, B adds file2 and file3, and @ deletes file1 and changes file2 to b. When plain jj diffedit runs, the left directory holds only file1 and file2, the two paths that changed, and the right holds JJ-INSTRUCTIONS and file2. file3 isn't checked out at all. Closing the editor without changes prints Nothing changed. (source). With the editor resetting file2 to the left side instead:

jj diffedit, resetting file2: before and after Before: A adds file1; B adds file2 and file3; C at @ deletes file1 and changes file2 to b. The diff editor opens with a left directory holding file1 and file2 from B and a right directory holding file2 from C plus JJ-INSTRUCTIONS; file3 is not checked out. The editor resets file2 to the left side. After: C is rewritten with the same change ID and only deletes file1. A and B are unchanged. before after A qpvuntsm adds file1 B rlvkpnrz adds file2, file3 C kkmpptxz @ −file1, file2 = b temp jj-diff-…/ left/ file1, file2 (read-only) right/ file2, JJ-INSTRUCTIONS editor resets right/file2 A qpvuntsm unchanged B rlvkpnrz 7e268da3 unchanged C kkmpptxz 40ad4f80 @ now only −file1

Upstream's snapshot of the output (source):

Working copy  (@) now at: kkmpptxz 40ad4f80 (no description set)
Parent commit (@-)      : rlvkpnrz 7e268da3 (no description set)
Added 0 files, modified 1 files, removed 0 files

jj diff -s then shows only D file1. The working copy was updated because @ itself was rewritten. The same test also covers other cases:

  • jj diffedit -r @- writing file3: Rebased 1 descendant commits., and the change shows up in @ (source).
  • jj diffedit --from @--: the left side is A, so it holds only file1, and resetting file2 deletes it from @ (source).
  • jj diffedit --to @-: edits B against @, and the instructions add The diff initially shows the commit's changes relative to: followed by @'s summary (source).
  • A tool that exits with status 1: Error: Failed to edit diff, and the diff is unchanged (source).

Upstream's test_diffedit_restore_descendants shows the other descendant mode: adding a ; to @-'s line with --restore-descendants prints Rebased 1 descendant commits (while preserving their content)., and @'s diff now goes from println!("bar"); to println!("baz"); with no conflict.