Sarthak Agrawal's Blog

From git to jj (jujutsu)

Preface

I had heard about jj (short for jujutsu) a couple of years ago from a colleague but never paid much attention to it. jj is yet another vcs, something you used to version your code and manage changes. It claims that it has a much simpler mental model than git, a vcs that is both "simpler and more powerful" than git. This is a very strong statement. In software engineering, usually adding more features means making the system more complex; to get to a state where you have both a simpler and more powerful model means a dramatic architectural rewrite. But why should I care? git is the de-facto vcs, I have muscle memory on it, everyone else also knows it, and if you are a beginner you are better off starting with a tool that everyone knows than a tool that no one knows, even if it's simpler. And so I never thought more about it, until last month.

jj helped me collapse a painful stacked-branch rebase into two simple commands, and I am eager to walk you through it. If you have ever used stacked branches, you will feel right at home.

git is painful for rebase-heavy workflows (stacked branches)

I use git extensively in my job, but despite all the familiarity there are some workflows that are just suboptimal in git. For example, I heavily use stacked branches.

What are stacked branches? They're a series of branches built on top of one another, each based on the one below it rather than directly on the trunk. To see why you'd want that, it helps to understand how branches get reviewed and merged. In all the organisations I have worked in, a git branch, and not a git commit, is the unit of code review. In GitHub you raise pull requests, which represent a diff against two branches, typically a feature branch and a trunk branch. A reviewer will review the diff, and when they approve you are allowed to merge the changes into the trunk and discard your feature branch. What happens to commits in the branch? Typically a reviewer will not care about individual commits, or their messages, much. The pull request description is the canonical source of truth for what the branch represents, and the branch is the unit of merge. You can write beautiful commit messages, or write "wip", "fix", "whoops", and it is all the same in that neither matters. When the branch is merged though, what happens with commits is up to the merge strategy, which is an organisational policy. All organisations I have worked in follow a squash-and-merge strategy where all the commits in a branch are squashed into a single new large commit, whose message is the pull request description, and this commit gets authored into the trunk. Your branch and all its commits are then promptly deleted from the git remote.1

Since a branch is both a unit of review and merge, for large feature work you want to split your changes across multiple branches built on top of each other forming a stack. The bottommost branch in the stack is based off the trunk, and the other branches build on top of one another. This keeps the incremental diff in each branch small, which allows for both incremental deployment and better reviews.

master --- A (B1) --- B (B2) --- C (B3)

Here B1, B2, B3 are the branches, each pointing at commits A, B and C respectively, stacked on top of master.

The big problem with git is now as follows. Suppose you have branches B1, B2, B3 on top of the trunk branch master, pointing to commit sets A, B and C respectively. What happens when a reviewer reviews the branch B1 and requests some changes? I will go ahead and maybe add a new commit on top of A, and move B1 to point to it, or maybe I just amend the existing commit A. Regardless, I now need to rebase commits of branches B2 and B3 on top of the revised A. There is no clean one-shot way to do this, the naive sequence looks like:

git checkout B2
git rebase B1
git checkout B3
git rebase B2
git push --force-with-lease origin B1 B2 B3

It is actually more complicated than this if you are amending commits. Suppose I amend the commit A so that its SHA now changes to A'. B1 is now pointing at A', but B2 is pointing at (B -> A), whereas it actually needs to point to (B' -> A'). A simple git rebase B1 may not work here as git doesn't know that A has been rewritten to A', so rebase will try to replay A & B on top of A' and then end up in a messy conflict. The trick then is to do an interactive rebase (git rebase -i) where you can choose which commits to replay. Alternatively, you can use the --onto flag (e.g. git rebase --onto B1 A B2) to replay only B2's own commits onto the new B1, skipping the old A entirely.

before amend:        master --- A  (B1) --- B  (B2) --- C  (B3)

after amending A:    master --- A' (B1)
                            \
                             A --- B (B2) --- C (B3)   <- old A still here, now orphaned

what we want:        master --- A' (B1) --- B' (B2) --- C' (B3)

The complexity of this operation grows linearly with the size of your stack. Even if you are a perfect engineer whose PRs don't need any review comments, when your bottom branch B1 goes to merge, you still need to rebase the next branch in the stack (B2) to replay it on top of trunk, and then consequently rebase all other branches again on top of each other. And if your org uses squash-and-merge, this is worse: B1's commits no longer exist on trunk as-is, they've been collapsed into a single new commit, so a plain git rebase will try to replay B2's now-redundant commits and conflict. You're forced into an interactive rebase or --onto for each branch in the stack, which is much more work.

