A Better Merge Workflow with Jujutsu

Introduction

Since reading Chris Krycho’s essay introduction to Jujutsu, I’ve been excited about the possibilities of a more modern, user-friendly Version Control System (VCS) tool. For those of you who aren’t familiar, Jujutsu (binary name: jj) is a new VCS which is compatible with existing Git repositories. I’ve been using Jujutsu as my daily Git driver for a while now. Though it’s still experimental software—with its fair share of bugs and unimplemented features, it has made my day-to-day interactions with Git repositories a much more pleasant experience.

There’s a really cool workflow that Austin Seipp shared on Jujutsu’s Discord, which I’m beginning to use everywhere, that I thought was worth writing more about. He calls it The Austin™ Mega Merge Strategy®, but me—I’m just going to call it for what it is: a Better Workflow for Manipulating Merge Commits in Jujutsu1. This workflow makes it easy to simultaneously activate multiple branches in the same working directory.

Before I go through the workflow, let’s take a look at some of the basics of Jujutsu.

A quick primer on Jujutsu

There’s no better way to demonstrate a VCS than by using it on its own repository. Jujutsu is compatible with Git repositories, and its own repository is a Git repository as well, hosted on GitHub.

Here’s the terminal output of jj log, which displays the graph of commits in the repository:

❯ jj log -r ::@
@  ytruvzpy benjamin@dev.ofcr.se 11 seconds ago e1b74dba
(empty) (no description set)
oqtzskyx martinvonz@google.com 1 day ago v0.16.0 HEAD@git 2dcdc7fb
│  release: release version 0.16.0
kyxszyrv 49699333+dependabot[bot]@users.noreply.github.com 1 day ago 6826be4a
│  cargo: bump the cargo-dependencies group with 2 updates
rxqmmmry yuya@tcha.org 2 days ago 363b5084
│  cli: ditch Deref, implement AsRef and Display for RevisionArg instead
zynytznv yuya@tcha.org 2 days ago c596d457
│  cli: migrate singular parse/resolve revset argument to RevisionArg
wnkplmms yuya@tcha.org 2 days ago 311bdbf5
│  cli: use RevisionArg type in "resolve -r", "bench", and example command
usyxoklz yuya@tcha.org 2 days ago ae91adba
│  cli: preserve RevisionArg type as much as possible
prqkmmqn yuya@tcha.org 2 days ago 426ee1c1
│  cli: abuse Cow to declare RevisionArg("@") constant
zrpqktts dev@noahmayr.com 2 days ago 88a4a828
│  cli: add better error message when immutable_heads() cannot be resolved
nopsqtrw dev@noahmayr.com 2 days ago b7998488
│  cli: only use default log revset when neither path nor revset is provided
rrtlsuyn ilyagr@users.noreply.github.com 2 days ago 670e6ac6
│  cmd `squash`: alias `--to` for the `--into` flag

There’s a couple of things to note, which differ from Git:

  1. Jujutsu has separate notions of changes and commits. Changes are identified by the alphabetical IDs on the left of the log, whilst commits are identified by the hexadecimal IDs on the right. When using Jujutsu’s Git backend, commits are just Git commits (the ID is the commit SHA), whereas a change is just a constant ID with an associated commit2.

    Change IDs address one of the pain points of Git: what we call “amending a commit” actually creates a brand new commit object with a different ID. After amending a commit, you’d need to check the status message or go back to the commit log to get the new commit ID to address the commit again.

    Using Jujutsu, “amending a commit” also produces a new commit object, as in Git, but the new commit has the same change ID as the original. The same ID always represent the same change, and points to the latest version of the commit, no matter how much you amend the commit history. This is really useful if you’re doing a lot of such operations.

    (They’re also really helpful in allowing you to understand how your change has evolved over time, although we won’t be going through that much in this article.)

  2. Jujutsu has a language called revsets (similar to Mercurial’s revsets), which allows you to select commits based on given properties. jj log -r [REVSET] only displays the commits in the log which the revset evaluates to. jj log -r ::@ shows all ancestors of the current working copy commit (denoted by @), and is the equivalent to git log. Revsets allow for much more succinct and expressive filters than possible in Git.

  3. Jujutsu removes the concept of the index by using working copy commits. Changes made in the repository always update the working copy commit, and do not need to be separately staged and committed. Once you’re done working on a change, jj commit will update the commit description and create a new empty change3, or you can use jj commit -i to interactively select the changes you want and split the working copy commit into two.

Creating a new merge commit

In the repository, I’ve been working on a few distinct features, some of which I’ve already pushed to various branches in my fork of the repository. Let’s take a look at the commits that I’m interested in—commits for which I’m the author, and have been pushed to a remote branch:

❯ jj log -r "mine() & remote_branches()"
nvympzuk benjamin@dev.ofcr.se 1 day ago push-nvympzukzzqo ebac1982
│  rebase: refactor to `MutRepo::set_extracted_commit` API
~
 
wtmqulxn benjamin@dev.ofcr.se 2 days ago push-uqxvnturzsuu 932e59d2
│  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
~
 
qklyrnvv benjamin@dev.ofcr.se 2 days ago push-qklyrnvvuksv 579ecb73
│  cli: print conflicted paths whenever the working copy is changed
~
 
zozvwmow benjamin@dev.ofcr.se 3 days ago ssh-openssh ea93486e
│  git: update error message for SSH error to stop referencing libssh2
~
 
xstwkkpp benjamin@dev.ofcr.se 1 month ago push-syolynqvlwsl 4d3bb253
│  git: Add revset alias for `trunk()` on `git clone`
~

I’d like to create a new merge commit which includes the changes from zoz and qkl. With Jujutsu, you can use jj new to start working on a new change, and specify any number of parent changes:

