It's quite a common opinion that git (while a big improvement on what came before) still has plenty of rough edges, particularly with regards to the user interface. At the same time, there's also a significant barrier to entry for a new version control system with all the tooling that's built up around git over the years, particularly if you want to try it in your workplace without migrating the entire company.

jj is one of the latest round of git-compatible version control systems which allows you to have a better experience locally, without having to abandon everything that depends on git. I've been trying it out now for about two months, and this post shares some of my thoughts.

My background with version control systems

I first learned of the concept of a version control system from a Coding Horror blog post on SVN back when I was still in school in 2007. I used SVN for a few personal projects, and even pushed some to Google Code.

Some time in the intervening years, probably on Hacker News, I started hearing about this new DVCS thing. However it was hard to get Git running on Windows, which I was using at the time, so I initially ignored it (as much as Github did look nicer than Google Code)

A few years later, Joel Spolsky published the sadly-now-offline hginit. This was a guide for Mercurial (hg). Mercurial had a clone of the SVN UI I was using (TortoiseHg), was much easier to install on Windows, and hginit explained it in a way that was much easier to understand. hg even had a github-like hosting site in Bitbucket, which even had free private repos, unlike Github at that time.

I actually held onto hg longer than most outside Facebook, but the writing was on the wall and I mostly migrated to git around 2014 - this was also when I started working professionally, and my workplace had just undergone a SVN to git migration shortly before I joined. That said, I still had some personal hg repos until bitbucket shut down their hosting support in 2020.

Despite not really using the advanced features of hg, and later becoming the person who received a lot of the git questions in future teams, I always felt that hg had a much more usable UI and it seemed unfortunate that git never got there. Still, it would be swimming against the flow too much to use something like hg-git, so I basically used git and learned its rough edges and that was more or less fine for the next several years.

Use Cases

I have three use cases for a version control system:

  1. Work. The company I work for, Personio, has a few different types of repo - a legacy monolith PHP application with 10s of commits a day, a monorepo for frontend services with similar traffic, and a number of backend microservices with less frequent changes.
  2. Open Source. I've got a couple of open source libraries like kdbx-rs which occasionally recieve third party contributions on Gitlab.com or Github.com but are mostly maintained by me
  3. Side projects. I have personal private side projects that I work on that are mostly just me, but even then I sometimes have branched work or work on different devices. This website is one :)

A change in the landscape

Sapling was my first big sign that there was a new wave of VCSes around that might provide a new option. While the likes of Fossil and Darcs had always been out there, having to convince everyone to abandon Git (and especially Github for open source or Gitlab in companies) meant that trying out a new VCS yourself felt like a waste of time.

Sapling is Facebook's fork of Mercurial, with a number of major QOL features built in and the backend replaced by Git. This means that you could use Sapling on a project while all the rest of your team mates use Git and all your git-based CI tools will continue along happily.

Earlier this year I decided I would try out one of the new wave of VCSes, to see what I was missing.

Sapling is the most mature of these git-compatible replacement solutions, but it had one fatal missing feature which meant I couldn't use it in my workplace - it didn't support SSH signing of commits.

jj (despite generally being less mature/feature complete) did have this, so I decided to experiment with jj. In the last 1.5 months, I have now ran a single digit number of git commands as I started using jj everywhere.

jj vs git

jj has two backends, the native backend and the git backend. While the native backend is tested for use in jj, given the world of tooling and hosting options out there, it's assumed that you'll be using the git backend. So for most users, jj functions as a layer on top of git.

The biggest difference between jj and git is that git revolves around commits as the main unit of change, while jj revolves around changesets. Unlike a commit, a change set provides a stable identifier around a set of changes even as those changes are revised, which is both convenient for CLI use and enables the other improvements around merging and automatic rebasing. For example, if you revise a commit, then the commit ID changes, but the changeset ID stays the same. This means jj knows to rebase all subsequent changes on that updated commit. It's a bit like commit --amend anywhere, and without the two steps of commit --fixup and autosquash.

Another major difference between jj and git is that the primary method of branching is anonymous branches. This might be more familiar to hg users, and depending on your workflow might be anywhere from very convenient to irrelevant. In particular, if you need to make MRs with a system like github, then you'll probably still be naming your branches with the bookmark[1] feature. But you don't have to, and if you're using a trunk based development workflow or a tool like gerrit, this saves an extra step.

jj generally tries to reduce the number of concepts a developer needs to understand. This comes through in both the simpler UI, and in the smaller number of concepts to deal with.

Some examples:

  • stashes in git? Just use commits.
  • Does a system need both fast forward merges and rebases? jj goes with just rebases.
  • Do you need a bunch of commands for different types of merge, or can you just create a new change with a list of parents?
  • Working copy? Just a commit.

To elaborate on the idea that the working copy is just a commit — every jj command updates that working copy commit to include all files from your checkout. [2]. I've seen many of my colleagues who default to git add -A or git commit -a so for them this kind of change would be nice, but if you're someone like me who used to deliberately assemble commits with git add, you'll need to learn a new workflow (jj offers two options – jj split which might be more familiar to those used to git add [-p] and the jj squash workflow which more builds on the fact that the working copy is just a commit).

A final difference worth commmenting on is that the jj log command is smarter by default than git log. As with Sapling, it will show you an abbreviated tree of all the commits you care about (tracked branches, the trunk, and local branches, though this is configurable). This is more useful than the default in git of just showing the ancestors of the current HEAD commit.

The Good

A developer of a competing (but not git compatible) VCS described jj as a better git rerere, and while that was meant as a criticism on how far it can diverge from git, it is a very nice feature. On more active repos like the monolith application, I've often got into the situation of fixing the same merge conflict repeatedly and jj so far has handled the situations which would cause that much better. It even manages to handle this despite my workplace enforcing squash merges on MRs with just one comment. It makes working on multiple changes, including some dependent changes, much nicer.

