jj / How jj converge works

How jj converge works

jj converge resolves a divergent change, one change ID with several visible commits, by writing one new commit that replaces them all. It works out each attribute (author, description, parents, tree) as a merge over the change's evolution history, and only asks you when that merge doesn't resolve.

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. The truncated evolution graph
  8. Merging an attribute against its dominator value
  9. Trees
  10. Prompts
  11. 7. Errors and edge cases
  12. 8. Worked example

In short: jj takes the search space (-r, or revsets.converge = mutable() & divergent()) and groups it by change ID. If several changes diverge it makes you pick one, or errors in --no-interactive mode. For the chosen change it builds the truncated evolution graph: the change's commits linked by predecessor edges, starting from where the divergence forked. For each attribute it finds the dominator value in that history and merges each divergent commit's value against it, accepting a trivial result. Parents are settled first, then the trees are rebased onto those parents and merged the same way. The solution commit gets the change ID, the divergent commits as predecessors, and replaces them. Descendants are rebased. The operation is converge <change> with N predecessors.

The command is cmd_converge, driving Converge::run. The algorithm lives in jj-lib's converge_change, whose module doc points at upstream's design note docs/design/jj-converge-command.md (source). How predecessors are recorded is on the commits page, and how trees are merged on the trees page.

1. Command-line surface

FlagTypeDefaultMeaning
-r, --revision, --revisions REVSETSrevsets, repeatablerevsets.convergeThe search space for divergent commits (source). Only commits inside it are replaced. Others with the same change ID stay divergent (help).
--no-interactivebooloffNever prompt. If anything can't be decided automatically, print what's missing and fail without changes (source).
-i, --interactivebool, hiddenoffA no-op partner for --no-interactive (source).

ConvergeArgs (with the full help text)

2. Configuration

KeyDefaultEffect
revsets.convergemutable() & divergent()The default search space (default, source).
ui.editor(built-in)Used if you choose to merge conflicting descriptions in an editor. The temp file is named .jj-converge-description (source).
revset-aliases."immutable_heads()"(built-in)The whole search space must be mutable (source).

3. End-to-end flow

jj converge: call flow cmd_converge resolves the search space, checks it is rewritable, starts a transaction and calls find_divergent_changes, which groups commits by change ID and keeps groups of two or more. None: prints that nothing diverges, with a hint. It reports the groups and chooses one, prompting if there are several. Converge::new builds the truncated evolution graph. run calls converge_change, which solves author, description and parents as value merges against a dominator value, and the tree once parents are known. Unsolved attributes are asked from the user, or the command fails in non-interactive mode. If the tree wasn't solved, converge_change runs again with the chosen attributes. apply_solution writes the solution commit, marks every divergent commit rewritten into it and rebases descendants, and tx.finish records "converge <change> with N predecessors". search space (-r or revsets.converge); check_rewritable_expr; start_transaction find_divergent_changes: group by change ID, keep groups with ≥ 2 commits; none → "No revisions were found …" + hint "Found N divergent change(s) in the specified revset:" … choose_change: one → it; several → prompt (or error with --no-interactive) TruncatedEvolutionGraph::new — walk predecessors of the divergent commits keep only commits with this change ID; edges predecessor → successor start node = the single initial commit, or a virtual root linking several author, description, parents merge = D + Σ(value(Xi) − D) D = dominator value over the history trivial resolution → Solved else Unsolved → prompt (or give up) tree (once parents are solved) rebase every history commit's tree onto the solution parents; dominator → base F F′ + Σ(Xi′ − F′), may contain conflicts labels: "converge base", "divergent commit" apply_solution: new_commit(parents, tree) with the change ID, description, author predecessors = all divergent commits; set_rewritten_commit(each → solution); rebase_descendants bookmarks on divergent commits move to the solution when the transaction finishes "Successfully converged change: …" [+ "Rebased N descendants"]; tx.finish("converge <change> with N predecessors")

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Commit predecessors (evolution)read, writeWalked with walk_predecessors to build the history (source). The solution records every divergent commit as a predecessor, so jj evolog shows them merging.commits
Operation logreadwalk_predecessors may walk back through operations to find old predecessors (see the note in source).operations
Treesread, writeEach history commit's tree is rebased onto the solution parents, then merged with MergedTree::merge. The result may be conflicted (source).trees
Rewrite recordswriteset_rewritten_commit(divergent → solution) for each divergent commit, then rebase_descendants.transactions
Index (ancestry)readremove_descendants uses roots() to drop divergent commits that descend from other divergent commits before choosing parents (source).index

5. Transaction and operation

  • Transaction: one, started before divergent changes are even looked up (source). The early exits (no divergence, abort) don't finish it, so they record nothing.
  • Operation description: converge <short change ID> with <N> predecessors, where N counts the change's divergent commits in the search space (source).
  • Commits created: exactly one solution commit. Descendants of the divergent commits are rebased onto it.
  • One change per run: if several changes diverge, a hint says how many remain (source).
  • jj undo restores the divergence. The help text suggests jj op show -p to review the result first.

6. Algorithm and invariants

