jj / How jj duplicate works

How jj duplicate works

jj duplicate copies commits into new changes. Each copy has the same content and, by default, the same description, but a new change ID. Copies go next to the originals, or onto a destination you name 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. Without a destination
  8. With a destination
  9. 7. Errors and edge cases
  10. 8. Worked example

In short: jj evaluates the revisions (default @), refuses the root commit, and renders each copy's description with templates.duplicate_description. Without a destination, each copy gets the original's parents, with any parent that is also being copied swapped for its copy. Trees are reused unchanged. With a destination, the roots of the set are rebased onto it, the rest follow, and -A/-B rebase the destination's children onto the copies' heads. The originals are never touched, so even immutable commits can be duplicated. The operation is duplicate <n> commit(s).

The command is cmd_duplicate. The work happens in jj-lib's duplicate_commits_onto_parents and duplicate_commits. The second shares its "connected set, internal parents, heads" logic with jj rebase -r.

1. Command-line surface

FlagTypeDefaultMeaning
REVSETS (positional)revsets, repeatable@The commits to copy. Positional and -r values are unioned. Any revision is allowed, including immutable ones (source).
-r REVSETSrevsets, repeatablenoneHidden alias for the positional argument (source).
-o, --onto, -d, --destination REVSETSrevsets, repeatablenonePut the copies' roots on these parents. Repeat it for a merge (source).
-A, --insert-after, --after REVSETSrevsets, repeatablenoneInsert the copies after these, and move their children onto the copies (source).
-B, --insert-before, --before REVSETSrevsets, repeatablenoneInsert the copies before these: on their parents, with them rebased on top (source).

DuplicateArgs

2. Configuration

KeyDefaultEffect
templates.duplicate_descriptiondescription (templates.toml)A commit template rendered against each original to give the copy's description. A trailing newline is added if missing (source). Upstream tests a custom template (test_duplicate_description_template).
revset-aliases."immutable_heads()"see the CLI pageOnly matters for -A/-B, whose rebased children must be mutable.
user.name, user.emailunsetBecome the copies' committer. The author is kept.

3. End-to-end flow

jj duplicate: call flow cmd_duplicate loads the workspace through the shared lifecycle, which snapshots the working copy. It evaluates the revisions (default @) to commit IDs, children first; with none it prints "No revisions to duplicate.", and it refuses the root commit. With -o, -A or -B it computes the location. In a transaction it warns if a copy would be its own ancestor or descendant, renders templates.duplicate_description for each original, then either duplicate_commits_onto_parents, which rewrites each original onto its parents or their copies with a new change ID, or duplicate_commits, which rebases the set's roots onto the destination, copies the rest on top, and rebases the new children onto the copies' heads. It prints each copy and tx.finish records "duplicate N commit(s)". Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) evaluate revsets (or @) → IDs, children first none → "No revisions to duplicate." root included → "Cannot duplicate the root commit" -o / -A / -B? compute_commit_location → (new parents, new children) start_transaction; warn "as a descendant of itself" / "as an ancestor of itself"; render templates.duplicate_description against each original → new descriptions no destination: duplicate_commits_onto_parents oldest first: rewrite_commit(original) no rewrite source, new change ID, parents → their copies where copied; same tree destination: duplicate_commits internal parents within connected(set) roots → destination parents, others → copies rebase (tree merge), new change ID new children → onto the copies' heads "Duplicated <id> as …" per copy; "Rebased N commits onto duplicated commits."; tx.finish(ui, "duplicate N commit(s)") (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Original commitsread onlyNever rewritten. Copies are built from them with rewrite_commit, but clear_rewrite_source() keeps jj from recording them as rewritten, so no descendant is rebased and no bookmark moves.commits
New commitscreatedOne per original. Each has a fresh change ID (generate_new_change_id), the original's author, and you as committer.commits
Treesreused or mergedReused as they are without a destination. Rebased with a 3-way merge with one, which can conflict.trees
New children (-A/-B)rewrittenRebased onto the copies' heads, along with their descendants.rebase
View.head_idswriteThe copies become new heads, unless they got children.view
Bookmarks, wc_commit_idsunchangedThey stay on the originals. Copies get no bookmarks, and @ doesn't move.view
OperationwriteOne operation, duplicate <n> commit(s).operations

5. Transaction and operation

  • Transaction: one, unless nothing matched or the root was named (source).
  • Operation description: duplicate <n> commit(s), with the literal (s). Upstream's undo snapshot shows duplicate 1 commit(s) (source).
  • Commits created or rewritten: one copy per original. With -A/-B, the destination's children and their descendants are rewritten too.
  • Working copy: snapshotted at the start. @ is never moved to a copy, so the files on disk change only if @ was a rebased new child.
  • jj undo hides the copies again and restores any rebased children.

6. Algorithm and invariants

Without a destination

duplicate_commits_onto_parents walks the targets oldest first. Each copy is rewrite_commit(original) with no rewrite source and a new change ID. Its parents are the original's, except that a parent which has already been copied is replaced by its copy. The tree isn't touched, so copying a stack reproduces the stack beside the original, with identical content at every step.

With a destination

duplicate_commits treats the targets as a connected set, like rebase -r:

  1. Compute each target's internal parents, its nearest ancestors within the set across any gaps. Targets with none are the roots (source).
  2. For -A/-B, compute the set's heads (source).
  3. Oldest first, copy each target: roots onto the destination parents, others onto their internal parents' copies. Use CommitRewriter::rebase, so each copy's tree is its own diff re-applied on the new base. Then clear the rewrite source and draw a new change ID (source).
  4. Rebase each new child. Any old parent that was a destination parent is replaced by the copies' heads, and the heads are added if missing (source).

Before any of this, jj warns, without stopping, if a copy would land among the descendants or ancestors of its own original (source).

Invariants: originals and everything referring to them are untouched. Every copy is a new change, so duplication never causes divergence. Copies keep their originals' internal graph shape. Without a destination, content is byte-identical.

7. Errors and edge cases

MessageWhenSource
No revisions to duplicate.Status: the revsets match nothing.duplicate.rs
Cannot duplicate the root commitThe root is among the targets, as with jj duplicate 'all()'.duplicate.rs
Warning: Duplicating commit <id> as a descendant of itself.A destination parent descends from a target.duplicate.rs
Warning: Duplicating commit <id> as an ancestor of itself.A new child is an ancestor of a target.duplicate.rs
Refusing to create a loop: … of the duplicated commits, No revisions found to use as parent, Commit … is immutableLocation errors for -A/-B, where an immutable commit would have to be rebased as a new child.cli_util.rs
Duplicated <id> as <summary>, Rebased <n> commits onto duplicated commits.Status output.duplicate.rs

8. Worked example

From upstream's test_duplicate. a and b sit on the root, and c (@) merges them. Plain jj duplicate copies @:

jj duplicate: before and after Before: a and b on the root and c at @ merging them. After jj duplicate, a new commit 71c64df584dc with change ID lylxulpl also merges a and b and has c's content and description. c itself, @, and its bookmark are unchanged. before after a b c @ 387b928721d9 a b c @ unchanged c (copy) lylxulpl 71c64df5 jj duplicate

Upstream's snapshot of the output:

Duplicated 387b928721d9 as lylxulpl 71c64df5 c

The copy keeps c's parents, tree and description (c), but it is a new change (lylxulpl), with no bookmark, and @ stays on the original. Earlier in the same test, jj duplicate a printed Duplicated 7d980be7a1d4 as kpqxywon 13eb8bd0 a, and jj undo named the operation duplicate 1 commit(s). Upstream's test_duplicate_destination covers -o, and the tests after it cover -A and -B.