Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Untangling divergent changes

We’ve leaned hard on one promise throughout this book: a change ID names one change, and it survives every rewrite. There is a situation where that promise bends. It’s called a divergent change, and it’s worth meeting in a repository we don’t care about before it happens in one we do, because the first time you see it, it looks alarming.

Here’s the short version: a change is divergent when two visible commits claim the same change ID. jj marks them (divergent) in the log, and refuses to let the bare change ID pick between them.

Making one on purpose

Let’s set up a little repository with a two-change stack and a bookmark on top:

$ jj log
@  znquvmmz steve@steveklabnik.com 2024-03-20 11:02:31 feature 6f590d66
│  add an evaluator
○  zluwzunl steve@steveklabnik.com 2024-03-20 11:02:14 511b69ca
│  add a parser
◆  zzzzzzzz root() 00000000

Now we’ll amend the parser change — jj edit zluwzunl, fix a file, and come back up with jj new znquvmmz. We know what happens: both changes get new commit IDs, the evaluator gets rebased, and the old versions go into hiding. jj evolog can show them to us:

$ jj evolog -r znquvmmz --no-graph
znquvmmz steve@steveklabnik.com 2024-03-20 11:03:44 feature cfffda13
add an evaluator
-- operation 7a0508060358 snapshot working copy
znquvmmz/1 steve@steveklabnik.com 2024-03-20 11:02:31 6f590d66 (hidden)
add an evaluator
-- operation a18d13686e71 describe commit 8c764b87308646d95ab0884ffb35e5d3ee20dbfd

So far, so normal. Now the mistake. A while later, we’re rooting around in the evolog for something we lost, we find that old commit 6f590d66, and — maybe thinking it’s the current version — we point our bookmark at it:

$ jj bookmark set feature -r 6f590d66 --allow-backwards
Moved 1 bookmarks to znquvmmz/1 6f590d66 feature* | (divergent) add an evaluator

There’s the word already. A bookmark keeps a commit visible, and so it keeps that commit’s ancestors visible too. We’ve just resurrected the entire old version of the stack:

$ jj log
@  tkvuwxwx steve@steveklabnik.com 2024-03-20 11:03:44 c5324b95
│  (empty) (no description set)
○  znquvmmz/0 steve@steveklabnik.com 2024-03-20 11:03:44 cfffda13 (divergent)
│  add an evaluator
○  zluwzunl/0 steve@steveklabnik.com 2024-03-20 11:03:44 c2e9dfd1 (divergent)
│  add a parser
│ ○  znquvmmz/1 steve@steveklabnik.com 2024-03-20 11:02:31 feature 6f590d66 (divergent)
│ │  add an evaluator
│ ○  zluwzunl/1 steve@steveklabnik.com 2024-03-20 11:02:14 511b69ca (divergent)
├─╯  add a parser
◆  zzzzzzzz root() 00000000

Both versions of both changes, all four marked (divergent). Note that a whole stack went divergent from one bookmark move. When people hit this in real life, it’s rarely one commit — it’s twenty, and it looks like the repository exploded. It didn’t. It’s the same two lines of history you can see right here, just longer.

Moving a bookmark isn’t the only way in. Fetching can do it too, if the remote has commits descended from a version you’ve since rewritten — a common one is someone (or CI) building on your branch right before you force-push over it. Two machines, or two workspaces, rewriting the same change also gets you here. However you arrive, the shape is the same: an old line and a new line, both visible.

Reading the display

Those /0 and /1 suffixes are how jj names the versions of a change. You’ve seen them in jj evolog output already, counting back through hidden predecessors. Now that two versions are visible, the suffixes are how we tell them apart, and the bare change ID stops working:

$ jj show znquvmmz
Error: Change ID `znquvmmz` is divergent
Hint: Use change offset to select single revision: znquvmmz/0, znquvmmz/1
Hint: Use `change_id(znquvmmz)` to select all revisions
Hint: To abandon unneeded revisions, run `jj abandon <commit_id>`

