I still stand by my testimonial! My greatest achievement has been convincing Steve Klabnik to try out Jujutsu.
I also saw a comment from JJ's lead author martinvonz, where he pointed out that adding new functionality is much simpler to jj than it is to older systems like Git and Sapling/Mercurial. Having each spent many years working on source control, both Martin and I came to a general belief that a lot of implementation complexity comes from the modal states created by merge conflicts. Because Jujutsu's core UX is more straightforward, this is less of an issue and Jujutsu's devs can prototype changes quicker.
I came across jj from a recent Bluesky post by Steve Klabnik who was talking about having to learn something with git. That seemed very odd to me. I then gathered that he (and many others in the comments) had been using jj exclusively for some time.
I haven't had time to give it a try, but I definitely will. Your achievement has ripple effects.
Can anyone explain why people want something like JJ? Is it simplicity? I actually quite like the staging and index in git. Although it took me a while to grok.
Looking at the other thread though I don't think it's more simple. It's just different. https://news.ycombinator.com/item?id=43020180
Same with the working copy.
Significantly nicer UI, very simple but powerfully expressive DSLs for formatting logs and selecting revisions to log, respectively.
For me it clicked when I really grasped how the latter DSL (called the revset language) wasn't just for selectively logging, it was for selecting commits/changes to act upon. The revset grammar is about specifying subgraphs of the graph of changes. Using it, and taking advantage of jj's by-design automatic rebasing of downstream commits, you'll eventually think nothing of rebasing N branches in a single command.
Even conflicts are less trouble when they crop up during auto-rebasing. jj leaves conflicts on the change where they originated, and marks that commit (and all downstream ones) as conflicted. Fixing conflicts is no longer a "drop everything and fix" matter; you can decide when to fix them without delaying work on other parts of the tree.
Haven't even gotten into the optional auto committing on every file change (it's better than it sounds). You might miss the staging area for a bit, and then you realize that there isn't really a difference between the staging area and a "current commit" that stays up to date on its own.
I guess what I'm saying is I would recommend it.
"Oh I forgot to make a change in this commit and I'm 4 commits in..." -> "I can go change that commit and everything else magically rebases on it".
It treats changes as this conceptual thing, and the whole immutable commit thing as a backing store but not how _you_ are looking at a problem. Or at least not how I look at it.
I added a test, put in a new feature, updated the readme. Those changes are not about all the metadata around the change, they're the change. Of course you still have the immutable commit graph when you want to _really specifically_ talk about a certain commit, but for most intents and purposes it's the changes that matter.
jj gives you similar workflows to git in the easy case. In the hard cases, jj makes it easier to do the work than git. So it's nicer!
I hit this today in a fun way. I was working along as one does, and needed to write a parser for a simple s-expression based language. So I did that fairly quickly, added a few tests which appeared to pass, and kept on working.
A few commits later I reached a point where I needed to manually test my code. And promptly hit a stack overflow in the parser. A few minutes of debugging I understood the issue (I'd used the wrong function and failed to require brackets around a nested s-expression) - except I could have sworn my tests should catch it. So I ran my tests manually, and lo and behold they did catch it.
Turns out that there was a bug in the tool I was using to run my tests [1] that was masking the test failure. I had completely broken my tests for the last few commits (all of them since I wrote the parser).
Anyways, I went and fixed the tool, but by now on my main repo I had fixes for the last few commits all mixed together with a fairly large WIP commit. Thankfully instead of making this the frustrating game of rebasing with git, jj makes it just take a few jj split (select changes you want to split off, sort like git add -p); jj squash (merging changes into the existing commit) commands.
Could I have fixed up the previous commits with git? Of course. But it would have made a frustrating hour even more frustrating.
There's other functionality in there too, including just straight editing old commits without having to deal with the child commits yourself.
It's `git rebase -i`, and edit a file to tell it what to do, and work in a limited rebase-state of git. Versus `jj squash --into <commit>` and if doesn't merge nicely, or it causes conflicts, you stay in the normal state with the normal tools. It's a difference in quality of life, of reducing friction, not the power of the tools.
Even the `git add -p`s interface is also just less nice than `jj split`'s interface. Where `git add -p` feeds you changes one at a time and makes you select them, `jj split` gives you a interactive selection gui that lets you scroll through changes, collapse and uncollapse files, select and unselect whole files, single diff sections, or even indivudal lines. It's nicer.
> including just straight editing old commits without having to deal with the child commits yourself.
This is sort of an example. Git needs a special mode, invoked via this file-based interface in `rebase -i` (though I imagine there's a more direct command that no one knows as well). While in that mode you can't do things like just switch to another branch and work on something else. It's "strange".
In jj this is just `jj edit <commit>`. It's within the set of operations that are "normal" in jj's model. You can do everything you can normally do.
If something like a merge conflict happens git says "and how you need to deal with this right away". jj says "these commits have merge conflicts in them, fix them if/when you like".
Every small piece of my interaction feels slightly better than the same small piece of the interaction with git, and it's remarkable because it really does feel like it's every piece of the interaction with jj that is better.
I don't feel like I'm being very eloquent - but hopefully the difference I'm trying to describe is coming across at least to some degree.
There are a couple of different ways that people use JJ, but I normally use the "squash" approach. When I want to make a new branch/pr/etc, I run `jj new master` (to create a new change off the master bench), and then I run `jj new` again. This gives me two empty changes - the older change is where I'm going to store my work when I'm finished, and the newer change is where I'm going to actually be working. This functions like staging. As I edit files, the staging change fills up. When I think I'm finished, I can run `jj diff` to exactly what's in my staging change. If I want to keep all of it, I can run `jj squash` to squash the entire change into the parent change, or I can run `jj squash -i` to interactively squash the bits that I want to keep, and keep the rest in staging.
You might argue that we've just reinvented the wheel - we used to have a pseudo-commit as our staging/index, and now we have a JJ change, but we're still doing the same thing with it. This is true, but the value of having our staging area be a change, i.e. a first class entity in our VCS, is that we don't need to special case it any more. Every command that works for our staging area works for any other change in our history. This is simpler because there's less stuff going on (fewer commands, fewer concepts), but we still have the same capabilities as before. On top of that, because our staging area is just another change, it is automatically stored in the local repository history. This means that if I've got stuff staged and I want to create a new branch somewhere else, I can do `jj new master`, and the stuff I've got staged will stay where it is. This also removes the need for an explicit stash: if all the work I'm doing is already part of a change, I don't need to create temporary "stash" commits to store them, they're already stored. Again, fewer commands, fewer concepts.
Just by itself, I find this feature really helpful because it simplifies how I need to think about changes a lot. It's so much easier to navigate around a repository like this. But it's just one of the features that has been simplified from existing VCSs. For example, you've also got automatic rebases - you can make a change to an older change (either directly or using something like `jj squash`), and all the parent change will automatically get rebased. Sometimes this causes conflicts, but it never fails. Instead, conflict tracking is part of the history itself. This allows you to fix conflicts in your own time - you're not put into the "rebase world" like in Git, where Git needs to statefully remember that it's currently doing a rebase, and you need to use different commands to resolve a rebase conflict than you need to commit a merge conflict, say. No, conflicts are first-class, which again massively simplifies how you interact with the repository - fewer commands, fewer concepts.
This is what I think people mean by simple - it's not simple in the sense of "make everything easy by wrapping it in an abstraction", instead it's simple in the sense of "find the ideal underlying model to expose to the user". Version control is always going to be fairly complex, but JJ feels like something that is very close to the minimum complexity required for a VCS: no simpler (because then it wouldn't work very well), but also but very much more complex.
* "Change" in this context refers to JJ's equivalent of commits, i.e. the individual rows you see when your run `jj log`.
As a minor update - sapling now also supports the .git on-disk formats so that you can use git and sl interchangeably in the same repo
jj always, automatically turns your current state (even if it's empty) into a "change" which is like a commit (it has a hash). in practice, this was actually incredibly annoying. when working in git, you often think about the current commit you're on, and amending/making a new commit. with jj, you're actually making "changes" (which are like commits) constantly. it's also so infuriating that there are two sets of hashes, jj changes and git commits, and they are not the same/interchangable.
on the flip side, sapling has been a breeze. it does force you to one commit per PR (which might be annoying to some but if you're going to squash the commit into main when you commit, why not do it locally too). its inter-op with github is really nice, you can do `sl goto pr1234` and it will just bring you to the code for pr #1234 (and fetch it in the process if you don't have it locally).
I don't know what this means, and it seems like a fairly large misunderstanding.
As a 1+ year jj user now, I can’t think of a single time I’ve needed to use a git hash. They’re there underlying things, but you’re always just using revision IDs.
I've been considering trying out jj and thought it could work since it uses git as store, but if I need to keep track of different hashes based on if I access version control from my IDE (primarily IntelliJ for now) or from my command line, that seems like an actual problem to me.
(I'm also used to work in the command line and I prefer it to point and click for many things, but version control is one of the things that I much prefer do approach from the same place I write my code.)
- I am absolutely loving the improved UI/UX for common operations - being able to do the same actions with fewer commands and fewer concepts to understand, and much more helpful error messages when I try to do something invalid
- The existence of some unique features (`split`, `absorb`, and `restack` being particular favourites -- IIRC people have created third-party scripts to replicate these commands for git, but last I checked they weren't as good, and they aren't installed by default)
- Having the commit log integrated with github (being able to see which of my branches match to which PRs, and whether the PR is unreviewed / accepted / rejected / merged)