❯ jj new zoz qkl
Working copy now at: orllnptq 5ea75c06 (empty) (no description set)
Parent commit      : zozvwmow ea93486e ssh-openssh | git: update error message for SSH error to stop referencing libssh2
Parent commit      : qklyrnvv 579ecb73 push-qklyrnvvuksv | cli: print conflicted paths whenever the working copy is changed
Added 0 files, modified 14 files, removed 0 files

This creates a new merge commit with the change ID of orl, with the 2 parents specified. Note that you can specify as many parents as you want, and Jujutsu can still merge them. (I’m only specifying 2 here, so I can add more later manually.) Here’s what the commit graph looks like at this point:

❯ jj log
@    orllnptq benjamin@dev.ofcr.se 34 seconds ago 5ea75c06
├─╮  (empty) (no description set)
│ ◉  qklyrnvv benjamin@dev.ofcr.se 2 days ago push-qklyrnvvuksv 579ecb73
│ │  cli: print conflicted paths whenever the working copy is changed
│ ~

zozvwmow benjamin@dev.ofcr.se 3 days ago ssh-openssh HEAD@git ea93486e
│  git: update error message for SSH error to stop referencing libssh2
yowkkkqn benjamin@dev.ofcr.se 3 days ago 6222cb24
│  git: use prerelease version of `git2` with OpenSSH support
~

A new merge workflow?

Merge commits, you might be wondering. Is that a new workflow? Can’t you just use Git for this?

Merge commits definitely aren’t anything new—nearly every modern VCS tool has merge commits. However, Jujutsu’s support for manipulating the commit graph is miles ahead of Git’s. With Jujutsu, you can merge commits without fear of modifying your repository to an unrecoverable state. Jujutsu’s first-class conflicts and jj undo makes it safe to merge different branches, play around with different configurations of your code, and then restore your original changes.

Whether you find this article useful likely depends on how you’re using your VCS right now. If you’re just building a linear stack of commits, then this is probably not going to be very helpful. However, if you use separate branches to work on different features and group commits together for code review, then you might find this useful. (If you’ve read Jackson Gabbard’s article on Stacked Diffs vs Pull Requests, I like to think that this workflow allows you to enjoy the benefits of a Stacked Diff-like workflow of working on a single branch, but still allows you to work with code forges like GitHub which expect a Pull Request-style workflow.)

The gist of this workflow is basically: merge all or as many of your branches/commits together as you need, and keep that combined merge commit in your working directory.

Why is this useful? Some good usecases include:

Modifying existing merge commits is difficult using Git, but is much simpler with Jujutsu. Let’s go through a few examples.

Adding a new parent from an existing commit

Here’s how to add another parent to the merge commit:

 jj rebase -s orl -d "all:orl-" -d wtm
Rebased 1 commits
Working copy now at: orllnptq dd20e255 (empty) (no description set)
Parent commit      : qklyrnvv 579ecb73 push-qklyrnvvuksv | cli: print conflicted paths whenever the working copy is changed
Parent commit      : zozvwmow ea93486e ssh-openssh | git: update error message for SSH error to stop referencing libssh2
Parent commit      : wtmqulxn 932e59d2 push-uqxvnturzsuu | rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
Added 0 files, modified 3 files, removed 0 files

This command rebases the commit with change ID orl and all its descendants on top of all the given destinations. The given destinations here were all:orl-, which means all of orl’s existing parents, as well as the new destination of wtm. We’ve now got a new merge commit with the 2 original parents and the new one:

❯ jj log
@      orllnptq benjamin@dev.ofcr.se 20 seconds ago dd20e255
├─┬─╮  (empty) (no description set)
│ │ ◉  wtmqulxn benjamin@dev.ofcr.se 2 days ago push-uqxvnturzsuu 932e59d2
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ │ ◉  uqxvntur benjamin@dev.ofcr.se 2 days ago 2ac431c5
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ │ ◉  nkzsqppm benjamin@dev.ofcr.se 2 days ago ecf4a6e8
│ │ │  rebase: extract out some functions from `rebase_revision`
│ │ ~
│ │
│ ◉  zozvwmow benjamin@dev.ofcr.se 3 days ago ssh-openssh ea93486e
│ │  git: update error message for SSH error to stop referencing libssh2
│ ◉  yowkkkqn benjamin@dev.ofcr.se 3 days ago 6222cb24
│ │  git: use prerelease version of `git2` with OpenSSH support
│ ~

qklyrnvv benjamin@dev.ofcr.se 2 days ago push-qklyrnvvuksv HEAD@git 579ecb73
│  cli: print conflicted paths whenever the working copy is changed
~

If you’ve got any other commits on top of the one you specified for -s, Jujutsu also correctly rebases all of the original commit’s descendants on top of the new commit, so you don’t have to worry about those commits going out of sync.

Rebasing all parents

In this case, I realized that all my feature branches were outdated, and I’d like to rebase them on top of main. Here’s the command to do that:

 jj rebase -s 'all:roots(main..@)' -d main
Rebased 9 commits
Working copy now at: orllnptq 6e4f5799 (empty) (no description set)
Parent commit      : qklyrnvv 28af9083 push-qklyrnvvuksv* | cli: print conflicted paths whenever the working copy is changed
Parent commit      : zozvwmow c6c73906 ssh-openssh* | git: update error message for SSH error to stop referencing libssh2
Parent commit      : wtmqulxn 8673733e push-uqxvnturzsuu* | rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
Added 0 files, modified 3 files, removed 0 files

The syntax is a bit of a doozy, but can be better understood by breaking it down part-by-part:

  1. main..@: This finds all ancestors of @, the working copy commit, which are not ancestors of main. (By coincidence, my default configured revset for jj log shows exactly all the commits in the main..@ set.)
  2. roots(main..@): This gets the roots of commits in main..@ set, which are commits that do not have any ancestors within the set. This evaluates to the first commit of each arm of the merge commit in the log above (qkl, yow, and zoz).
  3. all:roots(main..@): The all prefix is required since -s expects a single commit by default, but roots(main..@) evaluates to multiple commits.