Since this is a common enough workflow, and largely mechanical work, people have built plugins & tools to automate some of this. One such tool that I used is av CLI. The idea is that these tools track the relationships between branches in another database, and then you use av commands like av restack when you are done making changes to the bottom of the stack, which will automatically do something similar to what I showed before. Even better, it doesn't matter if you amend the commits or do a squash-and-merge as av will track all of that and smartly do the right set of operations needed to rebase correctly.

This mostly works except that:

  1. You have to live in the constant fear that av may corrupt its state or you hit a weird edge case they have not accounted for, and then it does an incorrect rebase and you lose all or some of your commits irrecoverably (this has happened to me more times than I would have liked; fortunately now I can just ask an agent to recover the commits for me using git reflog).
  2. Only linear stacks are supported,2 and this is actually a big one. I want my branch relationships to reflect the logical code dependencies — does the code on this branch actually depend on the branch beneath it? Real work isn't always a straight line. Say I have some prerequisite refactoring to do before building a feature, and that prep work is itself large enough to split into two independent branches, A and B. Neither depends on the other, but my feature branch C depends on both. The dependency graph I actually want is master → {A, B} → C: A and B sit in parallel on top of trunk, and C builds on the merge of the two. But rebasing C whenever I revise A or B is genuinely painful because of the merge, and no tool I know of handles this. The workaround is to just linearize everything — stack A, B, C in a line even though A and B are independent — but it's suboptimal.

Why jj shines for rebase-heavy workflows

It turns out not only can jj solve all of this pain, it is so simple that I don't even need to use third-party tools to achieve any of this. Truly, "simpler and more powerful" at the same time. To see why, it helps to understand the one idea jj is built on.

In git, a commit's SHA is a hash of its content and its parent.3 That means the SHA isn't just a name for the commit, it is the commit's identity, and it is derived from everything below it. So the moment you amend a commit, you don't actually modify it, you create a brand-new commit with a different SHA and abandon the original. Worse, every descendant's SHA was computed from that parent, so all of them are now different commits too. This is the root cause of the rebase dance from before: git was never moving your commits around, it was making fresh copies and leaving you to manually re-point every branch at the new ones.

jj separates the two ideas git conflates. Every change gets a stable change ID that stays constant across amends and rebases, while the content hash underneath is free to change. When you amend a commit, its content hash changes but its change ID does not. The commit keeps its identity even as its contents and position move. You can take a commit and move it around into any other place in the commit graph, like you are dragging it around.

