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
| Flag | Type | Default | Meaning |
|---|---|---|---|
FILESETS (positional) | filesets | all files | Edit only these paths. Unmatched paths stay unchanged (source). |
-r, --revision REVSET | revset | @ if neither --from nor --to | The commit to touch up, compared with its parents. Conflicts with --from and --to (source). |
-f, --from REVSET | revset | @ if --to is given | The commit shown on the left (source). |
-t, --to REVSET | revset | @ if --from is given | The commit to edit, shown on the right (source). |
--tool NAME | string | ui.diff-editor | The diff editor to use (source). |
--restore-descendants | bool | false | Keep descendants' content, not their diffs, when moving them onto the edited commit (source). |
2. Configuration
| Key | Default | Effect |
|---|---|---|
ui.diff-editor | unset, meaning :builtin | The 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-mode | dir | dir runs the tool once on the two directories; file-by-file runs it once per changed file (source). |
merge-tools.<name>.program | the tool's name | The executable to run (source). |
merge-tools.<name>.conflict-marker-style | unset | Overrides how conflicted files are written into the temporary directories (field, use). |
ui.diff-instructions | true | Whether an external editor gets a JJ-INSTRUCTIONS file. The built-in editor never does (default, use). |
revset-aliases."immutable_heads()" | built in | Decides whether the target may be rewritten. |
3. End-to-end flow
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Target commit | rewritten | A new commit with the edited tree. It keeps the parents, change ID, description and author, and you become the committer. | commits |
| Base commits | read | Only their trees: the target's parents merged, or the one --from commit (merge_commit_trees). | trees |
| Trees and files | read, written | Changed files are checked out for the editor; the result is written back as new file and tree objects. | trees |
Temporary TreeStates | created, snapshotted | For an external editor: one working copy per side in a jj-diff- temp directory, deleted afterwards (source). | working-copy |
| Descendants | rewritten | Rebased, applying their diffs to the edited commit, or reparented with their trees unchanged. | transactions |
View: heads, bookmarks, wc_commit_ids | write | Follow the rewritten and rebased commits. | view |
| Operation | write | One operation, unless nothing changed. | operations |
| Working copy | read, then maybe write | Snapshotted 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 undorestores 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:
- 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).
- It checks the base tree out into
leftand the target tree intoright, each a temporaryTreeStateunder ajj-diff-directory. If the edit arguments mention$output, it also checks the target tree out intooutput(source, 3-pane choice). - It makes
leftread-only, andrighttoo in 3-pane mode (source). - If instructions are enabled and no such path already exists, it writes
JJ-INSTRUCTIONSinto the editable side. In 3-pane mode each pane gets a note saying whether to edit it (source). - It runs the tool once, or once per checked-out file in
file-by-filemode. A non-zero exit aborts the edit (source). - It deletes
JJ-INSTRUCTIONSand 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
| Message | When | Source |
|---|---|---|
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 editor | The 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/--to | Both 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:
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 @-writingfile3:Rebased 1 descendant commits., and the change shows up in@(source).jj diffedit --from @--: the left side isA, so it holds onlyfile1, and resettingfile2deletes it from@(source).jj diffedit --to @-: editsBagainst@, and the instructions addThe 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.