Each of these 3 commits are rebased on top of the destination, main, and have their descendants automatically rebased as well. This results in a subgraph where the root is main, and the leaf is the merge commit with its 3 parents:

❯ jj log
@      orllnptq benjamin@dev.ofcr.se 1 minute ago 6e4f5799
├─┬─╮  (empty) (no description set)
│ │ ◉  wtmqulxn benjamin@dev.ofcr.se 1 minute ago push-uqxvnturzsuu* 8673733e
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ │ ◉  uqxvntur benjamin@dev.ofcr.se 1 minute ago dd7454a2
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ │ ◉  nkzsqppm benjamin@dev.ofcr.se 1 minute ago 0a949714
│ │ │  rebase: extract out some functions from `rebase_revision`
│ ◉ │  zozvwmow benjamin@dev.ofcr.se 1 minute ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ ◉ │  yowkkkqn benjamin@dev.ofcr.se 1 minute ago ffec92c9
│ ├─╯  git: use prerelease version of `git2` with OpenSSH support
◉ │  qklyrnvv benjamin@dev.ofcr.se 1 minute ago push-qklyrnvvuksv* HEAD@git 28af9083
├─╯  cli: print conflicted paths whenever the working copy is changed
oqtzskyx martinvonz@google.com 1 day ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

Here, we’ve automatically rebased all the changes we’re interested in with just a single command! 😲

Adding a new parent from new changes

Whilst testing out the features from these different changes, you might want to work on a new change. Instead of having to check out a new branch as you would in Git, you can just work on the new change on top of this merge commit:

❯ jj new
Working copy now at: rwqywnzl 461d45c8 (empty) (no description set)
Parent commit      : orllnptq 6e4f5799 (empty) (no description set)
 
 nvim
 
 jj commit -m "new: avoid manual `unwrap()` call"
Working copy now at: ovypxnus e0c160c9 (empty) (no description set)
Parent commit      : rwqywnzl 919fae76 new: avoid manual `unwrap()` call
 
 jj show rwq
Commit ID: 919fae76dccba57d1df3df3125f4d4eac6676ce9
Change ID: rwqywnzlzmnoqrqkosupxwtyrxumymxs
Author: Benjamin Tan <benjamin@dev.ofcr.se> (23 hours ago)
Committer: Benjamin Tan <benjamin@dev.ofcr.se> (23 hours ago)
 
    new: avoid manual `unwrap()` call
 
