jj / How jj operation revert works

How jj operation revert works

jj op revert X undoes one earlier operation, X, while keeping everything that happened after it. It does this as a three-way merge of views: apply "X's parent minus X" to the current state, and record the result as a new operation.

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. The inverse as a merge
  8. Then --what
  9. 7. Errors and edge cases
  10. 8. Worked example

In short: jj resolves X (default @) and requires it to have exactly one parent. In a transaction it loads the repo at X and at X's parent and merges them into the current repo, with X as the base and the parent as the other side. Rewrites and abandons implied by that merge are recorded, so later descendants get rebased. --what can keep the current repo state or remote-tracking bookmarks instead of the merged ones. The operation is revert operation <id>.

The command is cmd_op_revert. Compare jj op restore, which jumps back to X's state and drops everything after it, and jj undo, which restores the current operation's parent. Merging views is the same machinery jj uses for concurrent operations.

1. Command-line surface

ArgumentTypeDefaultMeaning
OPERATION (positional)operation expression@The operation to revert (source).
--whatrepo | remote-tracking, repeatablebothWhich parts of the merged state to keep. Experimental (source, values).

OperationRevertArgs

2. Configuration

No revert-specific keys. templates.op_summary formats the Reverted operation: line (source). Descendants are rebased by tx.finish as for any other command.

3. End-to-end flow

jj op revert: call flow cmd_op_revert loads the workspace through the shared lifecycle and resolves X. X must have exactly one parent, otherwise it errors for the root or a merge operation. In a transaction it loads repos at X and at X's parent, and merges them into the current repo with X as the base. It then keeps the merged or the current parts of the view according to --what, prints Reverted operation, and tx.finish records "revert operation", rebasing descendants of rewritten commits. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) resolve X; parents(X) must be exactly one, else "Cannot revert root / a merge operation" start_transaction; load repos at X, parent(X) tx.repo_mut().merge(base: X, other: parent(X)) current + (parent − X) MutableRepo::merge merge indexes; record rewrites/abandons by change ID; 3-way merge heads, bookmarks, tags, refs, @ commits view_with_desired_portions_restored(merged, current, --what); set_view; "Reverted operation: …" tx.finish(ui, "revert operation <id>"): rebase descendants of rewritten commits, write the operation

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Repos at X and parent(X)readLoaded with repo_loader.load_at, views and indexes (source).operations
IndexmergedBoth indexes are merged in first, so the view merge can see every commit involved (source).index
Viewmerged, then filteredHeads, local bookmarks, tags, Git refs, remote refs, Git HEADs and working-copy commits are merged field by field (source).view
Commitsdescendants rewrittenRewrites X made are recorded in reverse, so descendants created after X are rebased by tx.finish.transactions
Operation logappendOne operation; X stays in the log.operations

5. Transaction and operation

  • Transaction: one, started after the parent check (source).
  • Operation description: revert operation <full hex id of X> (source). Unlike undo's description, jj undo doesn't treat it specially, so undoing after op revert undoes the revert.
  • Commits rewritten: descendants of commits whose rewrite is reversed, rebased by tx.finish (source).
  • Nothing to revert: if the merge leaves the view unchanged, tx.finish prints Nothing changed. and records nothing.

6. Algorithm and invariants

The inverse as a merge

MutableRepo::merge(base, other) applies "other minus base" to the repo being built. With base = X and other = parent(X), that is the inverse of X's change, applied on top of the current state (source). Field by field (merge_view):

  • Heads: with the default index, record_rewrites compares the commits X has and its parent doesn't. A commit X introduced whose change ID also appears in parent(X) is recorded as rewritten back to the parent's version; one whose change disappears is recorded as abandoned. Heads present in parent(X) but not X are added back.
  • Bookmarks, tags, Git refs, remote refs, Git HEADs: each name that differs between X and parent(X) is three-way merged into the current value, which can produce a conflicted bookmark if it moved again since.
  • Working-copy commits: merged per workspace the same way.

Then --what

view_with_desired_portions_restored is called with the merged view as the one to "restore" and the transaction's base view as current (source). Leaving out repo keeps the current heads, local bookmarks, tags and working-copy commits; leaving out remote-tracking keeps the current remote bookmarks. Git ref mirrors always come from the current view.

Invariants: append-only. Later operations' effects survive unless they conflict with the inverse. Root and merge operations can't be reverted, because there's no single "before".

7. Errors and edge cases

MessageWhenSource
Cannot revert root operationX is the root operation.revert.rs, test
Cannot revert a merge operationX has several parents, e.g. it reconciled concurrent operations.revert.rs, test
Nothing changed.The merge leaves the view as it is.cli_util.rs
Operation-resolution errorsX doesn't resolve to exactly one operation.cli_util.rs

8. Worked example

From upstream's test_revert_rewrite_with_child. The commit is described initial, then modified, and a child is created on top. Then jj op revert @- reverts the second describe, which is no longer the latest operation:

jj op revert of a describe, with a child created afterwards Operation X rewrote the commit initial into modified. Afterwards a child was created on modified. Reverting X merges the inverse: modified is recorded as rewritten back to initial, so the child is rebased onto initial. The log shows child on top of initial. before (X = describe -m modified) after jj op revert @- child @ created after X modified X rewrote initial into this child @ rebased initial X's rewrite reversed record_rewrites(base = X, other = parent(X)): modified's change ID reappears as initial → set_rewritten_commit(modified → initial)

Upstream's log after the revert (source):

@  child
○  initial
◆

The source comment states the intent: "Since we undid the description-change, the child commit should now be on top of the initial commit."