Exploring jj rebase
shimun December 30, 2024 #jj #gitIntro
The switch from git to Jujutsu (jj) has been quite a walk so far, due to how jj reimagines some core git concepts. Getting used to the concept of everthing is a commit took a couple of days to fully get used to, just like getting used to change ids being mutable instead of being content address like git commits.
jj rebase
however seems to be a different beast, which isn't really covered in any tutorial I could get my hands eyes on. Thats why I'm going to try to condense my limited understanding here, as a reference to myself and anybody else hitting the same roadblock.
Theory
To understand jj rebase
it might be helpful to refresh our mind on what exactly an rebase is conceptually. When rebasing an branch, git will first locate the common anchestor(r) for both branches(k,p).
all further commands in this posts will be run aginst the following tree:
@ kkyzulkk [email protected] 2024-12-29 17:23:08 63aad537 │ added a typo Hello world │ ○ pmtwutvs [email protected] 2024-12-29 17:18:34 69ead55a │ │ added Hello Universe │ ○ vqtttnsv [email protected] 2024-12-29 17:18:07 78633b76 ├─╯ added Hello World ○ rwonlquo [email protected] 2024-12-29 17:17:32 eb178c44 │ init ◆ zzzzzzzz root() 00000000
Based on this common commit git will then calculate an diff for each commit. Which are then reapplied on the other branch.
jj has quite some documentation for `jj rebase`:
Move revisions to different parent(s)
There are three different ways of specifying which revisions to rebase:
`-b` to rebase a whole branch, `-s` to rebase a revision and its
descendants, and `-r` to rebase a single commit. If none of them is
specified, it defaults to `-b @`.
With `-s`, the command rebases the specified revision and its descendants
onto the destination. For example, `jj rebase -s M -d O` would transform
your history like this (letters followed by an apostrophe are post-rebase
versions):
O N'
| |
| N M'
| | |
| M O
| | => |
| | L | L
| |/ | |
| K | K
|/ |/
J J
With `-b`, the command rebases the whole "branch" containing the specified
revision. A "branch" is the set of commits that includes:
* the specified revision and ancestors that are not also ancestors of the
destination
* all descendants of those commits
In other words, `jj rebase -b X -d Y` rebases commits in the revset
`(Y..X)::` (which is equivalent to `jj rebase -s 'roots(Y..X)' -d Y` for a
single root). For example, either `jj rebase -b L -d O` or `jj rebase -b M
-d O` would transform your history like this (because `L` and `M` are on the
same "branch", relative to the destination):
O N'
| |
| N M'
| | |
| M | L'
| | => |/
| | L K'
| |/ |
| K O
|/ |
J J
With `-r`, the command rebases only the specified revisions onto the
destination. Any "hole" left behind will be filled by rebasing descendants
onto the specified revision's parent(s). For example, `jj rebase -r K -d M`
would transform your history like this:
M K'
| |
| L M
| | => |
| K | L'
|/ |/
J J
Note that you can create a merge commit by repeating the `-d` argument.
For example, if you realize that commit L actually depends on commit M in
order to work (in addition to its current parent K), you can run `jj rebase
-s L -d K -d M`:
M L'
| |\
| L M |
| | => | |
| K | K
|/ |/
J J
If a working-copy commit gets abandoned, it will be given a new, empty
commit. This is true in general; it is not specific to this command.
The first paragraph is quite striking since it reads move revisions which is an artifact of jj working on changes on not commits. While infinitly many changes can be based on the same commit, the reverse isn't true therefore changes would need to be either duplicated (and be assigned an new id) or moved.
Experimenting
So how do we move the change k to be the parent of v ? Lets go for the naive approach first, -d
stands for destination which should be v right? Lets give it a shot:
@ kkyzulkk [email protected] 2024-12-30 09:06:37 4e135fb0 conflict │ added a typo Hello world │ ○ pmtwutvs [email protected] 2024-12-29 17:18:34 69ead55a ├─╯ added Hello Universe ○ vqtttnsv [email protected] 2024-12-29 17:18:07 78633b76 │ added Hello World ○ rwonlquo [email protected] 2024-12-29 17:17:32 eb178c44 │ init ◆ zzzzzzzz root() 00000000
Not really as expected, our change k sits atop of v but is neither the anchestor or child of r
Time to consult the documentation again to figure out my this didn't go as intended.
With
-s
, the command rebases the specified revision and its descendants onto the destination.
Ah, now its' obvious, -s
just moves/appends the specifed changes to the head of the destination, which wasn't indented. But luckily the answer to the problem lies just one paragraph further:
With
-b
, the command rebases the whole "branch" containing the specified revision. A "branch" is the set of commits that includes:
- the specified revision and ancestors that are not also ancestors of the destination
- all descendants of those commits In other words,
jj rebase -b X -d Y
rebases commits in the revset(Y..X)::
(which is equivalent tojj rebase -s 'roots(Y..X)' -d Y
for a single root).
That sounds a lot more like our expectation, so lets try that instead (after an quick jj undo
):
jj rebase -b k -d v
@ kkyzulkk [email protected] 2024-12-30 09:27:07 d1915e8a conflict │ added a typo Hello world │ ○ pmtwutvs [email protected] 2024-12-29 17:18:34 69ead55a ├─╯ added Hello Universe ○ vqtttnsv [email protected] 2024-12-29 17:18:07 78633b76 │ added Hello World ○ rwonlquo [email protected] 2024-12-29 17:17:32 eb178c44 │ init ◆ zzzzzzzz root() 00000000
Looks like we just achieved the same thing as before, bummer. Which means we still rebased our change k onto v.
But wait a minute, if the goal was the opposite, flipping around the arguments should do the trick:
jj rebase -b v -d k
And there it is (don't mind the conflicts):
× pmtwutvs [email protected] 2024-12-30 09:39:55 257caf91 conflict │ added Hello Universe × vqtttnsv [email protected] 2024-12-30 09:39:55 31c9474c conflict │ added Hello World @ kkyzulkk [email protected] 2024-12-29 17:23:08 63aad537 │ added a typo Hello world ○ rwonlquo [email protected] 2024-12-29 17:17:32 eb178c44 │ init ◆ zzzzzzzz root() 00000000
When working with an real repo though it's important to keep in mind, that an remote bookmark/branch causes the local branch to be immutable, which means we'd have to pass --allow-immutable
to proceed. And also be careful not to mix up the order of -d
and -b
again otherwise we would modify our master
instead of our feature branch(unless we exclude branches prefixed with feat/
, fix/
etc. from the relevant revset).
Going further
Now that we've managed to perform an basic rebase, it's time to dig into the remainder of options:
-r
: works like-s
but rebase only the specifed revset, no descendants-A
: moves the commit into the history after the specified change, unlike-d
this does not create an new branch-B
: moves the commit into the history before the specified change
Lets try to make the change v and its descendants a descendant of k:
jj rebase -s v -A k
@ qvywkqko [email protected] 2024-12-30 12:28:29 3f6f40ed conflict │ added an readme × pmtwutvs [email protected] 2024-12-30 12:28:29 79424054 conflict │ added Hello Universe × vqtttnsv [email protected] 2024-12-30 12:28:29 f5a7a3a4 conflict │ added Hello World ○ kkyzulkk [email protected] 2024-12-29 17:23:08 63aad537 │ added a typo Hello world ○ rwonlquo [email protected] 2024-12-29 17:17:32 eb178c44 │ init ◆ zzzzzzzz root() 00000000
Beautiful, how about moving just p:
jj rebase -r p -A k
@ qvywkqko [email protected] 2024-12-30 12:30:22 0a629c72 │ added an readme ○ pmtwutvs [email protected] 2024-12-30 12:30:22 00cfbe29 │ added Hello Universe ○ kkyzulkk [email protected] 2024-12-29 17:23:08 63aad537 │ added a typo Hello world │ ○ vqtttnsv [email protected] 2024-12-29 17:18:07 78633b76 ├─╯ added Hello World ○ rwonlquo [email protected] 2024-12-29 17:17:32 eb178c44 │ init ◆ zzzzzzzz root() 00000000
Too easy :)
Conclusion
Rebasing isn't too complicated, in fact it is a lot simpler with jj although git's interactive rebase makes moving commits around more ergonomic.