jj / How jj restore works

How jj restore works

jj restore rewrites one commit so that some or all of its paths get their content from a source tree: the commit's parents by default, or any revision you name. It is jj's way of discarding changes, either whole files or interactively chosen hunks.

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. Choosing source and destination
  8. Building the new tree
  9. Descendants
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: jj picks a destination commit and a source tree. That is @ and @'s parents by default, --changes-in X for X and its parents, or --from/--into, each defaulting to @. It refuses an immutable destination. The shared DiffSelector builds the new tree: the destination with the matching paths copied from the source, optionally refined in a diff editor. If that equals the old tree, it prints Nothing changed.. Otherwise it rewrites the destination, keeping its change ID and description. Descendants are rebased, which can conflict, or reparented with --restore-descendants. The operation is restore into commit <id>.

The command is cmd_restore. Its help text compares plain jj restore with jj abandon: both drop @'s changes, but restore keeps the commit and its metadata, leaving it empty (source). Selection works as in jj commit. For moving changes rather than discarding them, see jj squash.

1. Command-line surface

FlagTypeDefaultMeaning
FILESETS (positional)filesetsall filesRestore only these paths (source).
-f, --from REVSETrevset@ if --into is givenThe commit whose tree is the source. It must resolve to one commit (source).
-t, --into, --to REVSETrevset@ if --from is givenThe commit to rewrite (source).
-c, --changes-in REVSETrevset@ when no --from/--intoUndo this commit's own changes, restoring it from the merge of its parents. Conflicts with --from and --into (source).
-r, --revisionrevsethiddenExists only to print an error, so -r isn't mistaken for --from (source).
-i, --interactiveboolfalseChoose what to restore in a diff editor (source).
--tool NAMEstringnoneDiff editor to use. Implies -i (source).
--restore-descendantsboolfalseKeep descendants' content, not their diffs, when moving them onto the rewritten commit (source).

RestoreArgs

2. Configuration

No restore-specific keys. ui.diff-editor (defaulting to :builtin with a hint) and ui.diff-instructions apply to -i, as described on the commit page. revset-aliases."immutable_heads()" decides whether the destination may be rewritten. user.name/user.email become the committer of the rewritten commits.

3. End-to-end flow

jj restore: call flow cmd_restore loads the workspace through the shared lifecycle, which snapshots the working copy. It rejects -r. With --from or --into it resolves both, each defaulting to @, and uses the source commit's tree; otherwise the destination is --changes-in (default @) and the source is the merge of its parents' trees. It checks the destination is rewritable, builds the path matcher and diff selector, and runs DiffSelector::select, which calls restore_tree to copy matching paths from the source onto the destination tree, then optionally opens the diff editor. It warns about unmatched paths. If the tree is unchanged it prints Nothing changed. Otherwise a transaction rewrites the destination with the new tree, rebases or reparents descendants, and tx.finish records "restore into commit". Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) --from / --into given destination = --into or @ source tree = (--from or @).tree() otherwise: --changes-in (default @) destination = that commit source tree = merge of its parents' trees check_rewritable(destination); parse_file_patterns → matcher; diff_selector(-i / --tool) DiffSelector::select → restore_tree(source, destination) all paths: the source tree as is some paths: merge(dest, dest's matched, source's matched) -i: then edit the right side in the diff editor warn on unmatched paths new tree == old tree? "Nothing changed." — no transaction start_transaction; rewrite_commit(destination).set_tree(new tree).write() then rebase_descendants() — or reparent_descendants() with --restore-descendants — "Rebased N descendant commits …" tx.finish(ui, "restore into commit <id>"): write the operation, update the working copy (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Destination commitrewrittenA new commit with the restored tree. It keeps the parents, change ID, description and author, and you become the committer.commits
Source commitsreadOnly their trees: one commit's tree for --from, or the merged parent tree for --changes-in.trees
Treesread, mergedrestore_tree filters both sides to the matched paths, then 3-way merges them so conflict labels survive (source).trees
DescendantsrewrittenRebased, applying their diffs to the restored commit, or reparented with their trees unchanged.transactions
View: heads, bookmarks, wc_commit_idswriteFollow the rewritten and rebased commits.view
OperationwriteOne operation, unless nothing changed.operations
Working copy (TreeState)read, then maybe writeSnapshotted first. If @ was rewritten or rebased, the restored files are written to disk.working-copy

5. Transaction and operation

  • Transaction: one, but only if the new tree differs from the destination's. Otherwise jj prints Nothing changed. and records no operation (source).
  • Operation description: restore into commit <full hex id of the destination before the rewrite> (source).
  • Commits created or rewritten: the destination, plus every descendant. Descendants are handled right after the write because, as the source comment puts it, the new commit would otherwise "always have a conflicted change id at this point" (source).
  • Working copy: snapshotted at the start, so plain jj restore discards your latest edits too, though they remain recoverable through the operation log. If @ changed, its new tree is checked out.
  • jj undo restores the previous view, bringing back the destination's old tree and the old descendants.

