The Jujutsu version control system

December 12, 2024

Jujutsu is a new version control system that seems pretty nice!

The first few times I tried it I bounced off the docs, which to my taste has too much detail up front before I got the big picture. Someone else maybe had a similar experience and wrote an alternative tutorial but it's in a rambly bloggy style that is also too focused on the commands for me.

I suspect, much like writing a monad tutorial, the path of understanding is actually writing it down. So here's an attempt from me at an introduction / tutorial.

Perhaps unlike the others, my goal is that this is high-level enough to read and think about, without providing so much detail that it washes over you. Don't try to memorize the commands here or anything, they're just here to communicate the ideas. At the end if you're curious to try I recommend the docs found on their website.

Overview

Omitting details, you can think of Jujutsu (hereafter "jj") as a new Git frontend. The underlying data is still stored in Git. The difference is how you interact with your files locally, with a different conceptual model and a different set of commands.

Git quiz: are commits snapshots of file state or diffs? The technical answer is subtle — as a user you usually interact with them as diffs, while conceptually they are snapshots, but concretely they are stored as deltas. The more useful answer is that thinking about the details obfuscates the conceptual model. Similarly, to describe jj in terms of what happens in Git is tempting but I think ultimately clouds the explanation.

In practice what this means is try to put your knowledge of Git on hold, but also be aware you can use jj and continue to interoperate with the larger Git ecosystem, including e.g. pushing to GitHub.

The big idea: everything is commits

The purpose of a version control system is to keep track of the history of your code. But interestingly in most, as soon as you edit a file locally in your working copy, that new history ("I have edited file X starting on version Y") is is in a kind of limbo state outside of the system and managed separately.

This is so pervasive it's almost difficult to see. But consider how a command like git diff has one mode that takes two commits to diff, and then a bunch of other modes and flags to operate on the other kinds of things it tracks. You can get a diff against your working copy but there's no way to name "the working copy" in the diff command. (Git in particular adds the additional not-quite-a-commit state of the index, with even more flavors of attendant commands. The ultimate Git quiz: what are the different soft/hard/mixed behaviors of git reset?)

Another example: consider how if you have a working copy change and and want to check out other code you either have to put it in a new place (git stash, a fourth place separate from the others) or make a temporary commit. Or how if you have a working copy change that you want to transplant elsewhere you git checkout -m, but to move around committed changes it's git rebase.

In jj, in contrast, your working copy state is always a commit. When making a new change this is a new (descriptionless) commit. Any edit you make on disk is immediately reflected in the current commit.

So many things fall out of this simple decision!

From a Git perspective, jj is very "rebasey". Editing a file is like a git commit --amend, and in the "fix a typo" move above the edit implictly rebases any downstream commits. To make that work out there are some other conceptual leaps around conflict handling and branches that will come below after the basics.

The basic workflow

In a Git repo:

$ jj git init --colocate

This creates a .jj dir that works with the Git repo. Git commands will still work but can be confusing.

The plain jj command runs jj log, showing recent commits. Here it is from the repo for this blog:

$ jj       
@  zyqszntn evan.martin@gmail.com 2024-12-12 11:58:52 21b06db8
│  (no description set)
○  pmnzyyru evan.martin@gmail.com 2024-12-12 11:58:48 86355427
│  unfinished drafts
◆  szzpmvlz evan.martin@gmail.com 2024-09-18 09:08:15 fcb1507d
│  syscalls
~

The leftmost letter string is the "change id", which is the identifier you use to refer to the change in a command like diff. They are stable across edits, unlike the Git hashes on the right. In the terminal the change ids are colored to show the necessary prefix to uniquely refer to them (a single letter) in commands.

The topmost commit zyqszntn is the current one, containing this blog post as I write it. As you would expect, if I run jj status it shows me the list of edited files, and if I run jj diff it shows me a diff.

I can give it a description now or when I'm done:

$ jj desc -m 'post about jujutsu'

And then create a new commit for the next change:

$ jj new

Iterating on changes

That's enough for trivial changes, but often I work on more significant changes where I might lose context across days. There are two ways you might do this depending on how you work.

