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
| Type | Holds | Role |
|---|---|---|
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
-
ReadonlyRepo::start_transactionbuilds aMutableRepofrom a mutable copy of the index and a clone of the view. -
The command makes changes.
has_changes()is true once anything is recorded incommit_predecessorsorparent_mapping, or the view differs from the base. If it's false, the CLI records no operation at all. - The CLI calls
rebase_descendantsbefore writing (§4). -
Transaction::writecallsMutableRepo::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 anUnpublishedOperation. 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:
| Variant | Recorded by | Children | Bookmarks | Working copies on it |
|---|---|---|---|---|
Rewritten(new) | set_rewritten_commit (called by CommitBuilder::write for rewrites) | Rebased onto new | Moved to new | Moved to new |
Divergent(news) | set_divergent_rewrite | Left in place | Conflicted across all of news | Moved to the first of news |
Abandoned(parents) | record_abandoned_commit, record_abandoned_commit_with_parents | Rebased onto parents | Moved to the parents, or deleted if delete_abandoned_bookmarks | A 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:
-
find_descendants_for_rebasecollects the roots' descendants, skipping ones already rewritten and anything inimmutable. The CLI passes commits that were already immutable, so ajj git fetchthat updates immutable history never rebases it. -
transform_commitsvisits them parents-first. For each one it computesnew_parents(old parents)and hands aCommitRewriterto the callback. - After the walk,
update_rewritten_referencesmoves bookmarks, updates working-copy commits per the table in §3, and fixes up the heads. parent_mappingis 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
| Option | Values | Effect |
|---|---|---|
EmptyBehavior | Keep (default), AbandonNewlyEmpty, AbandonAllEmpty | Whether 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_merge | bool | If a merge would end up with one parent being an ancestor of another, drop the ancestor. |
RewriteRefsOptions.delete_abandoned_bookmarks | bool | Delete bookmarks on abandoned commits instead of moving them to the parents. |