jj / How jj undo works

How jj undo works

jj undo makes the repository look the way it did before the last operation. It doesn't delete that operation. It records a new operation whose view is the old one, and repeating jj undo walks further back.

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 undo stack, encoded in descriptions
  8. What restoring means
  9. Guards
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: jj takes the current operation. If it is itself an undo, jj takes the operation that undo restored, read back from its description. It refuses if the operation came from another workspace, is the root, or is a merge, and warns about undoing a push or a workspace removal. It then picks the operation to restore: the target's parent, or, if that parent is an undo, what that undo restored. A transaction copies that operation's view (commits, bookmarks, tags, working-copy pointers and remote-tracking refs) over the current one. It is recorded as undo: restore to operation <id>. That description is how the next jj undo finds its place.

The command is cmd_undo. The operations page compares undo with redo, op restore and op revert, and explains what a view is. This page follows undo line by line. Restoring an operation never touches commits, which are immutable and stay in the store. Only the view, meaning which commits are visible and where refs point, changes.

1. Command-line surface

FlagTypeDefaultMeaning
--allow-cross-workspaceboolfalseUndo the operation even if it was recorded in a different workspace (source).

UndoArgs and its help text

There is no argument naming an operation. To restore or revert a particular one, the help text points to jj op restore and jj op revert. The global --at-operation flag changes which operation the repository is loaded at, and therefore which one is "current".

2. Configuration

jj undo reads no settings of its own. templates.op_summary formats the Undid operation: and Restored to operation: lines (source). The working-copy settings on the working-copy page govern the snapshot at the start and the checkout at the end. jj undo always restores both the repo portion and the remote-tracking portion of the view; the choice that op restore --what offers isn't exposed (DEFAULT_REVERT_WHAT).

3. End-to-end flow