6. Algorithm and invariants

Choosing source and destination

There are two shapes (source). With --from or --into, both are single commits, each defaulting to @, and the source is one tree. Otherwise the destination is --changes-in (default @) and the source is parent_tree, the merge of all its parents' trees. So for a merge commit, "undo its changes" means restoring the merged parents, not one parent. -r is rejected outright: in jj diff's convention it would name the destination, and the source comment worries that someone meaning --from would empty the wrong commit without noticing (source, error).

Building the new tree

DiffSelector::select is called with the destination tree as "before" and the source tree as "after" (source). It first runs restore_tree:

  1. If the matcher matches everything, the result is simply the source tree (source).
  2. Otherwise it makes a base tree: the destination's tree IDs with each conflict label prefixed "base files for restore", so the two copies of the destination can't be confused (source).
  3. It narrows the base tree and the source tree to the matched paths with MergedTree::filtered, which writes a new tree through FilteredTreeWriter (a visitor for the shared tree rewriter) and keeps the input's arity and conflict labels. It then merges destination + (source's matched paths − destination's matched paths). Matched paths that are the same on both sides cancel out, so the result is the destination with exactly the paths that differ replaced. Using a merge rather than overwriting keeps the conflict labels of both sides when restoring a conflicted file into a conflicted commit (source).

With -i, the diff editor then opens on destination → that result. Whatever you leave on the right becomes the destination's tree, which is how partial-file restores work. The instructions tell you which commits you're restoring from and into (source).

Descendants

rebase_descendants applies each descendant's own diff on top of its rewritten parent. If a descendant edited content the restore removed, it conflicts, as in the example below. reparent_descendants instead gives each descendant its new parents with its tree untouched. Its content stays the same, and its diff absorbs what was restored away.

Invariants: only the destination's tree changes, never its identity or metadata. Paths outside the matcher are never touched. A no-op restore records nothing. Restoring is a tree replacement, not a reverse patch, so the result for a matched path is the source's content, whatever the destination had.

7. Errors and edge cases

MessageWhenSource
`jj restore` does not have a `--revision`/`-r` option., with hints pointing to --from and --changes-in-r was passed.restore.rs
Nothing changed.The restored tree equals the destination's. No operation is recorded.restore.rs
Warning: No matching entries for paths: …An explicit path exists in neither the destination nor the source.cli_util.rs
Commit … is immutableThe destination is immutable.cli_util.rs
Revset `…` resolved to more than one revision (or to none)--from, --into or --changes-in isn't exactly one commit.revset_util.rs
Empty diff - won't run diff editor.-i when nothing differs.cli_util.rs
clap: --changes-in cannot be used with --from/--intoBoth forms given.restore.rs
Rebased <n> descendant commits[ (while preserving their content)].Status after rewriting descendants.cli_util.rs

8. Worked example

From upstream's test_restore. A adds file1, B adds file2 (b), and @ removes file1, changes file2 to c and adds file3. Running jj restore -c=@- undoes B's own change:

jj restore -c=@-: before and after Before: A adds file1; B adds file2 with content b; C at @ deletes file1, changes file2 to c and adds file3. After restoring the changes in B: B is rewritten and is now empty, keeping its change ID. C is rebased onto it. C's change to file2 has nothing to apply to now that file2 no longer exists in B, so C becomes conflicted on file2: a 2-sided conflict including one deletion. before after A qpvuntsm adds file1 B rlvkpnrz adds file2 = b C kkmpptxz @ −file1, file2 = c, +file3 A qpvuntsm unchanged B rlvkpnrz 67841e01 restored: now (empty) C kkmpptxz faa5911c @ rebased: (conflict) on file2 jj restore -c=@-

Upstream's snapshot of the start of the output:

Rebased 1 descendant commits.
Working copy  (@) now at: kkmpptxz faa5911c (conflict) (no description set)
Parent commit (@-)      : rlvkpnrz 67841e01 (empty) (no description set)
Added 0 files, modified 1 files, removed 0 files
Warning: There are unresolved conflicts at these paths:
file2    2-sided conflict including 1 deletion

B keeps its change ID but is now empty. Its one change, adding file2, was restored from its parent, where file2 doesn't exist. The default rebase then replays @'s modification of file2 onto a parent without the file, hence the conflict. The operation is restore into commit <B's old id>. Upstream's test_restore_restore_descendants shows the alternative: jj restore -c b file --restore-descendants fixes a commit that shouldn't have touched file, while the merge on top keeps exactly its content. It prints Rebased 1 descendant commits (while preserving their content).