jj / Repos, transactions and rewriting

Repos, transactions and rewriting

Everything a command changes goes through three types: an immutable ReadonlyRepo at one operation, a MutableRepo that collects changes on top of it, and the Transaction that turns those changes into a new operation. This page covers those types and the rewrite machinery that keeps descendants, bookmarks and working copies following a commit when it's rewritten.

On this page
  1. 1. The repo types
  2. 2. Transaction lifecycle
  3. 3. Recording rewrites: parent_mapping
  4. 4. Rebasing descendants
  5. How a single commit is rebased
  6. Rebase options

In short: rewriting a commit never edits it. It writes a new commit and records "old → new" in the MutableRepo's parent_mapping. Later, rebase_descendants uses that mapping to rebase every descendant, move bookmarks, and repoint working copies. That's why most commands only touch the commits they care about and never have to think about descendants.

1. The repo types

TypeHoldsRole
trait Repo store, op_store, index, view, submodule_store, plus change-ID lookups What revsets, templates and most helpers take, so they work on either repo kind.
RepoLoader settings and every store: commit Store, workspace, op, op-heads, index, submodule Loads repos. load_at(op) reads the operation's view and asks the index store for the index at that operation (finish_load).
ReadonlyRepo loader, operation, read-only index, lazily built change-ID index, view An immutable snapshot of the repo at one operation, shared as Arc<ReadonlyRepo>.
MutableRepo base repo, mutable index, view copy, commit_predecessors, parent_mapping Where all changes happen. Commits written through it are added to its index immediately.
Transaction a MutableRepo, parent operations, metadata, end time Turns the MutableRepo into a written, then published, operation.

2. Transaction lifecycle

Transaction lifecycle ReadonlyRepo::start_transaction creates a MutableRepo with a mutable copy of the index and view and wraps it in a Transaction. The command writes and rewrites commits, which records entries in parent_mapping and commit_predecessors. rebase_descendants consumes parent_mapping. Transaction::write calls MutableRepo::consume, which normalizes heads and yields the index, view and predecessors; it writes the view, the operation and the index, giving an UnpublishedOperation. publish updates the op heads and returns a new ReadonlyRepo. ReadonlyRepo start_transaction() MutableRepo new_commit · rewrite_commit · set_* rebase_descendants consumes parent_mapping Transaction::write consume → view, op, index publish() op heads → new ReadonlyRepo the next command loads the new operation
  1. ReadonlyRepo::start_transaction builds a MutableRepo from a mutable copy of the index and a clone of the view.
  2. The command makes changes. has_changes() is true once anything is recorded in commit_predecessors or parent_mapping, or the view differs from the base. If it's false, the CLI records no operation at all.
  3. The CLI calls rebase_descendants before writing (§4).
  4. Transaction::write calls MutableRepo::consume, which normalizes the view's heads and hands back the index, the view and the predecessor map. It then writes the view, the operation and the index, producing an UnpublishedOperation.
  5. publish() moves the op heads to it. See The operation log.

3. Recording rewrites: parent_mapping

parent_mapping: HashMap<CommitId, Rewrite> maps each replaced commit to a Rewrite:

VariantRecorded byChildrenBookmarksWorking copies on it
Rewritten(new)set_rewritten_commit (called by CommitBuilder::write for rewrites)Rebased onto newMoved to newMoved to new
Divergent(news)set_divergent_rewriteLeft in placeConflicted across all of newsMoved to the first of news
Abandoned(parents)record_abandoned_commit, record_abandoned_commit_with_parentsRebased onto parentsMoved to the parents, or deleted if delete_abandoned_bookmarksA new empty commit on top of the parents

recording methods · update_wc_commits

new_parents(old_ids) follows the mapping transitively, so if A became B and B was abandoned onto P, A's children end up on P. This is separate from commit_predecessors, which is never cleared and becomes the operation's record of what was rewritten from what. parent_mapping is consumed and cleared by rebase_descendants.

4. Rebasing descendants

rebase_descendants_with_options(immutable, options, progress) takes every key of parent_mapping as a root and calls transform_descendants_with_options with a callback that rebases any commit whose parents changed:

  1. find_descendants_for_rebase collects the roots' descendants, skipping ones already rewritten and anything in immutable. The CLI passes commits that were already immutable, so a jj git fetch that updates immutable history never rebases it.
  2. transform_commits visits them parents-first. For each one it computes new_parents(old parents) and hands a CommitRewriter to the callback.
  3. After the walk, update_rewritten_references moves bookmarks, updates working-copy commits per the table in §3, and fixes up the heads.
  4. parent_mapping is cleared.

transform_descendants is the same walk with a caller-supplied callback, used directly by commands such as jj fix. The callback can rebase, reparent() (new parents, same tree), abandon, or leave the commit alone. The set of commits to visit is fixed at the start.

How a single commit is rebased

CommitRewriter::rebase_with_empty_behavior computes the new tree as a three-way merge. If the new parents' trees equal the old ones, it keeps the old tree and skips the merge. Otherwise:

new_tree = merge(
    new_base,   // merge of the new parents' trees  — "(rebase destination)"
  − old_base,   // merge of the old parents' trees  — "(parents of rebased revision)"
  + old_tree    // the commit's own tree            — "(rebased revision)"
)

That's "apply this commit's diff onto its new parents", expressed as a Merge. If the sides disagree, the result is a conflicted tree, stored as a conflict rather than stopping the rebase. The labels in quotes become the conflict marker labels. See conflict_label.

Rebase options

OptionValuesEffect
EmptyBehaviorKeep (default), AbandonNewlyEmpty, AbandonAllEmptyWhether to abandon commits that are empty after the rebase. It only ever applies to commits with a single parent; the parent inherits the abandoned commit's children and bookmarks.
simplify_ancestor_mergeboolIf a merge would end up with one parent being an ancestor of another, drop the ancestor.
RewriteRefsOptions.delete_abandoned_bookmarksboolDelete bookmarks on abandoned commits instead of moving them to the parents.

RebaseOptions · rebase_commit_with_options