In short: jj resolves the revisions to revert (children first) and the location (new parents, plus new
children for -A/-B). Starting from the merged tree of the new parents, for
each revision it computes base + (reverted revision's parents − reverted revision) as a
3-way merge, which may conflict. It writes a commit with that tree and a description from
templates.revert_description, and the next revert stacks on top. Finally it inserts the
chain above the new children and rebases them. The operation is
revert commit <id> (and N more).
The command is cmd_revert. Unlike
jj abandon or
jj restore, it never rewrites the reverted
commits. It only adds new ones. Placement with -o/-A/-B works as
for jj duplicate. (Reverting an
operation is jj op revert, a different command.)
1. Command-line surface
| Flag | Type | Default | Meaning |
|---|---|---|---|
-r, --revision, --revisions REVSETS | revsets, repeatable | required | The revisions to undo (source). |
-o, --onto, -d, --destination REVSETS | revsets, repeatable | — | Put the revert chain on top of these commits. Nothing is rebased (source). |
-A, --insert-after, --after REVSETS | revsets, repeatable | — | Insert the chain after these commits. Their children move on top of it (source). |
-B, --insert-before, --before REVSETS | revsets, repeatable | — | Insert the chain before these commits, on their parents (source). |
A clap group requires at least one location flag. -A and -B can be combined,
and neither can be combined with -o (source).
2. Configuration
| Key | Default | Effect |
|---|---|---|
templates.revert_description | Revert "<first line>", a blank line, then This reverts commit <full id>. | The description of each new commit, rendered against the reverted commit (default, source). |
revset-aliases."immutable_heads()" | (built-in) | Only new children (from -A/-B) must be mutable. Reverted commits may be immutable, since they aren't touched. |
3. End-to-end flow
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Trees | read, write | A 3-way MergedTree::merge per revision, with conflict labels naming each side (source). | trees |
| Commits | write | One new commit per reverted revision, chained. The reverted commits are only read. | commits |
| Descendants of the location | write | With -A/-B, the new children and all their descendants are rebased onto the chain. | transactions |
| Templates | read | The description template is parsed once and rendered against each reverted commit. | — |
| Operation | write | One operation. | operations |
5. Transaction and operation
- Transaction: one, after the location is resolved and the descriptions rendered (source).
- Operation description:
revert commit <full hex>, orrevert commit <first> and <N> more, where "first" is the first revision in the children-first order (source). - Commits created: one per reverted revision. The working copy doesn't move to them.
- Commits rewritten: none with
-o. With-A/-B, the new children and their descendants.
6. Algorithm and invariants
Revisions are reverted in the order the revset returns them, which is reverse topological, children first (source). That is the natural order for undoing a stack: the newest change is undone first, so each later reverse diff applies to a tree that no longer has the changes stacked on top of it.
For each revision R, with the running base tree B (initially the merged tree of the new parents), the
new tree is MergedTree::merge([B, R.tree, R.parent_tree]). That is a merge whose sides
are B and R's parent tree, with R's own tree as the base (source).
So the result is B plus the change from R to its parents, which is R's diff reversed. If B has since
changed the same lines, the result contains a conflict, labelled
(revert destination), (reverted revision) and
(parents of reverted revision) (test_revert_with_conflict).
Inserting with -A/-B (source): each new
child's parents that were among the new parents are replaced by the chain's head, and the head is
always added. IndexSet keeps the order and removes duplicates. Every commit
transform_descendants visits is rebased and counted, so "Rebased N" includes descendants
further down, not just the direct children.
Invariants: reverted commits are never modified. Every new commit's tree is a merge, so conflicts are recorded rather than failing the command. The chain is linear.
7. Errors and edge cases
| Message | When | Source |
|---|---|---|
error: the following required arguments were not provided: <--onto …|--insert-after …|--insert-before …> | No location flag. Clap exits with status 2. | revert.rs, test |
No revisions to revert. | The revset is empty. This is a status message. | revert.rs |
Refusing to create a loop: commit … would be both an ancestor and a descendant of the reverted commits | A new child is an ancestor of a new parent. | cli_util.rs |
No revisions found to use as parent | The location resolves to no parents. | cli_util.rs |
Commit … is immutable | A new child from -A/-B is immutable. | cli_util.rs |
8. Worked example
From upstream's test_revert. A stack a → b → c → d,
where only a adds a file (a), and @ is d. Then
jj revert -ra -d@ runs:
Upstream's snapshot:
$ jj revert -ra -d@
Reverted 1 commits as follows:
wqnwkozp 64910788 Revert "a"
$ jj log
○ 64910788f8a5 Revert "a"
│
│ This reverts commit 7d980be7a1d499e4d316ab4c01242885032f7eaf.
@ 98fb6151f954 d
...
$ jj diff -s -r@+
D a
The base was d's tree. Reversing a (parent root, which has no file
a) deletes the file. Reverting that revert again with jj revert -r@+ -d@+
gives Revert "Revert "a"", which adds it back. With -Ab instead of
-d@, the new commit goes between b and c, and the output adds
Rebased 2 descendant commits. for c and d
(test).