Blog

Jujutsu + Radicle = ❤️

How I use Jujutsu in tandem with Radicle

Published by fintohaps on 14.08.2025

Roughly a year ago at the first ever Local First Conference, a friend and previous colleague – Alex Good – told me about this tool called jj (Jujutsu). We did the usual thing and I sat down beside him as he explained it to me. My brain did the usual thing and took in some of the information but not enough of it, and so I didn’t touch jj for quite some time after that – but what’s good enough for Alex Good is good enough for me.

After that, I feel like I saw a post about jj once every couple of months on Hacker News – confirmation bias anyone? It was a constant talking point during Git Merge 2024, and now it’s a third Git tool that uses the concept of change identifiers, so it’s a talking point on the Git mailing list.

So, fast-forward a year or so, and I’ve been using jj for quite some time while contributing to and maintaining the heartwood repository – the home of the Radicle protocol – as well as some others. Did I have to convince my whole team that jj should be used by all of us and we all switch to this new workflow? No. The first piece of “magic” of jj is that it is essentially a version control system that has a transparent layer on top of Git itself. A change in jj will always point to a Git commit. The beauty of its implementation is that the underlying commit can change as much as it wants, while the jj change remains the same. This unlocks a lot of nice flows for managing changes using jj.

So, you must be wondering by now, “How do I blend Radicle with jj?” Well, let’s dance between the three worlds of jj, Git, and Radicle, to see how they have melted together to form a beautiful (almost) branch-less workflow.

Radicle and Git

I won’t spend too much time here, but if you don’t know by now, Radicle works on top of Git to allow people to use this ubiquitous tool, while we benefit from its storage and protocol. When you start a Radicle repository, it’s essentially a Git repository where we use some special references and extension points of Git to cryptographically secure your commits, and store all your social, collaborative artifacts. If you haven’t yet, go download Radicle and try it yourself using our guides.

Note that if you’re already familiar with jj this might not be that interesting for you, and you can skip to User Config.

My .git/config

As a maintainer of a few repositories using Radicle, I naturally need to push to and fetch from the repository in Radicle storage. This means that I’ll need a remote – this is set up for you when you run rad init or rad clone. This looks like:

[remote "rad"]
	url = rad://z371PVmDHdjJucejRoRYJcDEvD5pp
	fetch = +refs/heads/*:refs/remotes/rad/*
	fetch = +refs/tags/*:refs/remotes/rad/tags/*
	pushurl = rad://z371PVmDHdjJucejRoRYJcDEvD5pp/z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM
[branch "master"]
	remote = rad
	merge = refs/heads/master

The rad:// URL tells git which remote helper to use by trying to find git-remote-rad. This will handle fetching/pushing from/to the repository identified by z371PVmDHdjJucejRoRYJcDEvD5pp. The string z6MkireRatUThvd3qzfKht1S44wpm4FEWSSa4PRMTSQZ3voM is my Node ID, and identifies my machine which makes sure that when I push, my references get stored under that namespace. Then when have the usual upstream branch setup for master for the rad remote – you may be familiar with this Git config entry when you have your origin set up for another Git forge.

There’s one last piece of the puzzle in config that is an alias for easily creating a Radicle patch.

[alias]
    patch = push rad HEAD:refs/patches

When you push to the special reference refs/patches, the remote helper will catch this and create a new patch for you, and in this case it will use whatever HEAD is for the head of the patch. Note that it will use whatever rad/master is as the base of the patch – that is to say, whatever commits are between rad/master and HEAD (including HEAD) are the commits being proposed by the patch. So, whenever I’m ready to make a patch, I use git patch and my $EDITOR pops open to make my well-crafted message describing what changes I’m making.

git fetch rad and git push rad

This is going to be brief. All I do with git now is git fetch rad (or my peer’s remotes) to fetch any new work in Radicle storage. For pushing I will use git push rad to create or update patches (coming up), update my version of master, and, on the rare occasion, push a branch. That’s it! No more commit, no more rebase, no more merge – ok I still use git log – but that’s pretty much it. So how did I ditch all of these commands? Let’s take a look jj.

Jujutsu and Git

Let’s see how I’m using jj by visiting several of its commands and seeing how I can use them in different scenarios.

jj new

It’s only natural to start off with jj new. This command creates a new change in jj, as well as creating a new, empty commit for that change. Whenever I’m going to make a new change that’s based on the master branch, I run:

$ jj new master@rad
Working copy  (@) now at: qxuvyurn 8e711a87 (empty) (no description set)
Parent commit (@-)      : xslqmmsl 62cdaf6d master@rad | deployment: Vercel → Cloudflare Workers
Added 0 files, modified 0 files, removed 1 files

You’ll notice that jj spits out a Change ID and a Commit ID. You may also notice that a prefix is highlighted – this is the unique prefix for the change and the commit at this time! Which means that I can use qx or 8e to refer to this particular change or commit without any ambiguity; an amazing UX, if you ask me.

At this point, I might know what I’m going to be working on so I use jj describe to give this change a message.

$ jj describe -m "blog: Radicle and JJ"
Working copy  (@) now at: qxuvyurn 408133a5 (empty) blog: Radicle and JJ
Parent commit (@-)      : xslqmmsl 62cdaf6d master@rad | deployment: Vercel → Cloudflare Workers

I’ve now changed the description so that it no longer says (no description yet), and it now reads blog: Radicle and JJ.

So let’s see what we have here:

$ jj show qx
Commit ID: 408133a5e54c80d2398be0c78cccabbd6063902d
Change ID: qxuvyurnqsvupzlpzsvzzpqlmlqvoxwq
Author   : Fintan Halpenny <fintan.halpenny@radicle.xyz> (2025-06-10 07:52:34)
Committer: Fintan Halpenny <fintan.halpenny@radicle.xyz> (2025-06-10 07:52:34)

    blog: Radicle and JJ

We can see that it looks similar to a Git commit, which we can also inspect using:

$ git show 408133a5e54c80d2398be0c78cccabbd6063902d
commit 408133a5e54c80d2398be0c78cccabbd6063902d
Author: Fintan Halpenny <fintan.halpenny@radicle.xyz>
Date:   2025-06-10 07:52:34 +0200

    blog: Radicle and JJ

This leaves us in a position to do our usual changes within our working copy of the Git repository.

At any point where I’m looking to separate changes, I can use jj new again, specifying any change to make a new change after the given change:

$ jj new qx
Working copy  (@) now at: wmsmovxx c50301c1 (empty) (no description set)
Parent commit (@-)      : qxuvyurn 408133a5 blog: Radicle and JJ
$ jj describe -m "blog: Radicle an JJ - add body"
Working copy  (@) now at: wmsmovxx a3d195ad (empty) blog: Radicle and JJ – add body
Parent commit (@-)      : qxuvyurn 408133a5 blog: Radicle and JJ

If I ever think I’m about to make some changes before the change I’m on, then I can use the -B option:

$ jj new -B @
Rebased 1 descendant commits
Working copy  (@) now at: zvrmpyox f0635336 (empty) (no description set)
Parent commit (@-)      : xslqmmsl 62cdaf6d master@rad | deployment: Vercel → Cloudflare Workers
Added 0 files, modified 0 files, removed 1 files

jj edit

At any point in time, I can also decide to go back to an old change and edit it, specifying the change that I want to edit:

$ jj edit qx
Working copy  (@) now at: qxuvyurn 408133a5 blog: Radicle and JJ
Parent commit (@-)      : xslqmmsl 62cdaf6d master@rad | deployment: Vercel → Cloudflare Workers

You can now forget about all those fixup! commits you were making to add changes into previous commits. No longer are you at the mercy of making a commit that is ahead of some other changes and you need to reorder it using git rebase. You taste that? It tastes like victory…

jj squash

Ok, so you’ve made some changes that are not related to the current change? This happens, or at least it does to me – I’m not perfect, (un)fortunately. I can use the power of jj new, whether after or before the current change, and combine it with jj squash:

$ jj squash -u --from w --to qx
Rebased 1 descendant commits
Working copy  (@) now at: qxuvyurn 1e2b0ccc (empty) blog: Radicle and JJ
Parent commit (@-)      : xslqmmsl 62cdaf6d master@rad | deployment: Vercel → Cloudflare Workers

This says that I’m squashing the changes from the change identified by r into the change xn, and I want to keep the description of xn and drop the description of r (the -u option).

For extra points, jj even includes the beautiful -i option for choosing which changes you’re taking from the source change – via a TUI. I cannot begin to describe how useful this is for moving around file changes and keeping my history clean and linear.

jj rebase

The final piece of the puzzle, at least for my workflow, is jj rebase. I can move around changes and put them on top of a destination change:

$ jj rebase -d qx -r sm
Working copy  (@) now at: smvvuqzo 420180e8 blog: relevant blog material
Parent commit (@-)      : qxuvyurn 1e2b0ccc blog: Radicle and JJ
Added 1 files, modified 0 files, removed 0 files

This rebases the change sm onto the change qx. In fact, the -r can take a set of changes (see revsets) and graft them all on top of the destination.

My .jj/config

The final part I’ll touch on is my jj config, which can be split into the user and repo config. Thanks to Bruno, who wrote a lot of this on Zulip, and I cribbed it from him.

User Config

Here is my user config, and we’ll discuss a couple of the entries, and I’ll leave the rest as homework.

[aliases]
dlog = ["log", "-r"]
l = ["log", "-r", "(trunk()..@):: | (trunk()..@)-"]
fresh = ["new", "trunk()"]
tug = [
    "bookmark",
    "move",
    "--from",
    "closest_bookmark(@)",
    "--to",
    "closest_pushable(@)",
]

[revset-aliases]
"closest_bookmark(to)" = "heads(::to & bookmarks())"
"closest_pushable(to)" = "heads(::to & mutable() & ~description(exact:\"\") & (~empty() | merges()))"
"desc(x)" = "description(x)"
"pending()" = ".. ~ ::tags() ~ ::remote_bookmarks() ~ @ ~ private()"
"private()" = "description(glob:'wip:*') | \
    description(glob:'private:*') | \
    description(glob:'WIP:*') | \
    description(glob:'PRIVATE:*') | \
    conflicts() | \
    (empty() ~ merges()) | \
    description('substring-i:\"DO NOT MAIL\"')"
Repository Config

And here is my repository config, which we’ll discuss a bit more in detail.

[revset-aliases]
"trunk()" = "master@rad"
"immutable_heads()" = "present(trunk()) | \
    tags() | \
    ( \
        untracked_remote_bookmarks() ~ \
        untracked_remote_bookmarks(remote='rad') ~ \
        untracked_remote_bookmarks(regex:'^patch(es)/',remote='rad') \
    )"

[git]
write-change-id-header = true

We want to change the trunk() alias from its default in jj so that it points to master@rad, the default branch in this particular Radicle repository. The trunk() revset is used in a few place, for example, we saw it above in fresh, but it is also in the next revset alias.

Some changes in jj will be marked as immutable. jj will prevent you from changing certain changes if they are marked as immutable, and its default value for this can be very restrictive, so instead we change it here. First we mark changes that are present in trunk() or tags() as immutable. Then we have untracked remote bookmarks with the set difference operator ~. What are not marking as immutable are bookmarks that are in rad or that patch/patches. That is, if the changes are ours or from patches, then they’re safe to edit. You might think, “Why are patches safe?”” Well, let’s finally get into Radicle and Jujutsu.

Radicle and Jujutsu

So here we are, a lot of build up to get to the point where I can describe how I can avoid using branches (as much as possible).

Contributing Patches

We will first dive into contributing a new patch using Radicle. As described in Jujutsu and Git, I can start making a set of changes using jj new, editing them just how I like using jj edit, and ordering them just the way I want with jj rebase and jj squash. During this whole time, I’m in that, initially scary, detached HEAD state. Here it comes, we’re going to make a patch!

Creating a New Patch
git patch

That’s it. Well, the $EDITOR opens and I write a title and a body describing my wonderful changes, and when I’m done, the remote helper will create the patch and announce it to the network.

 Patch e5f0a5a5adaa33c3b931235967e4930ece9bb617 opened
 Synced with 8 node(s)

To rad://z3cyotNHuasWowQ2h4yF9c3tFFdvc/z6MkvZwzK64f3GuDcAs6dEcje89ddfHkBjS1v9Dkh7aCGq3C
 * [new reference]   HEAD -> refs/patches
Updating a Patch

Let’s be honest though, my wonderful changes are rarely wonderful from the get-go. They need some polishing, and my peers always have great suggestions that I should integrate into the patch.

From here, I can find the patch using rad patch:

$ rad patch
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
ID       Title                                                  Author                          Reviews    Head     +      -      Updated     
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
  18a71ad  radicle-cli: Warn when using old names of nodes        self           (you)            - - - - -  552f4af  +146   -3     4 days ago  
  ed450c9  node, profile, ssh: Make key location configurable     self           (you)            - - - - -  d2f7b89  +376   -74    1 month ago 
  12bc851  node, cli: Refactor test environment                   self           (you)            - - - - -  d059957  +826   -1214  1 month ago 
  3219ef8  Remove predefined bootstrap nodes                      istankovic     z6MkmiJ…mkTV5sS  - - - - -  7322e3a  +138   -108   2 days ago  
  058586b  Suggest the git configured default branch during init  stemporus      z6MkqLa…jr8xo5K  - - - - -  6a1147f  +16    -8     2 weeks ago 
  1015e51  build: Rewrite tagging script                          fintohaps      z6Mkire…SQZ3voM  - - - - -  149de0b  +24    -12    3 weeks ago 
  e85ff9a  node: clean up `UploadError`                           fintohaps      z6Mkire…SQZ3voM  - - - - -  b408e44  +15    -13    3 weeks ago 
  c54883e  Canonical References                                   fintohaps      z6Mkire…SQZ3voM  - - - - -  34014a6  +4642  -1575  1 month ago 
  e500399  radicle: improve inline comments                       fintohaps      z6Mkire…SQZ3voM  - - - - -  e7cab63  +924   -244   1 month ago 
  6080c3c  Add issue instructions                                 yorgos-laptop  z6MkrnX…CPFSFS3  - - - - -  1877285  +32    -15    1 month ago 
  40a8d72  radicle: introduce COB stream                          fintohaps      z6Mkire…SQZ3voM  - - - - -  ec00acb  +1178  -9     4 months ago
  8ab3f9c  Add document on how to implement a new COB type        liw            z6MkgEM…1b2w2FV  - - - - -  5a3b095  +314   -0     1 year ago  
───────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Let’s say I received feedback on my Canonical References patch, I can use its ID, the shortened version above, to inspect it:

$ rad patch show c54883e
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
Title     Canonical References                                                                                                                  
Patch     c54883e5ffb1f8a99f432e3ac79c0b728cd0dab3                                                                                              
Author    fintohaps z6Mkire…SQZ3voM                                                                                                             
Head      34014a67b0ddc859d95e17ffc71c1ae61aff5758                                                                                              
Branches  patch/c54883e, sync-goal                                                                                                              
Commits   ahead 6, behind 49                                                                                                                    
Status    open                                                                                                                                  
                                                                                                                                                
See RIP-0004[^0] for the specification.                                                                                                         
                                                                                                                                                
This patch is an implementation of RIP-0004. It implements the rules mechanism                                                                  
within the `rules` module. This is interplays with the existing `canonical`                                                                     
mechanisms, already defined (but slightly refactored).                                                                                          
                                                                                                                                                
The `rules` are then used in pushing and fetching references. A test is added to                                                                
illustrate the canonical references in action via tags.                                                                                         
                                                                                                                                                
There were some incidental changes that were made to ensure the tags use case is                                                                
easy for users. The first change was to add a tags refspec to remotes in order                                                                  
to easily fetch tags from peers -- as well ensuring those tags do not pollute                                                                   
the `refs/tags` namespace in the working copy.                                                                                                  
                                                                                                                                                
This had a knock on change where there was a bug `libgit2` that didn't allow for                                                                
deleting `multivar` entries, which the new remote setup fell under. This was                                                                    
fixed and so we update to `git2-0.19`.                                                                                                          
                                                                                                                                                
As well this, the `rad id update` command would error if the payload identifier                                                                 
was not the project identifier. This would stop adding new payloads to extend                                                                   
the identity -- which was needed for introducing canonical references.                                                                          
                                                                                                                                                
[^0]:                                                                                                                                           
https://app.radicle.xyz/nodes/seed.radicle.garden/rad:z3trNYnLWS11cJWC6BbxDs5niGo82/patches/1d1ce874f7c39ecdcd8c318bbae46ffd02fe1ea8?tab=changes
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
34014a6 radicle: refactor rule matching                                                                                                         
0e0b77e radicle: add canonical refs to identity                                                                                                 
bbe019c radicle: canonical reference rules                                                                                                      
b3ad6f2 radicle: refactor Canonical                                                                                                             
04277b4 radicle: store threshold in Canonical                                                                                                   
312c6a4 meta: relax radicle-git dependencies                                                                                                    
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
 opened by fintohaps z6Mkire…SQZ3voM (3e97837) 10 months ago                                                                                   
 updated to c1a2cc5787f44c0a835c1deae375be04c399dd7e (58e932c) 9 months ago                                                                    
 updated to c55494efc2e780cd6c91a1f90efdae8a3eb1c7ef (1b07774) 8 months ago                                                                    
 updated to 583e6b3366c36cc7e67910c29a66750397a60484 (fdd5277) 7 months ago                                                                    
 updated to d54ddef216909bdd3e54e33e4f82c45df79c00d3 (f24f9d8) 7 months ago                                                                    
 updated to ac48ae6e75d4eaa13daed657eed24dfeabb9be94 (7d8e461) 7 months ago                                                                    
 updated to 2b31e460db7451376dc3e346ee02b5fd597fa5c6 (040cfb7) 7 months ago                                                                    
 updated to e1c360a1311a0a215bed6eb42e4b0c8c5c44e611 (f0dec88) 6 months ago                                                                    
 updated to 492cfbafd31e4bac85ee73af519ddc6254b47f82 (f9cb27f) 4 months ago                                                                    
 updated to fbdf18d7683bdac7a76149777eed5cf9bbbf5bd5 (2a64755) 4 months ago                                                                    
 updated to 4baf32afd65f2c4b374d8f21fed6877aa804a003 (0cecae6) 4 months ago                                                                    
  └─ ⋄ reviewed by self (you) 1 month ago                                                                                                       
 updated to d2ebc70caca54a8ba508d72862c1e1c79d718129 (4515d45) 1 month ago                                                                     
 updated to 13e9ba641c624db26b6bfe85870daf064f90e9ab (045e465) 1 month ago                                                                     
 updated to 47495c408ccf5eec49b61c7bdb339e5f2d695a30 (a6be355) 1 month ago                                                                     
 updated to e3bdb65d3adb94360dd3449744792f6ecb1f451f (8d08215) 1 month ago                                                                     
  └─ ⋄ reviewed by erikli z6MkgFq…FGAnBGz 1 month ago                                                                                           
 updated to 9f779028704b4c022cbe25c0e4a9bb46dc8463ba (49fcea7) 1 month ago                                                                     
 updated to 86ebfcaaf986edba5e77ede1be4d3c4ce33bd27c (2df7cd9) 1 month ago                                                                     
 updated to fa9bdff35d76903f72cf24f1cccca812ae26e98c (34014a6) 1 month ago                                                                     
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

You can see here how non-perfect my changes are, I’m being vulnerable here.

I can now grab the value Head in the above table, and use it in jj, by running jj new 34014a67b0ddc859d95e17ffc71c1ae61aff5758. This will drop me onto a new change after 34014a67b0ddc859d95e17ffc71c1ae61aff5758, and then I can use jj log -r ::@ to see all the previous changes.

Again, I use the wonderful jj edit command, or perhaps I make new changes that I then jj squash into the relevant changes – it all depends on the scope of the change!

Once I’m done, I push HEAD to another special refspec, using the patch’s full identifier:

git push rad HEAD:patches/c54883e5ffb1f8a99f432e3ac79c0b728cd0dab3 -f

We use -f if we are editing the changes since this will change the underlying commits and git will reject this. Once again, this will open my $EDITOR and I will add a message about the changes that were made in this update.

This creates a new “revision” for the patch, preserving the older revisions. So essentially, patches in Radicle are append-only. This makes it safe for us to make edits to changes, marking them as mutable – the Git history will be preserved!

Maintaining Patches

From the maintaining perspective, the flow starts off similar to updating, where I would look up the patch that I want to merge. If I made the patch, things are a bit easier because the Git objects are easily accessible and I can do jj new using the commit. If I attempt to do this with a patch that came from another contributor, then I may run into this issue:

$ jj new 7322e3ac61669ba6dbde16bb0f7d30edf1ee85ce
Error: Revision `7322e3ac61669ba6dbde16bb0f7d30edf1ee85ce` doesn't exist

The way to do this instead, is to use the remote syntax and the special patches reference:

$ jj new patches/3219ef871dd44c7ef51693f4aeba4c2c5c0c5c7b@rad
Working copy  (@) now at: ooxzsqoy eb9e0803 (empty) (no description set)
Parent commit (@-)      : swpyssrk 7322e3ac patches/3219ef871dd44c7ef51693f4aeba4c2c5c0c5c7b patches/3219ef871dd44c7ef51693f4aeba4c2c5c0c5c7b@rad | node, cli: remove predefined bootstrap nodes

At this point, I can also look at what commits are in the patch via rad patch show, or by using jj log -r ::@. If they’re already on top master@rad, then to merge the patch I can simply git push rad master – and the remote helper marks the patch as merged if the canonical reference of master is update (a topic for another time).

If the patch isn’t on top of master@rad then I can rebase the changes using jj rebase -d master@rad -r <base>::<head> to get the series of changes on top of our latest. It’s then necessary to push a new revision to the patch so that the patch can know it is being merged with the new commits – remember that I rebased, so this changes the underlying commits.

We should update our master bookmark, and this is where the tug alias comes in. When I run jj tug, it figures out that master is the closest bookmark and pulls it up to the latest change that can be pushed. I can then push to update the patch:

git push rad master:patches/3219ef871dd44c7ef51693f4aeba4c2c5c0c5c7b -f

Here I’m using master instead of HEAD – this gets around a little issue I’ve been seeing for patches that I do not own, where the remote helper rejects the push because it cannot resolve HEAD (a mystery left for another day).

Once the patch has been rebased, I can do the usual git push rad master to update the canonical reference and have the patch marked as merged.

Conclusion

And our adventure ends here. We dived into how Radicle works with Git, how Jujutsu works Git, and how I use Jujutsu to have a branch-less flow in Radicle. This is has been a dream to work with. This type of tooling feels like it enables me a lot more when managing my changes and keeping a clean history. I was able to do this with git rebase, but it felt like it got in the way more than it enabled me – and I haven’t even touched on how conflicts are easy in Jujutsu!

There is plenty of room for improvements here, some things on my list are:

Come help in discussion on our Zulip, and enjoy being Radicle 🌱👾