2025-05-27

Jujutsu From The Trenches


I've always been one for early adoption and niche tools, often to my detriment. Last year I tried to use Jujutsu (jj) with the code review tool Gerrit at work and gave up.

Since then things have changed and recently I've found a workflow that works well with Gerrit's patch-based model.

If you aren't familiar with Jujutsu it's a new version control system that uses Git as a backend, and has been getting a lot of buzz recently. The big differences are the staging area and index are gone, instead you are always updating a commit. This plus more flexible commands allows some pretty nifty workflows.

For the impatient amongst you the takeaways are:

  • Use this commit message template
  • Do work on a new commit at the top of your relation chain, and jj squash --to
  • Cleanup merged changes as you go
  • Remember to do new work on your main branch

Gerrit?

If you are unfortunate enough to only know Github for code reviews, Gerrit is code review software where your unit of review is a commit, rather than a branch/PR. When you want to change your code you edit the commit and reupload.

I've talked more about why I love Gerrit in a previous post and I use it every day at work.

A False Start

I first heard of Jujutsu in 2024 whilst looking for a VCS that fit better with patch-based code review. I knew of Sapling and was trying to find something with a similar workflow for Git. jj fit that niche nicely.

There was one problem: Gerrit change IDs. To keep track of commits - even when they are edited with git rebase - Gerrit makes you add a trailer to commits called a change ID. For example the last line from this commit:

DOC-9965 Collection names can be 251 chars long

Docs mistakenly said up to 30

Change-Id: Iccd178eb0249d31e4750de5ae37bbb54004ef0b8

For Git this is done with a pre-commit hook, but jj does not have these. Whilst googling around I saw that there was a work in progress PR which implemented pushing changes to Gerrit and handling the change ID stuff for you. This had the added nicety of working with jj's revset language.

Unfortunately at the time I found it fiddly. I am not sure if it was user error or a bug in the implementation but I often did not push the changes I wanted to. After one too many cock ups I went back to using Git.

A Fresh Start

A couple of months ago I decided to take another look at jj. I had been working on a complicated feature where I was using relation chains heavily - the way Gerrit groups commits together. Sometimes a problem would be noticed in a later commit that required reworking a previous one, so I was doing a lot of rebasing which was tiring and error-prone.

Since I had last tried jj they added templates which can pre-fill commit messages for you. This was perfect for Gerrit change IDs and avamsi on the Gerrit PR had provide one:

[template-aliases]
	'gerrit_change_id(change_id)' = '"Id0000000" ++ change_id.normal_hex()'

[templates]
	draft_commit_description = '''
		separate("\n",
			description.remove_suffix("\n"),
			if(!description.contains(change_id.normal_hex()),
				"\nChange-Id: " ++ gerrit_change_id(change_id)
			),
			"\n",
			surround("JJ: Changes:\n", "", indent("JJ: \t", diff.summary()))
		)
	'''

jj itself has the concept of a change ID so generating the trailer is as simple as hashing jj's change ID.

This made my workflow a lot closer to what I was used to, minimising errors. I could reuse my normal

> git push gerrit HEAD:refs/for/master

command and have it work with jj. Even better, the default log format for jj now included where HEAD was located, stopping me from pushing the wrong thing:

@  pwzkpxpt [email protected] 2025-05-27 19:24:50 e4999a1d
│  This is a work in-progress
○  toquswwk [email protected] 2025-05-27 19:24:33 git_head() e0dbb2b2
│  Added a test file
~

In the terminal git_head() and the unique part of the change ID (e.g. the t in toquswwk) are highlighted to make it more obvious).

Workflow Adjustment

Initially I was using jj as if it was git rebase. For example if I had these commits adding SVG support to a drawing app:

○  vvnusqsl [email protected] 2025-05-27 19:34:14 git_head() 1af283e6
│  Use a file picker for output SVG filename
○  yoqksoop [email protected] 2025-05-27 19:33:13 7c7257da
│  Refactor text centering to work with SVG
○  pxzxzxyo [email protected] 2025-05-27 19:32:47 253714c2
│  Use SVG writer to output current diagram
○  npwsvlmo [email protected] 2025-05-27 19:32:28 e1db16fe
│  Implement a basic SVG writer

and I wanted to add a new test to the commit "Use SVG writer to output current diagram" I'd create a commit off it (we can use just p here as it is a unique prefix):

> jj new p
> jj log
@  pxznrtnx [email protected] 2025-05-27 19:35:33 66bdaee2
│  (empty) (no description set)
│ ○  vvnusqsl [email protected] 2025-05-27 19:34:14 1af283e6
│ │  Use a file picker for output SVG filename
│ ○  yoqksoop [email protected] 2025-05-27 19:33:13 7c7257da
├─╯  Refactor text centering to work with SVG
○  pxzxzxyo [email protected] 2025-05-27 19:32:47 git_head() 253714c2
│  Use SVG writer to output current diagram
○  npwsvlmo [email protected] 2025-05-27 19:32:28 e1db16fe
│  Implement a basic SVG writer

I'd then add my test and jj squash to put it back into its original commit. This is laborious and isn't much of an upgrade on my git rebase workflow.

I realised this wasn't required - most jj commands allow you to specify the target (by default the current working commit, @) and any other commits involved.

Instead I now do my work on top of the relation chain. I then simply do

jj squash --to p

which takes the changes in my current commit and puts them in p ("Use SVG writer to output current diagram"). This is a lot nicer and thanks to jj's support for conflicts I find it a lot easier to clean up any problems.

Annoyances

There are a couple of things that still slow me down whilst using Jujutsu. First of all, when using Gerrit with Git it is common to be in a detached-HEAD state, which means that the checkout is at a commit that is not in a branch.

People new to Gerrit sometimes find this difficult to get used to but it can be nice. When changes are eventually merged you do not need to clean up any local branches.

Unfortunately with jj, even though it doesn't have branches, we still have cleanup to do. When changes are merged in Gerrit and then fetched locally in jj, duplicates of the commits will appear: one for the merged commits into main and one for the original ones from code review.

Getting rid of the duplicates is easy because jj has a notation for a commit and its descendents: you take the change and suffix it with ::. In the example used previously I would do:

> jj abandon -r n::

but it's still annoying. There's a proposal to make change IDs a part of Git which hopefully means this could be automatic or scripted more easily.

The other issue I hit is that sometimes the default log view does not include the main branch. The log view is very customisable using the revset language, so I should be able to fix this, but after a lot of faffing I haven't got anywhere.

In practice this is easy to deal with, I just always fetch from the remote and start new work off of main using

jj new main

Conclusion

After struggling with getting jj to work with Gerrit I am now in a place where I wouldn't go back. Editing relations chains are now a breeze and I haven't lost Git as a get out of jail card. If you've not tried it yet - and especially if you're using Gerrit - give jj a go.