This one decision is what makes everything else fall out for free. Because identity is stable, when you amend or rebase a commit, jj can walk its descendants and re-create each of them on top of the new parent automatically, and any bookmark (jj's name for a branch) pointing at those commits still points at them, because the change IDs never changed. Nothing needs manual re-pointing. So in our earlier example, with commits A, B and C stacked on one another, you just amend A and jj rebases B and C for you. Then you run jj git push and every updated branch lands on the remote in one sweep. The six-command rebase dance is gone: managing a linear stack is now a constant two operations (edit, then push) instead of a careful, branch-by-branch sequence that grew with the size of the stack.

That handles the straight-line case, but the real test is the non-linear one I complained about earlier — branches that fan out and merge back together. Here jj's model has a second nice property: a merge is not a special operation, it's just a commit that happens to have more than one parent. You declare one with jj new A B, which creates a commit that is a merge of A and B. Unlike git, you don't merge one branch into another; both parents are equal participants. In git, git merge B2 while on B1 is asymmetric — B1 moves to point at the merge commit, B2 doesn't. In jj there is no such asymmetry: jj new A B produces a commit that isn't even associated with a branch (it is "anonymous"), and it treats both parents identically. Because a merge is just an ordinary commit, the automatic-rebase machinery from before applies to it with zero special handling. Amend A, and jj rebases its descendants — including any merge commit below it — exactly as it would anything else. The merge of new-A and old-B is reconstructed for you, and the bookmarks, with their stable IDs, follow along.

So you can have an arbitrarily complex DAG: multiple parallel branches merging and diverging as many times as you like, as large as you can imagine. No matter where in the DAG you make a change, jj rebases everything below it for you, for free, and a single jj git push brings the remote up to date. The master → {A, B} → C graph I said no tool could handle? In jj, that is just the default behaviour.

jj is not just a better git

Once the core model clicks, you start noticing things jj lets you do that git simply never offered. In a month I have barely scratched the surface, so I am sure there are many more I haven't met yet, but a few have already become part of how I work.

Did you ever want to check out multiple git branches at the same time? Well, jj just lets you do that. Say you have four independent branches off trunk and changes to make across all of them. In git you'd check out one at a time, commit, switch, and shuffle back and forth. In jj you make a single working commit on top of all four — jj new A B C D — and now you're effectively working with all four checked out simultaneously, editing whatever you like across them. When you're done, jj absorb does some git-blame magic to work out which hunk belongs to which ancestor and folds your changes down into the right commits automatically. If you want more control you can squash and rebase the pieces down by hand instead, but absorb handles the common case beautifully. Either way, once the changes are absorbed you discard the working commit with jj abandon — it was only ever a local view over the graph, never something destined for the remote. Being checked out on four branches at the same time still feels like a small magic trick to me.

Another one is splitting a commit into independent pieces. Remember the prerequisite work from earlier? Often I'll start some prep as a single commit and only realise partway through that it is really two unrelated changes that would be cleaner modelled separately. jj split lets you break a commit apart, and the split doesn't have to be linear — if the two sets of changes are independent, you can split them into parallel sibling commits rather than a chain. So a commit sitting between A and C can become two siblings B1 and B2, with C turning into a merge of both (merges are not special, remember, just commits with two parents). And as always, everything below the split rebases itself automatically.

Then there are features I genuinely did not ask for but have grown to love, to the point I can't believe I survived git without them. You need to taste the power to feel it. The one that won me over is revsets — a little DSL for describing a set of commits. Almost every command accepts one, but I lean on it most with jj log. jj log -r 'all()' shows every commit in the repo; jj log -r 'mine()' narrows it to just mine. My current default is @ | ancestors(trunk()..(visible_heads() & mine()), 2) | trunk(), though everyone seems to settle on their own. At first the DSL looks like one more thing to learn, and if you're a busy engineer who doesn't feel like mounting the learning curve for yet another query language right now, you don't have to — I just describe what I want to see and have my agent write the revset for me. You get the power immediately and can pick up the syntax whenever, or never.

Should you switch?

I feel jj lives up to its claim of "simpler & more powerful", and is a must-use tool for any engineer who likes their commit graphs clean. I came to jj because of stacked branches, but once I was there I kept discovering things it also did well — checking out multiple branches at once, splitting commits, revsets, and more I haven't even mentioned. Any one of these is a good enough reason to switch. On the other hand, if you have never felt the need for any of this, maybe jj is just overkill for you.

If you do decide to try it, know that it interoperates with git extremely well. You can use it on any git repository, even existing ones, with no need for a fresh clone. Your co-workers don't need to know (and won't know) that you are using jj and not git, so adoption becomes a personal choice rather than an organisational one.

There is one rough edge worth flagging: tooling. IDEs like VS Code don't support jj as maturely as they support git, and newer editors like Zed are further behind still. The ecosystem is young. But for me the power is more than worth the inconvenience, and it's just too good to let go.

What strikes me most is the timing. I'm seeing new tools emerge like GitButler — a fresh, well-funded vcs frontend built around modern agentic workflows, whose headline feature is letting you (and your AI agents) work on multiple branches at once. It's a great idea. But it's also exactly the jj absorb workflow I described earlier, which jj has had all along. So if multi-branch parallelism for agents is what you're after, why reach for the brand-new thing when jj — older, more mature, and rock solid — already does it?

  1. The primary reason for orgs preferring this merge strategy is that it keeps the trunk history cleaner (no more wip, fix commits). A lot of engineers don't write good commit messages, or will not spend effort in structuring their changes in a logically coherent set of commits, and this strategy sidesteps the entire issue nicely (pull request descriptions are more sacrosanct, and reviewers routinely block a PR if its description is not representative of the change).

  2. By linear stack I mean every branch has exactly one parent — a straight chain from trunk, with no merge commits anywhere in the stack.

  3. I'm simplifying here. A git commit hash actually folds in a number of other things too — the tree, author and committer details, timestamps, the commit message, and so on — but the parent is the part that matters for this discussion, since it's what makes descendants cascade.