jj / How jj rebase works

How jj rebase works

jj rebase moves commits to new parents while keeping each commit's own changes. You choose what moves (-b, -s or -r) and where it goes (-o, -A or -B). jj-lib works out every commit's new parents up front, then rewrites them in one pass.

On this page
  1. 1. Command-line surface
  2. What to rebase (at most one; default -b @)
  3. Where to (exactly one of -o or -A/-B)
  4. Options
  5. 2. Configuration
  6. 3. End-to-end flow
  7. 4. Core architecture touchpoints
  8. 5. Transaction and operation
  9. 6. Algorithm and invariants
  10. Planning the move
  11. Computing every new parent list
  12. Applying it
  13. Duplicate divergent commits
  14. 7. Errors and edge cases
  15. 8. Worked example

In short: the command turns its flags into a MoveCommitsLocation: the target commits (an explicit set for -r, or roots plus all their descendants for -s/-b), the new parents, and any new children. compute_move_commits computes a new parent list for every commit that has to move. That includes descendants outside the set, which close the "hole" a -r rebase leaves. apply_move_commits then rebases each one in topological order with a 3-way tree merge, recording conflicts instead of stopping. Duplicate divergent commits are abandoned along the way. One operation, such as rebase commit <id> and descendants, records everything.

The command is cmd_rebase plus three planning functions, one per mode. The engine is in jj-lib's rewrite.rs, which jj duplicate, jj parallelize and the -A/-B modes of other commands also use. The help text draws a before/after graph for every mode (source). This page explains the machinery behind those graphs. How a single commit's tree is rebased, and how rewritten commits propagate, is on the transactions page.

1. Command-line surface

What to rebase (at most one; default -b @)

FlagTypeMeaning
-b, --branch REVSETSrevsets, repeatableThe whole "branch" relative to the destination: roots(destination..X) and all their descendants (source).
-s, --source REVSETSrevsets, repeatableThese commits and all their descendants. Each one becomes a direct child of the destination, even if some descend from others (source).
-r, --revision, --revisions REVSETSrevsets, repeatableExactly these commits, keeping the edges between them. Their descendants outside the set stay behind, reparented onto the moved commits' old parents. This is the only mode that may move a commit onto its own descendant (source).

Where to (exactly one of -o or -A/-B)

FlagTypeMeaning
-o, --onto, -d, --destination REVSETSrevsets, repeatableNew parents. Repeat it to make the moved roots merges. Existing children of the destination are untouched (source).
-A, --insert-after, --after REVSETSrevsets, repeatableNew parents, and the destination's existing children are moved onto the rebased commits (source).
-B, --insert-before, --before REVSETSrevsets, repeatableNew children: the commits go onto these commits' parents, and these commits are moved onto them. Can be combined with -A (source).

RebaseDestinationArgs, a required clap group

Options

FlagDefaultMeaning
--skip-emptiedfalseAbandon a target that becomes empty because of the rebase. Commits that were already empty, and merges, are kept. This becomes EmptyBehavior::AbandonNewlyEmpty (source).
--keep-divergentfalseDon't abandon divergent commits whose identical twin is already in the destination (source).
--simplify-parentsfalseDrop any new parent of a target that is an ancestor of another of its new parents (source).

RebaseArgs

2. Configuration

jj rebase reads no settings of its own. revset-aliases."immutable_heads()" decides what check_rewritable refuses: the moved commits, and with -A/-B the new children. user.name/user.email become the rewritten commits' committer. The working-copy settings on the working-copy page govern the snapshot and checkout. The bookmark rules are fixed in code: delete_abandoned_bookmarks is false, so a bookmark on an abandoned commit moves to its parent (source).

3. End-to-end flow

