jj / How jj revert works

How jj revert works

jj revert creates new commits that undo the changes of existing ones. The originals are left alone. Each new commit applies one revision's diff in reverse, in a chain at a location you pick with -o, -A or -B.

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. 7. Errors and edge cases
  8. 8. Worked example

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

FlagTypeDefaultMeaning
-r, --revision, --revisions REVSETSrevsets, repeatablerequiredThe revisions to undo (source).
-o, --onto, -d, --destination REVSETSrevsets, repeatable—Put the revert chain on top of these commits. Nothing is rebased (source).
-A, --insert-after, --after REVSETSrevsets, repeatable—Insert the chain after these commits. Their children move on top of it (source).
-B, --insert-before, --before REVSETSrevsets, 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).

RevertArgs

2. Configuration

KeyDefaultEffect
templates.revert_descriptionRevert "<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

jj revert: call flow cmd_revert resolves the revisions to revert, children first, and returns "No revisions to revert." if there are none. compute_commit_location turns -o, -A or -B into new parents and new children, checking that new children are mutable and that no loop would form. jj renders each new description from templates.revert_description and starts a transaction. Starting from the merged tree of the new parents, for each revision it merges the base with the reverted revision's parents minus the revision itself, writes a new commit, and makes it the parent of the next. Then it rebases the new children onto the last new commit and records "revert commit … (and N more)". to_revert = -r (children first) none → "No revisions to revert." compute_commit_location(-o, -A, -B) → new parents, new children location rules (shared helper) -o: parents only; -A: children of targets -B: parents of targets; new children must be mutable and not ancestors of new parents start_transaction; base = merge_commit_trees(new parents); for each revision R: tree = merge(base, minus R.tree, plus R.parent_tree) — i.e. base + (R's parents − R) labels: "(revert destination)", "(reverted revision)", "(parents of reverted revision)" new_commit(parents, tree) with the templated description parents := [new commit]; base := tree transform_descendants(new children): replace parents that were new parents with the chain head and always add the chain head; rebase() every visited descendant (-o has no new children, so nothing is rebased) "Reverted N commits as follows:" … [+ "Rebased M descendant commits."]; tx.finish("revert commit <id> [and N more]")

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Treesread, writeA 3-way MergedTree::merge per revision, with conflict labels naming each side (source).trees
CommitswriteOne new commit per reverted revision, chained. The reverted commits are only read.commits
Descendants of the locationwriteWith -A/-B, the new children and all their descendants are rebased onto the chain.transactions
TemplatesreadThe description template is parsed once and rendered against each reverted commit.—
OperationwriteOne operation.operations

5. Transaction and operation

  • Transaction: one, after the location is resolved and the descriptions rendered (source).
  • Operation description: revert commit <full hex>, or revert 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

MessageWhenSource
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 commitsA new child is an ancestor of a new parent.cli_util.rs
No revisions found to use as parentThe location resolves to no parents.cli_util.rs
Commit … is immutableA 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:

jj revert -ra -d@ Before: root, a (adds file a), b, c, d which is @. After jj revert -ra -d@: the same stack, plus a new commit 64910788 "Revert a" on top of d that deletes file a. a itself is unchanged and @ stays on d. before after revert -ra -d@ root a A a b c @ d root a A a b c @ d 64910788 Revert "a" · D a

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).