The truncated evolution graph

TruncatedEvolutionGraph::new walks predecessors from the divergent commits. It ignores commits with other change IDs, and stops once every pending commit is accounted for and an initial commit (one with no same-change predecessors) has been reached. Edges point from predecessor to successor. If several initial commits are found, a virtual start node (the root commit ID) is added above them, so the graph is a flow graph with one start.

Merging an attribute against its dominator value

For author, description and parents, create_value_merge computes each history commit's value and finds the closest common dominator value of the divergent commits' values, in the graph of value transitions (find_dominator_value_with_value_cache). That value D is what the history agreed on before it forked. The merge is D + (v₁ − D) + (v₂ − D) + …. If it resolves trivially (with SameChange::Accept, so identical edits count once), the attribute is Solved. Otherwise it is Unsolved, carrying a base commit that produces D. When several commits produce D, the base is the one with the lowest change offset, then input order, then newest committer time, then commit ID (get_value_producer).

Parents are special. Divergent commits that descend from another divergent commit are left out, since using their parents would create a cycle once everything is rebased onto the solution (converge_parents).

Trees

The tree is only computed once parents are solved (source). converge_trees rebases every history commit's tree onto the solution parents: P + (X.tree − X.parents), or X.tree if X already has those parents. It finds the dominator of those rebased tree IDs (labels ignored, to find a closer common value), takes a commit F producing it, and returns F′ + (A′ − F′) + (B′ − F′) + …. The source comment at L459 gives the derivation.

Prompts

generic_solver accepts Solved values. For Unsolved ones it prompts, or returns nothing with --no-interactive. The change prompt lists the divergent change IDs and takes an index or a unique change ID prefix; EOF cancels it with an error (choose_change, parse_index_or_change_id_choice). The author and parent prompts list the distinct values and ask for an index (q aborts). The description prompt offers to open an editor on a conflicted description built from the divergent descriptions against the base commit's, or to keep the conflict markers as is (source, materialize_conflicted_description).

Invariants: exactly one change is converged per run. Only divergent commits in the search space are replaced. The solution keeps the change ID. Nothing is written unless author, description, parents and tree are all decided.

7. Errors and edge cases

MessageWhenSource
No revisions were found to be divergent with each other in revsets.converge: REVSET / … among the specified revisions.Nothing diverges in the search space (default or -r). Not an error. A hint follows: if some commit in the search space is divergent with commits outside it, it suggests jj converge -r 'change_id(ID)' for one such change; otherwise it explains that divergent revisions must share a change ID.converge.rs, test
Cannot automatically choose which change to convergeSeveral divergent changes with --no-interactive. The hint suggests a narrower revset.converge.rs
Could not determine which author/description/parents to use. then Could not converge changeAn attribute stayed unsolved, for example in non-interactive mode.converge.rs
Failed to converge treeThe second converge_change call still produced no tree.converge.rs
Aborting... nothing changed.You chose q at an author or parent prompt.converge.rs
Prompt canceled by EOFInput ended at the change prompt, which has no q choice.converge.rs
Commit … is immutableThe search space includes immutable commits.converge.rs
(conflicts in the solution)The tree merge didn't resolve. The help text warns about this.help

8. Worked example

From upstream's test_converge_simple. Change zsuskuln ("b2") was rebased from a onto c, but its old version is still visible through bookmark b1, with child d on top. So the change has two visible commits: 59a77004 on a and 2c2bd25d on c. The evolog is b2852eb2 → 59a77004 → 2c2bd25d.

jj converge on a rebased-but-still-visible change Before: root, a; on a are c and 59a77004 (b1, divergent) with d on top; on c is 2c2bd25d (b2, divergent). After jj converge: root, a, c, then the solution 6b1ce5bc with bookmarks b1 and b2, and d rebased on top as 696cf5e0. The solution's parent is c because the history's dominator parent value was a, and only one side moved away from it. before after jj converge root a c 2c2bd25d b2 · divergent 59a77004 b1 · divergent @ d root a c 6b1ce5bc b1 b2 · solution @ d rebased · 696cf5e0

Upstream's snapshot, which needed no prompts:

$ jj converge
Found 1 divergent change(s) in the specified revset:
- Change: zsuskulnrvyr with 2 commits:
    zsuskuln/0 2c2bd25d b2 | (divergent) b2
    zsuskuln/1 59a77004 b1 | (divergent) b2

Attempting to converge change zsuskulnrvyr...

Successfully converged change: created commit 6b1ce5bc4cbe.
Rebased 1 descendants
Working copy  (@) now at: znkkpsqq 696cf5e0 d | d
Parent commit (@-)      : zsuskuln 6b1ce5bc b1 b2 | b2
Added 1 files, modified 0 files, removed 0 files

Here 59a77004 is itself a predecessor of 2c2bd25d. For the parents, the history agreed on [a], and only one side moved to [c], so the merge [a] + ([a] − [a]) + ([c] − [a]) resolves to [c]. The descriptions are all "b2", and the tree follows the same pattern. Afterwards the evolog shows 6b1ce5bc with both old commits as predecessors (test).