jj rebase: call flow cmd_rebase builds RebaseOptions from the flags and loads the workspace through the shared lifecycle. One of three planners runs: plan_rebase_revisions for -r, plan_rebase_source for -s, or plan_rebase_branch for -b, defaulting to @. Each resolves the commits, checks they are rewritable, calls compute_commit_location for -o, -A or -B, checks for self or descendant destinations, and returns a MoveCommitsLocation. If there are no targets it prints "No revisions to rebase." Otherwise a transaction starts, compute_move_commits computes every commit's new parents, find_duplicate_divergent_commits marks duplicates to abandon, and apply runs transform_commits, rebasing each commit with a 3-way tree merge. Statistics are printed and tx.finish records the operation. RebaseOptions from flags; shared lifecycle: load workspace, snapshot working copy (CLI page) -r: plan_rebase_revisions target = Commits(set) refuse "onto itself" (descendant allowed) -s: plan_rebase_source target = Roots(sources) refuse onto itself or onto a descendant -b (default @): plan_rebase_branch roots(dest..branch) target = Roots(those) each planner: check_rewritable, then compute_commit_location(-o | -A | -B) → MoveCommitsLocation { new_parent_ids, new_child_ids, target }; -A/-B also check the new children and refuse loops no targets? "No revisions to rebase." — no operation start_transaction; compute_move_commits target set and its roots, external parents, new children, descendants to visit, → new parents for every commit find_duplicate_divergent_commits unless --keep-divergent → record_to_abandon apply_move_commits transform_commits, parents first: abandon, skip if parents unchanged, or rebase_commit_with_options print_move_commits_stats; tx.finish(ui, "rebase commit <id> …"): write the operation, update bookmarks and @, check out the new working-copy tree (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Target commitsrewrittenNew parents and a merged tree. Each keeps its change ID, description and author. The committer becomes you, now.commits
Descendants and new childrenrewrittenRebased with EmptyBehavior::Keep and no parent simplification, regardless of the flags (source).transactions
Treesread, mergedEach rebased tree is new base + (old tree − old base), where a base is the merge of a commit's parents' trees. Conflicts are stored in the result.trees
IndexreadAncestry checks (is_ancestor, heads) for destination validation, --simplify-parents and new-children handling. Revset evaluation for connected(), descendants() and ranges.index (page queued)
Change-ID indexreadresolve_change_id finds other visible commits of each target's change, to detect duplicates.commits
View.head_idswriteOld versions are replaced by rebased ones.view
View.local_bookmarkswriteBookmarks follow their rebased commits. Bookmarks on abandoned commits move to the abandoned commit's parents.view
View.wc_commit_idswriteFollows a rebased @. If @ was abandoned, a new empty @ is created on its parents.view
OperationwriteOne operation, described below.operations
Working copy (TreeState)read, then maybe writeSnapshotted at the start. Checked out again if @'s tree changed.working-copy

5. Transaction and operation

  • Transaction: one, started after planning, and only if there is at least one target (source).
  • Operation description (tx_description): for -r, rebase commit <id> or rebase commit <id> and <n> more. For -s/-b, rebase commit <id> and descendants with one root, or rebase <n> commits and their descendants with several. IDs are full hex.
  • Commits created or rewritten: every target whose parents change, every descendant of a target or new child, and the new children themselves. Commits whose computed parents equal their current ones are skipped and counted as "already in place". No commit is created, except a replacement @ if @ is abandoned.
  • Working copy: snapshotted first. If @ was rebased, its new tree is checked out, and jj reports the added, modified and removed files.
  • jj undo restores the whole previous graph, including commits abandoned by --skip-emptied or the divergence check.

6. Algorithm and invariants

Planning the move

  • -r evaluates the union of revsets to a set, children first, and checks it is rewritable as a whole. It then refuses only a destination that is one of the targets (source).
  • -s resolves each revset in order. With -o, it refuses a destination that is the source itself or one of its descendants (check_rebase_destinations).
  • -b defaults to @. It computes roots(new_parents..branch): the oldest commits that are ancestors of the branch commits but not of the destination. Those become the roots, checked as for -s (source). A branch already on top of the destination therefore yields its first commit above the destination, which is already in place and gets skipped.
  • The destination comes from the shared compute_commit_location. -o gives parents only. -A X gives parents X and children children(X). -B Y gives the parents of Y as parents and Y as children. New children must be mutable, and must not create a cycle.

Computing every new parent list

compute_move_commits works in five steps:

  1. Target set and roots (source). For Roots, the target set is all descendants of the roots. For Commits, it's the given set, and the roots are the targets with no parent inside connected(set). Commits between two targets count as connecting them, so -r 'A|C' with B between them keeps C above A.
  2. External parents (source). For each target, oldest first, find its nearest ancestors outside the set. A commit left behind that had a target as a parent gets these instead, which is how the hole closes.
  3. Self-referencing destinations (source). A new parent inside the set is replaced by its external parents, so jj rebase -r A -B A works. A new child inside the set is replaced by its nearest descendants outside the set.
  4. New children's parents (source). Each new child keeps its other parents, except that any parent which is one of the new parents is replaced by the heads of the target set. The target heads are then added if they aren't present, which inserts the targets between the two.
  5. Everything to visit (source). These are the descendants of the roots and the new children (find_descendants_for_rebase). A root gets the new parents. Another target keeps its parents inside the set and drops outside parents that descend from a new child. A commit outside the set has any target parents replaced by external parents. Anything else keeps its parents.

