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:
- 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.
- 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
- 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 branch
es to bookmark
s 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 jj
developers, 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.