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
| Flag | Type | Default | Meaning |
|---|---|---|---|
FILESETS (positional) | filesets | all files | Restore only these paths (source). |
-f, --from REVSET | revset | @ if --into is given | The commit whose tree is the source. It must resolve to one commit (source). |
-t, --into, --to REVSET | revset | @ if --from is given | The commit to rewrite (source). |
-c, --changes-in REVSET | revset | @ when no --from/--into | Undo this commit's own changes, restoring it from the merge of its parents. Conflicts with --from and --into (source). |
-r, --revision | revset | hidden | Exists only to print an error, so -r isn't mistaken for --from (source). |
-i, --interactive | bool | false | Choose what to restore in a diff editor (source). |
--tool NAME | string | none | Diff editor to use. Implies -i (source). |
--restore-descendants | bool | false | Keep descendants' content, not their diffs, when moving them onto the rewritten commit (source). |
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
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Destination commit | rewritten | A new commit with the restored tree. It keeps the parents, change ID, description and author, and you become the committer. | commits |
| Source commits | read | Only their trees: one commit's tree for --from, or the merged parent tree for --changes-in. | trees |
| Trees | read, merged | restore_tree filters both sides to the matched paths, then 3-way merges them so conflict labels survive (source). | trees |
| Descendants | rewritten | Rebased, applying their diffs to the restored commit, or reparented with their trees unchanged. | transactions |
View: heads, bookmarks, wc_commit_ids | write | Follow the rewritten and rebased commits. | view |
| Operation | write | One operation, unless nothing changed. | operations |
Working copy (TreeState) | read, then maybe write | Snapshotted 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 restorediscards your latest edits too, though they remain recoverable through the operation log. If@changed, its new tree is checked out. jj undorestores 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:
- If the matcher matches everything, the result is simply the source tree (source).
- 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).
-
It narrows the base tree and the source tree to the matched paths with
MergedTree::filtered, which writes a new tree throughFilteredTreeWriter(a visitor for the shared tree rewriter) and keeps the input's arity and conflict labels. It then mergesdestination + (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
| Message | When | Source |
|---|---|---|
`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 immutable | The 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/--into | Both 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:
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).