Applying it

apply_move_commits hands the precomputed map to transform_commits, which visits parents before children. Each visited commit is handled in one of three ways. A commit marked by the divergence check is abandoned. A commit whose parents didn't change is skipped. Otherwise it goes to rebase_commit_with_options, using the command's options for targets and keep-everything options for the rest. That function:

  1. with --simplify-parents, keeps only the new parents that are heads among themselves (source);
  2. merges trees (rebase_with_empty_behavior). If the parents' trees are unchanged, the old tree is kept with no merge. Otherwise the new tree is merge(new base, old base, old tree), labelled (rebase destination), (parents of rebased revision) and (rebased revision) for conflict markers;
  3. applies the empty rule, single-parent results only. --skip-emptied abandons the commit if the new tree equals the parent's and the commit wasn't empty before (EmptyBehavior).

Duplicate divergent commits

find_duplicate_divergent_commits looks, for each target, for other visible commits with the same change ID that would become new ancestors, meaning those in roots..new_parents. For each such twin, it rebases the target onto the twin's parents. If that gives exactly the twin's tree, the target is a duplicate. It is abandoned during the rebase, and its descendants are rebased onto the parents it would have had. This cleans up the common case of rebasing a local commit onto an upstream that already contains the same change.

Invariants: every commit's own diff is preserved, with conflicts recorded rather than failing. Every parent list is computed before anything is written, so each commit is rewritten at most once. Change IDs never change. Empty and merge commits are never abandoned unless asked, and merges never are. Descendants outside the target set never lose content; they're only reparented around a hole.

7. Errors and edge cases

MessageWhenSource
No revisions to rebase.Status message (not an error) when the target set is empty, for example -r 'none()'. No operation is recorded.rebase.rs
Cannot rebase <id> onto itself-o names a commit that is being moved (all modes).rebase.rs, rebase.rs
Cannot rebase <id> onto descendant <id>-s or -b with -o pointing into the moved subtree. Use -r instead.rebase.rs
Commit … is immutable / The root commit … is immutableA target (or -b root), or an -A/-B new child, is immutable.cli_util.rs
Refusing to create a loop: commit … would be both an ancestor and a descendant of the rebased commits-A/-B where a new child is an ancestor of a new parent.cli_util.rs
No revisions found to use as parentThe destination resolves to no parents.cli_util.rs
Abandoned <n> divergent commits that were already present in the destination: followed by their summariesStatus message: duplicates were dropped.rebase.rs
Skipped rebase of …, Rebased … commits to destination., Rebased … descendant commits., Abandoned … newly emptied commits.Status statistics after the move.rebase.rs
clap: a required argument was not provided; -r/-s/-b conflict; -o with -A/-BNo destination given, two "what" modes given, or -o mixed with -A/-B.rebase.rs

8. Worked example

From upstream's test_rebase_single_revision. b and c both sit on a, d merges them, and e (@) is on d. Each commit adds a file named after itself. Run jj rebase -r c -o b:

jj rebase -r c -o b: before and after Before: a on the root; b and c both on a; d merging b and c; e on d as @. After rebasing only c onto b: c is now a child of b. d, which had c as a parent, gets c's external parent a in its place, so d merges b and a. e is rebased onto the new d and no longer contains file c. One target and two descendants were rebased. before after a b a c a d b, c e @ d a b a c b — target d b, a — descendant e @ d — descendant jj rebase -r c -o b

Upstream's snapshot of the output:

Rebased 1 commits to destination.
Rebased 2 descendant commits.
Working copy  (@) now at: znkkpsqq 2baedee4 e | e
Parent commit (@-)      : vruxwmqv 45142a83 d | d
Added 0 files, modified 0 files, removed 1 files

c was the only target, so it is the root and gets the new parent b. d lies outside the set, but had c as a parent, so it gets c's external parent a instead and becomes a merge of b and a. That parent list isn't simplified, because descendants never are. e follows d. c's file is no longer below @, hence "removed 1 files". The operation is rebase commit <c's id>. In the same test, jj rebase -r d -o a moves the merge itself, and e becomes a merge of b and c.