The first is to just describe your change as above and keep on editing it, without running jj new. Each subsequent edit will update the change as you go. This is simple to operate but it means jj diff will always show the whole diff. In Git this is similar to just keeping a lot of edits in your working copy.

The other option is called "the squash workflow" in the tutorial book. In this, when you do new work you jj new to create a new distinct commit from your existing work, and when you are happy with it (by e.g. examining jj diff, which shows you just the working copy's new changes) you run jj squash to flush these new changes into the previous commit. To me this feels pretty analogous to using the Git index as a staging area for a complex change, or perhaps repeatedly using git commit --amend.

Moving around and editing history

These commands like jj diff and jj desc work on the current commit (or any explicitly requested via the -r flag).

To switch the working copy to an existing change, it's jj edit <changeid>. Again, any changes you make here, to the files or descriptions, or by making new changes and squashing them, work directly on the historical commit you are editing. I repeat this because it is both weird and obvious in retrospect.

Conflicts

Any operations on history cause implicit rebases that happen silently. Rebases can conflict. jj has interesting handling of how this works.

In Git, rebase resolution happens through your working copy, so there is again extra state around "rebase in progress" and git rebase --continue. In jj instead, conflicting commits are just recorded as conflicting and marked as such in the history, so rebases always "succeed" even if they produce a string of conflicting commits.

If you go to fix a conflicting commit (via jj edit as above), you edit the files as usual and once the conflict markers are removed it's no longer considered conflicting.

As usual, once you make a history edit, downstream changes are again rebased, possibly resolving their conflicted state after your edit. Again, the jj pattern of "all of the relevant information is modeled in the commits" without having a separate rebase mode with state etc. is a recurring powerful theme.

I don't have a lot of experience with this yet so I can't comment on how well it works, except that the times I've ran into it I was pleasantly surprised. The jj docs seem proud of the modeling and behavior here which makes me think it's plausibly sophisticated.

Branches

jj doesn't have named branches, but rather only keeps track of commits. Because of the way jj juggles commits, where it's trivial to start adding commits at random points in history, branch names are not as useful. In my experience so far having useful commit descriptions is enough to keep track of what I'm working on. Coming from Git the lack of named branches is surprising, but I believe this is comfortable for Mercurial users and Monotone worked similarly (I think?).

It's worth highlighting the absence of branches because in particular when interoperating with Git you still do need branches, if only to push. There is support in jj for this (where "bookmarks" are pointers to specific commits) but it feels a little clunky. On the other hand, I probably have Stockholm syndrome about the git push syntax.

What's missing: VSCode

Working with jj made me realize how much I rely on VSCode's Git support, for both viewing diffs and for merges.

When editing a given commit in jj, Git thinks all the files in the commit are in the working tree and not the index. In other words, in the VSCode UI the current diff shows up as pending changes just as they would in Git. This works pretty well and is about all I would expect. I haven't yet touched the buttons that interact with Git's index, for fear of what jj will do with it.

For technical reasons I do not quite understand — possibly VSCode only does three-way file merges and jj needs three-way directory merges? — the two do not quite cooperate for resolving conflicts. The jj docs recommend meld and I have used meld in the past but I hadn't quite realized how VSCode had hooked me until I missed using it for a merge.

The future

The author of jj works at Google and is possibly making it for the Google internal version control system. (Above I wrote that jj is a Git frontend, but officially it has pluggable backends; I'm just unlikely to ever see a non-Git one.)

When I left Google three years ago I recall they were trying to figure out what to do about either making Git scale, or adopting Mercurial, or what. I remember talking to someone involved in this area and thinking "realistically your users have to use Git to work with the larger world, so anything else you do is pure cost". I found this post from a Mercurial fan about jj an interesting read in how it talks about Mercurial shortcomings it fixes. From that perspective it is pretty interesting: it can replace the places you currently use Git, while also providing a superior UI.

In all, jj seems pretty polished, has been around for years, and has a pretty simple exit strategy if things go wrong — just bail out to the Git repo. I aim to continue using it.

PS: every time I read the name "jujutsu" I kept thinking it was a misspelling of "jiu-jitsu", the martial art. But the Japanese word is じゅうじゅつ, it's actually it's jiu-jitsu that is misspelled! Read a longer article about it.