jj undo: call flow cmd_undo loads the workspace through the shared lifecycle, which snapshots the working copy and may record a snapshot operation. It takes the current operation as the target; if the target's description starts with "undo: restore to operation", it loads the operation named there instead. It refuses a target from another workspace unless --allow-cross-workspace, and warns for a push or a workspace removal. The target must have exactly one parent; the root and merges are refused. If that parent is itself an undo, it is replaced by the operation it restored. A transaction sets the view to view_with_desired_portions_restored of the parent's view, prints what it undid and restored, and tx.finish records "undo: restore to operation" plus the parent's ID, then updates the working copy. Shared lifecycle: load workspace and repo, snapshot (may record an operation) (CLI page) target = current operation an undo? → target = the op it restored (parsed from "undo: restore to operation <hex>") target from another workspace? "Refusing to undo operation …" unless --allow-cross-workspace warnings "push …" → may conflict bookmarks "remove workspace …" → directory not restored parent = target's only parent none → "Cannot undo root operation" several → "Cannot undo a merge operation" an undo? → parent = the op it restored start_transaction; set_view(view_with_desired_portions_restored(parent's view, current view, [Repo, RemoteTracking])) from the parent: head_ids, local_bookmarks, local_tags, wc_commit_ids (Repo), remote_views (RemoteTracking) kept from the current view: git_refs, git_heads print "Undid operation: …" and "Restored to operation: …" tx.finish(ui, "undo: restore to operation <parent id>"): write the operation, check out the restored @ (or warn if this workspace no longer exists), and report the changes (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Current operation and its metadatareaddescription (is it an undo or a push? which operation did it restore?), workspace_name (cross-workspace check), and parents.operations
Op storereadload_operation for the operation named in an undo's description, and that operation's parents and view.operations
View.head_ids, local_bookmarks, local_tags, wc_commit_idswriteReplaced wholesale by the restored operation's values. That is the Repo portion.view
View.remote_viewswriteReplaced by the restored operation's remote bookmarks and tags. That is the RemoteTracking portion.view
View.git_refs, git_headskeptAlways taken from the current view (source).view
CommitsnoneNo commit is created or rewritten. Commits that become invisible stay in the store, and commits that become visible again were never deleted.commits
OperationwriteA new operation whose parent is the current one, holding the restored view.operations
Working copy (TreeState)read, then maybe writeSnapshotted at the start. If this workspace's @ differs in the restored view, its tree is checked out. If the workspace doesn't exist in the restored view, the files are left alone, with a warning.working-copy

5. Transaction and operation

  • Transaction: one, started after every check passes (source).
  • Operation description: undo: restore to operation <id>, where <id> is the full hex ID of the restored operation (UNDO_OP_DESC_PREFIX, source). This exact text is functional: the next jj undo strips the prefix and parses the ID. jj op revert uses a different description, so an undo after op revert undoes the revert rather than continuing a stack (upstream test).
  • Commits created or rewritten: none. Only the view changes.
  • Working copy: the workspace is loaded with the usual snapshot. If you have unrecorded edits, that snapshot is committed as its own operation and becomes the current one (source), so it is what jj undo undoes. After the undo operation is written, the restored @ is checked out.
  • jj undo again continues further back. jj redo undoes the undo, by restoring the operation the undo replaced.

6. Algorithm and invariants

The undo stack, encoded in descriptions

There is no separate undo stack. It is reconstructed from operation descriptions, using two substitutions (source comment):

  1. Choosing what to undo. If the current operation is an undo, the thing to undo next is the operation it restored, not the undo itself (source). So repeated jj undo keeps stepping back instead of toggling between two states.
  2. Choosing what to restore. Normally that's the target's parent. If the parent is itself an undo, jj restores what that undo restored instead (source). The source comment explains that otherwise, repeated jj new; jj undo would build an ever-longer linked list of undos that a later undo would have to walk.

Together these make the stack "jump over" older undo stacks it runs into, as the worked example below shows.

What restoring means

view_with_desired_portions_restored builds a new View field by field. With Repo selected, head_ids, local_bookmarks, local_tags and wc_commit_ids come from the restored view. With RemoteTracking selected, so does remote_views. The Git ref mirrors git_refs and git_heads always come from the current view (RevertWhatToRestore). MutableRepo::set_view installs it un-normalized (source), and the transaction commits it as usual.

Guards

  • Other workspaces. An operation records the workspace it ran in. Undoing another workspace's operation would usually surprise you, so it needs --allow-cross-workspace. Operations with no recorded workspace pass the check (source).
  • Pushes. Undoing a push … operation (is_push_operation) restores remote-tracking bookmarks to their pre-push positions, while the remote keeps the pushed ones. That's why it warns that the bookmarks will often end up conflicted.
  • Workspace removal. The workspace's entry in the view comes back, but its directory on disk doesn't.
  • Shape. The root operation has nothing to restore. A merge operation, created when concurrent operations are reconciled, has no single "before", so jj points you to op restore.

Invariants: undo is append-only, because it always adds an operation and never deletes one. Undo can itself be undone (jj redo, or jj op restore to the undo's parent). No commit is ever destroyed. Repeated undos step back monotonically through the non-undo history, without building chains.

7. Errors and edge cases

MessageWhenSource
Refusing to undo operation <id> because it was performed in workspace <name>, hint about --allow-cross-workspace or jj op revertThe target operation's workspace_name is set and differs from the current workspace.undo.rs
Cannot undo root operationThe target has no parent, for example after undoing back to repository creation.undo.rs
Cannot undo a merge operation, hint Consider using `jj op restore` insteadThe target has several parents.undo.rs
Warning Undoing a push operation often leads to conflicted bookmarks., hint To avoid this, run `jj redo` now.The target's description starts with push .undo.rs
Warning Undoing a workspace removal restores the workspace, but does not restore the workspace directory. and a hintThe target's description starts with remove workspace.undo.rs
Warning The current workspace '…' no longer exists after this operation. The working copy was left untouched.The restored view has no entry for this workspace, for example after undoing add workspace 'default'.cli_util.rs
internal error Failed to parse ID of target operation in undo-stack (and … target operation's parent …)An operation description has the undo prefix but no valid hex ID after it.undo.rs, undo.rs

Upstream's tests cover the root case, including the missing-workspace warning on the first undo (source), the merge case, created with --at-op (source), and the push warning (source).

8. Worked example

undo moves through operations, not commits, so this example draws the operation log. It is upstream's test_jump_over_old_undo_stack. Each of operations A–D writes its own letter to a file named state and snapshots. The test then runs undo, undo, writes and snapshots E and F, and runs undo three more times. After each command it checks the file.

The operation log after test_jump_over_old_undo_stack Operations from oldest to newest: add workspace, A, B, C, D, then undo 1 restoring C, undo 2 restoring B, then E and F, then undo 3 restoring E, undo 4 restoring B, and undo 5 restoring A. Undo 2 targeted C because undo 1 had restored C. Undo 4 targeted E, whose parent is undo 2, so it restored what undo 2 restored, B, jumping over the old undo stack. Undo 5 targeted B and restored its parent A. The file reads C, B, E, B and A after the five undos. add workspace 'default' A snapshot state = A B snapshot state = B C snapshot state = C D snapshot state = D undo 1: restore to C undid D → state C undo 2: restore to B target C (what undo 1 restored) → state B E snapshot state = E F snapshot state = F undo 3: restore to E undid F → state E undo 4: restore to B target E; its parent is undo 2 → B undo 5: restore to A target B; parent A → state A

The test asserts the file reads C, then B, then E, B and A. The fourth undo is the interesting one. Its target is E, the operation undo 3 restored, and E's parent is undo 2. Rather than restore undo 2, it restores what undo 2 restored, B. The old stack (undo 1, undo 2, C, D) is jumped over, so the fifth undo goes straight from B to A.