Similarly, Steve Klabnik's in progress tutorial clued me in on the flow where you can rebase all your branches at once when there's new updates. This also showed me that you can also work on the merged state of all your changes, so you can know they'll all merge cleanly into the final result, even if things are broken up into smaller PRs for the sake of your teammates reviewing it. Previously this was painful enough that I'd often find something else to work on while some changes were awaiting review.

The CLI is a lot more consistent than git and so was very quick to pick up. There's a serious effort to keep the number of command and concepts down, without ending up like the very overloaded git checkout and git reset commands. (Or even like git switch and git restore, which while they have a better seperation of concerns, can still be unclear).

Having concepts like the stash and the working copy be "just a commit" does wonders for simplifying the mental model. I was a heavy stash user, both for handling context switches, and for things like having an easy way to apply a debugging config, and having that concept represented by commits makes it much easier to deal with when "stashes" don't cleanly apply. With git stash you basically just need to recreate the stash if you don't want to deal with fixing the conflict every time but with jj you can just rebase your changeset. You could make yourself use commits for this in git, and I've tried at times, but it's really swimming against the tide.

I think I arrived just after they made jj split nicer to use for splitting changes into seperate commits, but I do suggest that you try the jj squash workflow. It's a little different to how you work with git (or what a squash workflow is in git, for those who are not fans of the common pattern of squashing a branch into a single commit always) but it was pretty easy to get used to.

Revsets are nice. They're not the as big to me as they seem to be to others, but I always have terrible trouble remembering if it's HEAD^^ or HEAD~~ or HEAD^2 or HEAD~2 etc., (or which need shell escaping) for referring to a few commits ago. The revset language seems more logical to me.

The Bad

jj is still under active development, and so some features are missing or undecided on if they'll ever support them. For example, it's not clear if tag is totally needed as a different concept to bookmark so jj doesn't support them. But e.g. the Gitlab/Github UI for releases expect tags, so I had to drop down to git to tag v0.5.2 of kdbx-rs earlier this month.

This also means there's a decent amount of churn. For example NixOS 24.05 comes with version 0.17.0 which is about 4 months old, while homebrew comes with version 0.22.0 which is a couple of weeks old. There's enough drift that commands that work in one do not work in the other, so I resorted to installing 0.22.0 from nixpkgs-unstable for my personal machines.

This also applies to core concepts - for example, the latest release renames branches to bookmarks and all their associated commands. jj branch still works for now and prints a warning about it being deprecated in favour of the renamed version, but this is the level of early development jj is at.

For SSH support, jj has two options. There's the inbuilt libgit2 ssh (based on libssh), or if there's a running ssh-agent, it will connect to that agent. My experience is that the the libgit2 ssh stuff is effectively non-functional on any of my setups. It probably works if you have a single unencrypted key at ~/.ssh/id_rsa or ~/.ssh/id_ed25519, but between keys in password managers at work and SSH certificates in my personal environment, I don't fit into that bucket, so I need to make sure all my keys are loaded into my ssh-agent before performing jj git fetch or jj git push.

There's still a couple of rough edges. For example, on one of my repos, I had renamed master to main some months ago, but started working on a checkout that still had a local master branch. jj decided that was the main upstream branch, and then when I fetched from the remote, that deleted the master branch and broke... basically everything in jj in that repo. I filed a bug report and in fairness to the jjdevelopers, there was a MR merged the same day to check for this error state and give resolution steps to the user.

CLI confusion is also still sometimes present. One example is that many jj commmands can take a -r revision parameter or a combo of --from and --to to operate on a range, but -r can also refer to a range. It feels like a lot of commands which take --from a --to b could just take -r a..b instead, but that often doesn't work or does something different.

The Ugly

jj has two modes. There's the colocated mode, where the underlying .git folder is directly exposed so the repo appears to jj-unaware tools as a git repo (though one with a dangling HEAD pointer), and the seperated repo where the .git folder is hidden within the .jj folder, or entirely elsewhere on disk. Generally, it feels cleaner to use the mode where jj manages the git repo out of sight - for example, my shell git prompt is basically noise for a dangling HEAD commit and my editor will proudly tell me about how much it has staged even though jj doesn't use the staging area.

However, one tool which really doesn't work well with the .git folder hidden is Nix, specifically with flakes. flakes piggy back on git for a number of important details like tracking input files, and in the absence of the .git folder will fall back to storing everything in the git store. And I really mean everything. The .jj folder, the .git folder that is undereath that, those files you've .gitignore'd like node_modules or .direnv directories (which might appear to contain e.g. all of nixpkgs, multiple times). This makes nix operations very slow and disk space heavy, which is especially frustrating if you use direnv as it makes your shell prompt very slow.

There is a fix for the nix interaction though, which is to just always use colocated repos if you're using Nix. And this is more on Nix than it is on jj, it would be good to have a flakeignore or have it only import paths referenced in flake.nix into the store, but given I've previously done a multi-part series on Nix Flakes, I figured I should warn people about this poor interaction.

What next?

I still intend to try out Sapling at some stage. There's a comparison on the jj website, but it was nice for jj that I could make the change everywhere, all at once while Sapling is not yet usable in my work setting. But maybe I'll try it on some personal projects or maybe their SSH signing setup will become flexible enough to accomodate my employer's setup.

Other than that though, jj has proven itself enough that it's going to be how I interact with git from now on.


  1. bookmark is recently renamed – previously bookmarks were called branches

  2. For those of us with large repos who might be worried about the perf implications, the good news is this does support watchman