This error is usually the moment people discover divergence exists, so it’s nice that the hints tell you everything: pick a side with an offset, or take all sides with change_id().

One caution about the offsets: they’re assigned by sorting the visible commits, and they get reassigned as commits appear and disappear. For looking around, znquvmmz/1 is fine. For a multi-step cleanup, use commit IDs — 6f590d66 means the same commit tomorrow, and /1 may not.

jj log -r 'divergent()' shows every divergent commit in the repository, which is the quickest way to see the full extent of the problem.

Cleaning it up

Our repository has a new line we want and an old line we don’t. The plan: make sure nothing we care about is attached to the old line, then abandon the old line, all of it.

First, the bookmark. It’s sitting on the old tip, which is both why the old line is visible and not where we want it. Let’s put it back on the current version:

$ jj bookmark set feature -r cfffda13 --allow-backwards
Moved 1 bookmarks to znquvmmz/0 cfffda13 feature* | (divergent) add an evaluator

Now let’s check the old line is really abandoned-safe: no bookmarks left on it, and nothing built on top of its tip. We use commit IDs for the range, since the change IDs are exactly what’s ambiguous right now:

$ jj log -r '511b69ca::6f590d66 & (bookmarks() | remote_bookmarks())'
$ jj log -r 'children(6f590d66)'

Both empty. If they weren’t — if a bookmark or some new work were attached to the old line — we’d stop here and move or rebase those first.

Now we can abandon the old line. This is the same inclusive-range trick from the revsets chapter, from the old line’s first commit through its tip:

$ jj abandon '511b69ca::6f590d66'
Abandoned 2 commits:
  znquvmmz/1 6f590d66 (divergent) add an evaluator
  zluwzunl/1 511b69ca (divergent) add a parser

The range matters. If we’d abandoned only the tip, 511b69ca would have become the old line’s new head — still visible, still divergent, and we’d be playing whack-a-mole down the whole stack. Abandon the line, not the commit.

And we’re back to normal:

$ jj log -r 'divergent()'
$ jj log
@  tkvuwxwx steve@steveklabnik.com 2024-03-20 11:03:44 c5324b95
│  (empty) (no description set)
○  znquvmmz steve@steveklabnik.com 2024-03-20 11:03:44 feature cfffda13
│  add an evaluator
○  zluwzunl steve@steveklabnik.com 2024-03-20 11:03:44 c2e9dfd1
│  add a parser
◆  zzzzzzzz root() 00000000

No offsets, no (divergent), one version of each change.

Two more things worth knowing. If the operation that caused the divergence was the last thing you did, jj undo fixes it in one step, as usual. And nothing here was ever dangerous: a divergent change is two visible commits, both intact, both recoverable. The only real hazard is absent-mindedly continuing to work on top of the side you meant to throw away — which is why it’s worth cleaning up promptly rather than living with the offsets.

When both sides have work: jj converge

Here the old line was a duplicate we wanted gone, so jj abandon was the tool. The other case is two versions that each carry real, different work — two machines that both edited the same change, say — where you want to keep both sets of edits rather than throw one away. jj 0.45 added jj converge for that:

$ jj converge -r 'divergent()'
Found 1 divergent change(s) in the specified revset:
- Change: znquvmmzlxqr with 2 commits:
    znquvmmz/0 cfffda13 (divergent) add an evaluator
    znquvmmz/1 6f590d66 feature | (divergent) add an evaluator

Attempting to converge change znquvmmzlxqr...

Successfully converged change: created commit 41e0c3d8b2ef.

It groups the divergent commits by change ID and tries to replace each group with a single commit, then rebases descendants and moves bookmarks to follow. The replacement is built from heuristics, and where they can’t decide it prompts you — to merge two descriptions, to pick parents, and, rarely, to choose an author. --no-interactive prints a warning and stops instead, which is what a script wants. As always, jj undo reverses the whole thing if the result isn’t what you hoped, and jj op show -p shows exactly what it did.

The official guide to divergent changes covers a few rarer ways in and out, if you’d like more.