diff --git a/cli/src/commands/new.rs b/cli/src/commands/new.rs
index eeeb50aee6...e0defba129 100644
--- a/cli/src/commands/new.rs
+++ b/cli/src/commands/new.rs
@@ -193,7 +193,7 @@
             writeln!(formatter)?;
         }
     } else {
-        tx.edit(&new_commit).unwrap();
+        tx.edit(&new_commit)?;
         // The description of the new commit will be printed by tx.finish()
     }
     if num_rebased > 0 {

Here’s the updated commit graph, with the new commit (change ID rwq) as a child of the merge commit:

❯ jj log
@  ovypxnus benjamin@dev.ofcr.se 1 minute ago e0c160c9
(empty) (no description set)
rwqywnzl benjamin@dev.ofcr.se 1 minute ago HEAD@git 919fae76
│  new: avoid manual `unwrap()` call
orllnptq benjamin@dev.ofcr.se 15 minutes ago 6e4f5799
├─┬─╮  (empty) (no description set)
│ │ ◉  wtmqulxn benjamin@dev.ofcr.se 15 minutes ago push-uqxvnturzsuu* 8673733e
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ │ ◉  uqxvntur benjamin@dev.ofcr.se 15 minutes ago dd7454a2
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ │ ◉  nkzsqppm benjamin@dev.ofcr.se 15 minutes ago 0a949714
│ │ │  rebase: extract out some functions from `rebase_revision`
│ ◉ │  zozvwmow benjamin@dev.ofcr.se 15 minutes ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ ◉ │  yowkkkqn benjamin@dev.ofcr.se 15 minutes ago ffec92c9
│ ├─╯  git: use prerelease version of `git2` with OpenSSH support
◉ │  qklyrnvv benjamin@dev.ofcr.se 15 minutes ago push-qklyrnvvuksv* 28af9083
├─╯  cli: print conflicted paths whenever the working copy is changed
oqtzskyx martinvonz@google.com 1 day ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

Although this change was made on top of the merge commit, you typically wouldn’t want to leave it there for long. You’d likely want to rebase it to a better location (not on top of the mega merge commit), before sending the change up for code review. For example, you can first rebase the new change onto main:

 jj rebase -r rwq -d main
Also rebased 1 descendant commits onto parent of rebased commit
Working copy now at: ovypxnus 68bacc1f (empty) (no description set)
Parent commit      : orllnptq 6e4f5799 (empty) (no description set)
Added 0 files, modified 1 files, removed 0 files

The -r option rebases only the given revision on top of the destination; it rebases all of its descendants on top of its parents. Effectively, this is similar to moving a commit to another location in the graph.

After rebasing onto main, you can then add rwq as a new parent of the merge commit to keep the change applied to your working directory:

 jj rebase -s orl -d "all:orl-" -d rwq
Rebased 2 commits
Working copy now at: ovypxnus 7f278c0d (empty) (no description set)
Parent commit      : orllnptq 7b028dc9 (empty) (no description set)
Added 0 files, modified 1 files, removed 0 files
 
❯ jj log
@  ovypxnus benjamin@dev.ofcr.se 18 seconds ago 7f278c0d
(empty) (no description set)
orllnptq benjamin@dev.ofcr.se 18 seconds ago HEAD@git 7b028dc9
├─┬─┬─╮  (empty) (no description set)
│ │ │ ◉  rwqywnzl benjamin@dev.ofcr.se 47 seconds ago 402f7ad8
│ │ │ │  new: avoid manual `unwrap()` call
│ │ ◉ │  qklyrnvv benjamin@dev.ofcr.se 23 minutes ago push-qklyrnvvuksv* 28af9083
│ │ ├─╯  cli: print conflicted paths whenever the working copy is changed
│ ◉ │  zozvwmow benjamin@dev.ofcr.se 23 minutes ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ ◉ │  yowkkkqn benjamin@dev.ofcr.se 23 minutes ago ffec92c9
│ ├─╯  git: use prerelease version of `git2` with OpenSSH support
◉ │  wtmqulxn benjamin@dev.ofcr.se 23 minutes ago push-uqxvnturzsuu* 8673733e
│ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
◉ │  uqxvntur benjamin@dev.ofcr.se 23 minutes ago dd7454a2
│ │  rebase: add `--insert-after` and `--insert-before` options
◉ │  nkzsqppm benjamin@dev.ofcr.se 23 minutes ago 0a949714
├─╯  rebase: extract out some functions from `rebase_revision`
oqtzskyx martinvonz@google.com 1 day ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

This persists the change in the working directory, whilst extracting it to a standalone commit on top of the main branch which can be sent for code review. Here’s how you can create a branch and push to an upstream repository (GitHub in this case):

 jj branch create test -r rwq
 
 jj git push --remote bnjmnt4n --branch test
Branch changes to push to bnjmnt4n:
  Add branch test to 402f7ad8b9bd
remote:
remote: Create a pull request for 'test' on GitHub by visiting:
remote:      https://github.com/bnjmnt4n/jj/pull/new/test
remote:

Moving a change to a parent

Another possible scenario is that you’ve made some modifications to your working copy, and want to shift the commit into one of the arms of the merge commit.

This is what the commit graph looks like after making the change:

 nvim
 
 jj commit -m "test change"
Working copy now at: uyllouwm e9febf2c (empty) (no description set)
Parent commit      : ovypxnus e99d7578 misc: test change
 
❯ jj log
@  uyllouwm benjamin@dev.ofcr.se 13 seconds ago e9febf2c
(empty) (no description set)
ovypxnus benjamin@dev.ofcr.se 13 seconds ago HEAD@git e99d7578
│  misc: test change
orllnptq benjamin@dev.ofcr.se 1 hour ago 7b028dc9
├─┬─┬─╮  (empty) (no description set)
│ │ │ ◉  rwqywnzl benjamin@dev.ofcr.se 1 hour ago test 402f7ad8
│ │ │ │  new: avoid manual `unwrap()` call
│ │ ◉ │  qklyrnvv benjamin@dev.ofcr.se 1 hour ago push-qklyrnvvuksv* 28af9083
│ │ ├─╯  cli: print conflicted paths whenever the working copy is changed
│ ◉ │  zozvwmow benjamin@dev.ofcr.se 1 hour ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ ◉ │  yowkkkqn benjamin@dev.ofcr.se 1 hour ago ffec92c9
│ ├─╯  git: use prerelease version of `git2` with OpenSSH support
◉ │  wtmqulxn benjamin@dev.ofcr.se 1 hour ago push-uqxvnturzsuu* 8673733e
│ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
◉ │  uqxvntur benjamin@dev.ofcr.se 1 hour ago dd7454a2
│ │  rebase: add `--insert-after` and `--insert-before` options
◉ │  nkzsqppm benjamin@dev.ofcr.se 1 hour ago 0a949714
├─╯  rebase: extract out some functions from `rebase_revision`
oqtzskyx martinvonz@google.com 1 day ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

There’s a new change ovy which we want to set as the child of our previous change rwq, then update the branch test to point to ovy. There’s two possible ways to do this right now using Jujutsu:

  1. Rebase ovy onto rwq, rebase the merge commit to point to ovy instead of rwq, then update the branch test to point to ovy.
  2. Create a new commit after rwq, squash the changes from ovy into it, then update the branch test to point to ovy.

The first way is similar to what’s already been done above, so I’ll show the second way of doing this. First, we insert a new commit after rwq, making sure to specify --no-edit to avoid checking out the changes in rwq:

 jj new --after rwq --no-edit
Created new commit lqksrtkk 6a38dd7a (empty) (no description set)
Rebased 3 descendant commits
Working copy now at: uyllouwm 355ea4ba (empty) (no description set)
Parent commit      : ovypxnus 27baf0ef misc: test change

A new, empty commit with change ID lqks was created after rwq. Note how lqks was correctly inserted between orl and rwq, maintaining the ancestry of the merge commit:

❯ jj log
@  uyllouwm benjamin@dev.ofcr.se 1 minute ago 355ea4ba
(empty) (no description set)
ovypxnus benjamin@dev.ofcr.se 1 minute ago HEAD@git 27baf0ef
│  misc: test change
orllnptq benjamin@dev.ofcr.se 1 minute ago 8c486dfd
├─┬─┬─╮  (empty) (no description set)
│ │ │ ◉  lqksrtkk benjamin@dev.ofcr.se 1 minute ago 6a38dd7a
│ │ │ │  (empty) (no description set)
│ │ │ ◉  rwqywnzl benjamin@dev.ofcr.se 1 hour ago test 402f7ad8
│ │ │ │  new: avoid manual `unwrap()` call
│ │ ◉ │  qklyrnvv benjamin@dev.ofcr.se 1 hour ago push-qklyrnvvuksv* 28af9083
│ │ ├─╯  cli: print conflicted paths whenever the working copy is changed
│ ◉ │  zozvwmow benjamin@dev.ofcr.se 1 hour ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ ◉ │  yowkkkqn benjamin@dev.ofcr.se 1 hour ago ffec92c9
│ ├─╯  git: use prerelease version of `git2` with OpenSSH support
◉ │  wtmqulxn benjamin@dev.ofcr.se 1 hour ago push-uqxvnturzsuu* 8673733e
│ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
◉ │  uqxvntur benjamin@dev.ofcr.se 1 hour ago dd7454a2
│ │  rebase: add `--insert-after` and `--insert-before` options
◉ │  nkzsqppm benjamin@dev.ofcr.se 1 hour ago 0a949714
├─╯  rebase: extract out some functions from `rebase_revision`
oqtzskyx martinvonz@google.com 1 day ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

Next, we can “squash” or move the changes from ovy into lqks. This is followed by updating the branch test to point to lqks:

 jj squash --from ovy --into lqks
Rebased 2 descendant commits
Working copy now at: uyllouwm 23f02b9f (empty) (no description set)
Parent commit      : orllnptq ec83f9fc (empty) (no description set)
 
 jj branch set test -r lqks
 
❯ jj log
@  uyllouwm benjamin@dev.ofcr.se 1 minute ago 23f02b9f
(empty) (no description set)
orllnptq benjamin@dev.ofcr.se 1 minute ago HEAD@git ec83f9fc
├─┬─┬─╮  (empty) (no description set)
│ │ │ ◉  lqksrtkk benjamin@dev.ofcr.se 1 minute ago test* 07d8a576
│ │ │ │  misc: test change
│ │ │ ◉  rwqywnzl benjamin@dev.ofcr.se 1 hour ago test@bnjmnt4n 402f7ad8
│ │ │ │  new: avoid manual `unwrap()` call
│ │ ◉ │  qklyrnvv benjamin@dev.ofcr.se 2 hours ago push-qklyrnvvuksv* 28af9083
│ │ ├─╯  cli: print conflicted paths whenever the working copy is changed
│ ◉ │  zozvwmow benjamin@dev.ofcr.se 2 hours ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ ◉ │  yowkkkqn benjamin@dev.ofcr.se 2 hours ago ffec92c9
│ ├─╯  git: use prerelease version of `git2` with OpenSSH support
◉ │  wtmqulxn benjamin@dev.ofcr.se 2 hours ago push-uqxvnturzsuu* 8673733e
│ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
◉ │  uqxvntur benjamin@dev.ofcr.se 2 hours ago dd7454a2
│ │  rebase: add `--insert-after` and `--insert-before` options
◉ │  nkzsqppm benjamin@dev.ofcr.se 2 hours ago 0a949714
├─╯  rebase: extract out some functions from `rebase_revision`
oqtzskyx martinvonz@google.com 1 day ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

The log now shows that test@bnjmnt4n (the branch test on the remote bnjmnt4n) points to the previous commit, whilst test is pointing to the commit with change ID orl. The * indicator shows that the branch has been updated, but isn’t consistent with the remote.

The biggest downside of the jj squash workflow is that the change ID of the squashed commit is lost. You’ll need to refer to the change ID of the newly created commit instead.

However, there are plans to improve Jujutsu to make it easier to move commits around the commit graph. In the future, a command like jj rebase -r ovy --after rwq might be able to move the commit whilst maintaining its change ID.

Removing parents

Again, we can use jj rebase (and a small change to the revset) to remove parents from a merge commit:

 jj rebase -s orl -d "all:orl- ~ qkl"
Rebased 2 commits
Working copy now at: uyllouwm 521e9749 (empty) (no description set)
Parent commit      : orllnptq 090ffb0d (empty) (no description set)
Added 0 files, modified 9 files, removed 0 files

Previously, when adding new parents, we’ve specified the destinations using the flags -d "all:orl-" -d NEW_PARENT_ID. Now, we’re specifying the destinations using -d "all:orl- ~ qkl". The new argument for the destination highlights more of the revset language, in particular the set difference operator. As before, orl- evaluates to the set of all parents of orl, but ~ qkl now subtracts qkl from that set.

This has the effect of removing qkl from the merge commit:

❯ jj log
@  uyllouwm benjamin@dev.ofcr.se 14 seconds ago 521e9749
(empty) (no description set)
orllnptq benjamin@dev.ofcr.se 14 seconds ago HEAD@git 090ffb0d
├─┬─╮  (empty) (no description set)
│ │ ◉  zozvwmow benjamin@dev.ofcr.se 2 hours ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ │ ◉  yowkkkqn benjamin@dev.ofcr.se 2 hours ago ffec92c9
│ │ │  git: use prerelease version of `git2` with OpenSSH support
│ ◉ │  wtmqulxn benjamin@dev.ofcr.se 2 hours ago push-uqxvnturzsuu* 8673733e
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ ◉ │  uqxvntur benjamin@dev.ofcr.se 2 hours ago dd7454a2
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ ◉ │  nkzsqppm benjamin@dev.ofcr.se 2 hours ago 0a949714
│ ├─╯  rebase: extract out some functions from `rebase_revision`
◉ │  lqksrtkk benjamin@dev.ofcr.se 6 minutes ago test* 07d8a576
│ │  misc: test change
◉ │  rwqywnzl benjamin@dev.ofcr.se 1 hour ago test@bnjmnt4n 402f7ad8
├─╯  new: avoid manual `unwrap()` call
oqtzskyx martinvonz@google.com 1 day ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

(Likewise, we could also have used set operations to add new parents to the merge commit: jj rebase -d "all:orl- | NEW_PARENT_ID" uses the set union operator to add the new ID to the set of existing parents.)

Conflicting changes

What happens if you update something in the working directory, which you want to shift to a specific parent of the merge commit, but it actually conflicts with another change you made in another parent?

Here, I’ve committed a change which modifies the same lines as the previous commit rwq:

 jj commit -m "conflicting change"
Working copy now at: ywryozyt 7fd247f5 (empty) (no description set)
Parent commit      : uyllouwm 128d5444 conflicting change
 
 jj show uyl
Commit ID: e8cc1f87020ecfabc4fa4b44a6a8a8d67a5de23c
Change ID: uyllouwmkkkkrkvtzynuqwuqvxsrmpvx
Author: Benjamin Tan <benjamin@dev.ofcr.se> (21 hours ago)
Committer: Benjamin Tan <benjamin@dev.ofcr.se> (20 minutes ago)
 
    (no description set)
 
diff --git a/cli/src/commands/new.rs b/cli/src/commands/new.rs
index e0defba129...b946b93769 100644
--- a/cli/src/commands/new.rs
+++ b/cli/src/commands/new.rs
@@ -193,7 +193,8 @@
             writeln!(formatter)?;
         }
     } else {
-        tx.edit(&new_commit)?;
+        let commit = new_commit;
+        tx.edit(&commit).unwrap();
         // The description of the new commit will be printed by tx.finish()
     }
     if num_rebased > 0 {

Here’s the updated commit graph now, with uyl containing the change and no longer being empty:

❯ jj log
@  ywryozyt benjamin@dev.ofcr.se 35 seconds ago 7fd247f5
(empty) (no description set)
uyllouwm benjamin@dev.ofcr.se 35 seconds ago HEAD@git 128d5444
│  conflicting change
orllnptq benjamin@dev.ofcr.se 21 hours ago 090ffb0d
├─┬─╮  (empty) (no description set)
│ │ ◉  zozvwmow benjamin@dev.ofcr.se 23 hours ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ │ ◉  yowkkkqn benjamin@dev.ofcr.se 23 hours ago ffec92c9
│ │ │  git: use prerelease version of `git2` with OpenSSH support
│ ◉ │  wtmqulxn benjamin@dev.ofcr.se 23 hours ago push-uqxvnturzsuu* 8673733e
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ ◉ │  uqxvntur benjamin@dev.ofcr.se 23 hours ago dd7454a2
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ ◉ │  nkzsqppm benjamin@dev.ofcr.se 23 hours ago 0a949714
│ ├─╯  rebase: extract out some functions from `rebase_revision`
◉ │  lqksrtkk benjamin@dev.ofcr.se 21 hours ago test* 07d8a576
│ │  misc: test change
◉ │  rwqywnzl benjamin@dev.ofcr.se 23 hours ago test@bnjmnt4n 402f7ad8
├─╯  new: avoid manual `unwrap()` call
oqtzskyx martinvonz@google.com 2 days ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

I now want to shift this new commit into the arm of the merge commit with zoz (the ssh-openssh branch), so I create a new, empty commit after zoz:

 jj new --after zoz --no-edit
Created new commit txsrozwq ae4fdff5 (empty) (no description set)
Rebased 5 descendant commits
Working copy now at: ywryozyt e1fa0851 (empty) (no description set)
Parent commit      : uyllouwm 454caf02 conflicting change
 
❯ jj log
@  ywryozyt benjamin@dev.ofcr.se 5 seconds ago e1fa0851
(empty) (no description set)
uyllouwm benjamin@dev.ofcr.se 6 seconds ago HEAD@git 454caf02
│  conflicting change
orllnptq benjamin@dev.ofcr.se 6 seconds ago fabcecf1
├─┬─╮  (empty) (no description set)
│ │ ◉  txsrozwq benjamin@dev.ofcr.se 6 seconds ago ae4fdff5
│ │ │  (empty) (no description set)
│ │ ◉  zozvwmow benjamin@dev.ofcr.se 23 hours ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ │ ◉  yowkkkqn benjamin@dev.ofcr.se 23 hours ago ffec92c9
│ │ │  git: use prerelease version of `git2` with OpenSSH support
│ ◉ │  wtmqulxn benjamin@dev.ofcr.se 23 hours ago push-uqxvnturzsuu* 8673733e
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ ◉ │  uqxvntur benjamin@dev.ofcr.se 23 hours ago dd7454a2
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ ◉ │  nkzsqppm benjamin@dev.ofcr.se 23 hours ago 0a949714
│ ├─╯  rebase: extract out some functions from `rebase_revision`
◉ │  lqksrtkk benjamin@dev.ofcr.se 21 hours ago test* 07d8a576
│ │  misc: test change
◉ │  rwqywnzl benjamin@dev.ofcr.se 23 hours ago test@bnjmnt4n 402f7ad8
├─╯  new: avoid manual `unwrap()` call
oqtzskyx martinvonz@google.com 2 days ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

The new commit has the change ID txs, so I’ll squash my changes from uyl into txs:

 jj squash --from uyl --into txs
Rebased 4 descendant commits
New conflicts appeared in these commits:
  txsrozwq 0bbdad29 (conflict) conflicting change
To resolve the conflicts, start by updating to it:
  jj new txsrozwqlunv
Then use `jj resolve`, or edit the conflict markers in the file directly.
Once the conflicts are resolved, you may want inspect the result with `jj diff`.
Then run `jj squash` to move the resolution into the conflicted commit.
Working copy now at: ywryozyt 631fda4b (empty) (no description set)
Parent commit      : orllnptq 06531057 (empty) (no description set)

Jujutsu now warns that a new conflict appeared in txs, as expected. That’s because txs doesn’t have rwq in its history, which was where the first modification came from. Let’s take a look at the log now:

❯ jj log
@  ywryozyt benjamin@dev.ofcr.se 5 seconds ago 631fda4b
(empty) (no description set)
orllnptq benjamin@dev.ofcr.se 5 seconds ago HEAD@git 06531057
├─┬─╮  (empty) (no description set)
│ │ ◉  txsrozwq benjamin@dev.ofcr.se 5 seconds ago 0bbdad29 conflict
│ │ │  conflicting change
│ │ ◉  zozvwmow benjamin@dev.ofcr.se 23 hours ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ │ ◉  yowkkkqn benjamin@dev.ofcr.se 23 hours ago ffec92c9
│ │ │  git: use prerelease version of `git2` with OpenSSH support
│ ◉ │  wtmqulxn benjamin@dev.ofcr.se 23 hours ago push-uqxvnturzsuu* 8673733e
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ ◉ │  uqxvntur benjamin@dev.ofcr.se 23 hours ago dd7454a2
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ ◉ │  nkzsqppm benjamin@dev.ofcr.se 23 hours ago 0a949714
│ ├─╯  rebase: extract out some functions from `rebase_revision`
◉ │  lqksrtkk benjamin@dev.ofcr.se 21 hours ago test* 07d8a576
│ │  misc: test change
◉ │  rwqywnzl benjamin@dev.ofcr.se 23 hours ago test@bnjmnt4n 402f7ad8
├─╯  new: avoid manual `unwrap()` call
oqtzskyx martinvonz@google.com 2 days ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

The commit txs is marked in the log as containing a conflict. Here’s what txs looks like:

 jj show txs
Commit ID: 0bbdad290b695b94aab4e973349e1a5dda6ef0ce
Change ID: txsrozwqlunvppzymmtrnvotvtrnuwxr
Author: Benjamin Tan <benjamin@dev.ofcr.se> (14 minutes ago)
Committer: Benjamin Tan <benjamin@dev.ofcr.se> (13 minutes ago)
 
    conflicting change
 
diff --git a/cli/src/commands/new.rs b/cli/src/commands/new.rs
index eeeb50aee6...0000000000 100644
--- a/cli/src/commands/new.rs
+++ b/cli/src/commands/new.rs
@@ -193,7 +193,14 @@
             writeln!(formatter)?;
         }
     } else {
-        tx.edit(&new_commit).unwrap();
+<<<<<<<
+%%%%%%%
+-        tx.edit(&new_commit)?;
++        tx.edit(&new_commit).unwrap();
++++++++
+        let commit = new_commit;
+        tx.edit(&commit).unwrap();
+>>>>>>>
         // The description of the new commit will be printed by tx.finish()
     }
     if num_rebased > 0 {

The original line from txs’s parent commit in red is replaced with the new conflicting changes in green. Jujutsu’s conflict markers are slightly different from Git: lines following %%%%%%% are a diff between 2 sides, whilst lines following +++++++ are a snapshot of the changes a side. Here’s my annotations on what the conflict markers mean:

<<<<<<<
%%%%%%% Diff from destination `txs` to base tree of `orl`
-        tx.edit(&new_commit)?;
+        tx.edit(&new_commit).unwrap();
+++++++ Snapshot of new changes from source `uyl`
        let commit = new_commit;
        tx.edit(&commit).unwrap();
>>>>>>>

Even though txs has a conflict, note that the merge commit orl isn’t in a conflicted state. This is because Jujutsu doesn’t just store conflict markers, but the full metadata of the conflicts, so it can resolve the conflicts by applying all the changes from each of orl’s parents.

However, if we want to update the ssh-openssh branch to include the changes in txs, we can’t just push a conflicted file since it won’t be accepted in any code review. We need to first resolve the conflict in txs. I’m doing this manually here by checking out txs and editing the file in the working directory, but you can also use a graphical tool for conflict resolution.

 jj new txs
Working copy now at: yswompum 3ea0e8e9 (conflict) (empty) (no description set)
Parent commit      : txsrozwq 0bbdad29 (conflict) conflicting change
Added 0 files, modified 5 files, removed 0 files
 
 nvim
 
 jj status
Working copy changes:
M cli/src/commands/new.rs
Working copy : yswompum 509f7ea1 (no description set)
Parent commit: txsrozwq 0bbdad29 (conflict) conflicting change

The changes are updated in my working copy commit, so I can squash the changes into txs to apply the resolution there as well:

 jj squash
Rebased 3 descendant commits
Existing conflicts were resolved or abandoned from these commits:
  txsrozwq hidden 0bbdad29 (conflict) conflicting change
Working copy now at: xmynmysw 53a37139 (empty) (no description set)
Parent commit      : txsrozwq a11303a3 conflicting change

So, txs no longer has any conflicts, and we can update our branch to point to it and push it for review. However, if we go back to our merge commit orl, we can see that the merge commit is now marked as conflicting:

 jj new orl
Working copy now at: nlwnwups 69a1f4ce (conflict) (empty) (no description set)
Parent commit      : orllnptq 9192cf35 (conflict) (empty) (no description set)
Added 0 files, modified 5 files, removed 0 files
 
❯ jj log
@  nlwnwups benjamin@dev.ofcr.se 1 minute ago 69a1f4ce conflict
(empty) (no description set)
orllnptq benjamin@dev.ofcr.se 5 minutes ago HEAD@git 9192cf35 conflict
├─┬─╮  (empty) (no description set)
│ │ ◉  txsrozwq benjamin@dev.ofcr.se 5 minutes ago a11303a3
│ │ │  conflicting change
│ │ ◉  zozvwmow benjamin@dev.ofcr.se 1 day ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ │ ◉  yowkkkqn benjamin@dev.ofcr.se 1 day ago ffec92c9
│ │ │  git: use prerelease version of `git2` with OpenSSH support
│ ◉ │  wtmqulxn benjamin@dev.ofcr.se 1 day ago push-uqxvnturzsuu* 8673733e
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ ◉ │  uqxvntur benjamin@dev.ofcr.se 1 day ago dd7454a2
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ ◉ │  nkzsqppm benjamin@dev.ofcr.se 1 day ago 0a949714
│ ├─╯  rebase: extract out some functions from `rebase_revision`
◉ │  lqksrtkk benjamin@dev.ofcr.se 23 hours ago test* 07d8a576
│ │  misc: test change
◉ │  rwqywnzl benjamin@dev.ofcr.se 1 day ago test@bnjmnt4n 402f7ad8
├─╯  new: avoid manual `unwrap()` call
oqtzskyx martinvonz@google.com 2 days ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~

This makes sense, because we’ve removed manually resolved the conflicts from txs. Jujutsu no longer has the metadata about how the conflict came about from merging different files, so orl now has a conflict. Typically, this isn’t that big an issue since you can just delay conflict resolution for that individual commit until you’re done working on that branch. You can then remove that branch from the merge commit after that.

If you do want to continue working on both branches, you can also restore the state of the working directory to its original state, before squashing the commit. First, get the commit ID of the change uyl that we wrote originally, before any commit manipulation (128d5444 from above), then run jj restore:

 jj restore --from 128d5444 --to orl
Created orllnptq f2388131 (no description set)
Rebased 1 descendant commits
Working copy now at: nlwnwups d37fb681 (empty) (no description set)
Parent commit      : orllnptq f2388131 (no description set)
Added 0 files, modified 1 files, removed 0 files

This restores all the files in commit orl to their state in commit 128d5444—the original files before the conflict occured due to squashing of commits. This has the effect of solving the conflict within the merge commit orl:

❯ jj log
@  nlwnwups benjamin@dev.ofcr.se 39 seconds ago d37fb681
(empty) (no description set)
orllnptq benjamin@dev.ofcr.se 39 seconds ago HEAD@git f2388131
├─┬─╮  (no description set)
│ │ ◉  txsrozwq benjamin@dev.ofcr.se 3 days ago a11303a3
│ │ │  conflicting change
│ │ ◉  zozvwmow benjamin@dev.ofcr.se 4 days ago ssh-openssh* c6c73906
│ │ │  git: update error message for SSH error to stop referencing libssh2
│ │ ◉  yowkkkqn benjamin@dev.ofcr.se 4 days ago ffec92c9
│ │ │  git: use prerelease version of `git2` with OpenSSH support
│ ◉ │  wtmqulxn benjamin@dev.ofcr.se 4 days ago push-uqxvnturzsuu* 8673733e
│ │ │  rebase: allow both `--insert-after` and `--insert-before` to be used simultaneously
│ ◉ │  uqxvntur benjamin@dev.ofcr.se 4 days ago dd7454a2
│ │ │  rebase: add `--insert-after` and `--insert-before` options
│ ◉ │  nkzsqppm benjamin@dev.ofcr.se 4 days ago 0a949714
│ ├─╯  rebase: extract out some functions from `rebase_revision`
◉ │  lqksrtkk benjamin@dev.ofcr.se 4 days ago test* 07d8a576
│ │  misc: test change
◉ │  rwqywnzl benjamin@dev.ofcr.se 4 days ago test@bnjmnt4n 402f7ad8
├─╯  new: avoid manual `unwrap()` call
oqtzskyx martinvonz@google.com 5 days ago main* v0.16.0 2dcdc7fb
│  release: release version 0.16.0
~
 
 jj show orl
Commit ID: f2388131cba4be8fe0b267dcef1af8d823184851
Change ID: orllnptqzkuqpsonzzkytxlrzpyxmwtn
Author: Benjamin Tan <benjamin@dev.ofcr.se> (4 days ago)
Committer: Benjamin Tan <benjamin@dev.ofcr.se> (1 minute ago)
 
    (no description set)
 
diff --git a/cli/src/commands/new.rs b/cli/src/commands/new.rs
index 0000000000...b946b93769 100644
--- a/cli/src/commands/new.rs
+++ b/cli/src/commands/new.rs
@@ -193,14 +193,8 @@
             writeln!(formatter)?;
         }
     } else {
-<<<<<<<
-%%%%%%%
--        tx.edit(&new_commit).unwrap();
-+        tx.edit(&new_commit)?;
-+++++++
         let commit = new_commit;
         tx.edit(&commit).unwrap();
->>>>>>>
         // The description of the new commit will be printed by tx.finish()
     }
     if num_rebased > 0 {

Conclusion

I’ve shown how you can use Jujutsu to manipulate merge commits and work on separate logical branches of code, by adding and removing parents using the jj rebase command. Arguably, this workflow might be better visualized with a graphical interface to see how the commit graph is being manipulated. There’s a GUI app for Jujutsu, called GG, with a pretty convincing demo of this workflow.

Working on multiple branches of code at the same time can be really powerful. Personally, I use this workflow all the time to avoid having to switch branches. This is especially convenient when working on small bugfixes where it’s definitely easier to just work in the current directory.

Merging multiple branches together also allows you to very simply test out various features at the same time, without having to wait for all of them to be merged into the main branch. In fact, I use this to build a custom jj binary which contains various features which haven’t been merged into main.

Even if you aren’t convinced about switching to Jujutsu, I think this workflow is still valuable. In fact, GitButler—a new Git client—was recently launched with a similar end product: making it easy to activate different “virtual branches” in your working directory. Otherwise, alternative tools like git-branchless might allow you to do something similar.

If you are intrigued by Jujutsu, do check out the introduction and tutorial. I’d also recommend Chris’s article and video series. Steve Klabnik also has a long-form tutorial on Jujutsu, which includes a chapter on this workflow.

Footnotes

  1. Hmmm, maybe The Austin™ Mega Merge Strategy® is the better name after all…

  2. Actually, you can have change IDs associated with multiple commits, but that’s out of the scope of this article.

  3. jj commit is a shorthand for jj describe, to describe the current change, and jj new, to create a new change.