Reviewing large changes with Jujutsu

I’ve been using Jujutsu (jj) as a replacement for Git for the past 6 months or so. Much has been said about it already, which I don’t feel like repeating, so I’ll just say: I’m hooked. It feels like I’ve grown an extra arm when it comes to crafting clear and reviewable pull requests, and I’ve been able to adopt it without my colleagues needing to.

My general coding workflow depends a bit on context—such as the size of the change I’m making—but frequently looks a lot like the squash workflow . If you haven't explored jj already, there are a lot of really good articles about it that I'd recommend reading.

Recently I’ve started to cultivate a new workflow while reviewing code. With the growing use of coding agents, the size of pull requests seems to be increasing. Whether or not that’s a good thing is another matter, but I still need to stay abreast of the new code being introduced, and for now, therefore, I need to review it.

At work, Bitbucket Data Centre is currently the Git “forge” being used. It actually works pretty well with jj because it shows you an interdiff on a PR when you edit a change and push it again. However, one area in which it’s a bit lacking compared to something like GitHub is that it isn’t very easy to track which files you’ve already viewed and were happy with. This adds a lot of friction for a large change where you frequently need to jump around between files.

With a smaller pull request, it’s easy enough to keep the full context of a change in my head, and step through fairly linearly. When it comes to a larger change, especially when you don't yet understand the overall structure of the code, it becomes very tricky to navigate and gradually sign off portions of the code you’re happy with. Furthermore, if you know you're going to have to keep in your head the context of what you've already looked at and what you are still yet to, you're more inclined to put off the review until you've got a chunk of time free.

The new workflow I’ve come up with to address this is:

Duplicate my colleague’s change with jj duplicate and "check it out" with jj edit

Create a new empty change before this new duplicated one with jj new --no-edit --insert-before @

Review the code and gradually squash pieces of it into the parent change until there is nothing left

This allows you to track progress through the review using familiar jj diff --stat commands, and to squash hunks or entire files when you’re happy with them. You're also free to switch away and do something else at any point, knowing that things will be as you left them when you come back.

A benefit of this method is it naturally means you’re in your familiar coding environment as you do the review. You are using the same IDE, tooling, and commands as when you’re the one writing the code, without needing to context-switch back to the pull request to mark files as viewed. Of course you do need to move back to the web UI at some point in order to leave comments, but I hope to improve that at some point.

Here's an example of the workflow in action. Let's say our colleague Bill has just sent us a pull request for a new application they're creating on a branch called big-change. Note the diamond symbol indicating that it's an immutable change.

/jj-reviewing-demo # jj git fetch -b big-change
bookmark: big-change@origin [new] untracked
/jj-reviewing-demo # jj log -r 'main..big-change@origin'
◆ yykxruwv bill@example.com 2026-03-01 11:54:39 big-change@origin dcf940aa
│ feat: add new Python project
~

We can see the size of the change we're dealing with by show ing it with the --stat option (like in Git). Of course it's a very small change here as I'm just demonstrating the concept so you'll have to use your imagination.

/jj-reviewing-demo # jj show y --stat
Commit ID: dcf940aabd45c210a175eacaa7477da6afe04136
Change ID: yykxruwvkwwtttqotzyrtqkoxmymytxl
Bookmarks: big-change@origin
Author : Bill <bill@example.com> (2026-03-01 11:47:12)
Committer: Bill <bill@example.com> (2026-03-01 11:54:39)

feat: add new Python project

.python-version | 1 +
main.py | 10 ++++++++++
pyproject.toml | 7 +++++++
uv.lock | 8 ++++++++
4 files changed, 26 insertions(+), 0 deletions(-)

The first step is to duplicate it into a new mutable change we can manipulate.

/jj-reviewing-demo # jj duplicate big-change@origin
Duplicated dcf940aabd45 as kowrummo 6ffd7580 feat: add new Python project
/jj-reviewing-demo # jj edit kow
Working copy (@) now at: kowrummo 6ffd7580 feat: add new Python project
Parent commit (@-) : romwosqw 718f5479 main | Initial commit
Added 4 files, modified 0 files, removed 0 files
/jj-reviewing-demo # jj log -r 'all()'
@ kowrummo bill@example.com 2026-03-01 12:28:21 6ffd7580
│ feat: add new Python project
│ ◆ yykxruwv bill@example.com 2026-03-01 11:54:39 big-change@origin dcf940aa
├─╯ feat: add new Python project
◆ romwosqw ben@example.com 2026-03-01 11:37:40 main 718f5479
│ Initial commit
◆ zzzzzzzz root() 00000000

Then we create a new empty change as a parent of the current change. The --no-edit flag means we remain editing the kow change, the --insert-before @ flag inserts the change between the current change @ and its parent, and the --message flag specifies the commit message so we can easily find it again if we switch to doing something else.

/jj-reviewing-demo # jj new --no-edit --insert-before @ --message 'review: big-change'
Created new commit ltqpxklq 92fd29a9 (empty) review: big-change
Rebased 1 descendant commits
Working copy (@) now at: kowrummo b07bd9ec feat: add new Python project
Parent commit (@-) : ltqpxklq 92fd29a9 (empty) review: big-change
/jj-reviewing-demo # jj log -r 'all()'
@ kowrummo bill@example.com 2026-03-01 12:42:39 b07bd9ec
│ feat: add new Python project
○ ltqpxklq ben@example.com 2026-03-01 12:42:39 92fd29a9
│ (empty) review: big-change
│ ◆ yykxruwv bill@example.com 2026-03-01 11:54:39 big-change@origin dcf940aa
├─╯ feat: add new Python project
◆ romwosqw ben@example.com 2026-03-01 11:37:40 main 718f5479
│ Initial commit
◆ zzzzzzzz root() 00000000

Now we can review the code in whichever tool we're most comfortable with and move files into the review commit once we've got our head around them.

/jj-reviewing-demo # jj status
Working copy changes:
A .python-version
A main.py
A pyproject.toml
A uv.lock
Working copy (@) : kowrummo b07bd9ec feat: add new Python project
Parent commit (@-): ltqpxklq 92fd29a9 (empty) review: big-change
/jj-reviewing-demo # cat .python-version
3.14
/jj-reviewing-demo # jj squash .python-version
Rebased 1 descendant commits
Working copy (@) now at: kowrummo 6aba8a2f feat: add new Python project
Parent commit (@-) : ltqpxklq b8225061 review: big-change

We're free to go away and do something else and come back, and the record of what we've seen so far will be captured in the version control history. Finally we'll get to a point when we've seen everything and the duplicate change (with ID kow in this example) will be empty and replaced with a new empty change.

/jj-reviewing-demo # jj log -r 'all()'
@ vpmrpvpw ben@example.com 2026-03-01 12:49:28 9e957556
│ (empty) (no description set)
○ ltqpxklq ben@example.com 2026-03-01 12:49:16 5ed4a951
│ review: big-change
│ ◆ yykxruwv bill@example.com 2026-03-01 11:54:39 big-change@origin dcf940aa
├─╯ feat: add new Python project
◆ romwosqw ben@example.com 2026-03-01 11:37:40 main 718f5479
│ Initial commit
◆ zzzzzzzz root() 00000000

At this point we can compare our reviewed version to the original change to see any comments or change suggestions we left ourselves and add them to the pull request.

/jj-reviewing-demo # jj interdiff --from big-change@origin --to ltq
diff --git a/JJ-COMMIT-DESCRIPTION b/JJ-COMMIT-DESCRIPTION
--- JJ-COMMIT-DESCRIPTION
+++ JJ-COMMIT-DESCRIPTION
@@ -1,1 +1,3 @@
+review: big-change
+
feat: add new Python project
diff --git a/main.py b/main.py
index 886d2e4f6f..3eeef07924 100644
--- a/main.py
+++ b/main.py
@@ -1,5 +1,6 @@
def main():
print("Hello from jj-reviewing-demo!")
+ # PR: Hello Bill!
print(hello("Ben"))

And that's it! We've been able to process the code changes step-by-step, and persist our progress through the change as we went.

To me, an obvious benefit of using jj is the power of the tooling and how intuitive it is to use. Once you have internalised the mental model, it doesn’t take many commands to achieve what you want. Even when you're very comfortable with Git, it still often takes a few steps to jump around and craft a coherent set of changes. The aforementioned reviewing workflow would certainly be possible to replicate with Git, but it would be much more cumbersome and require more cognitive load to keep track of stashes, the staging area, and not making a mistake that would be painful to undo. This is all cognitive load that takes away from the review itself.

When it comes to writing the code in the first place, I think there’s a more subtle advantage. The feeling of being able to iterate on a change and continuously mould it, with everything being snapshotted and persisted in the background, pushes you to be much more intentional with how you present the changes to others and your future self. Whereas the Git tooling nudges you into either producing big-bang changes or a series of random commits that you have to squash after the fact, jj encourages you to think about the change you’re making and then silently captures it as you go. Anyone reviewing the code is surely better off if you've told the story of your changes instead of dumping a diff on them.

In a lot of ways, this workflow is similar to that described in matklad’s TigerBeetle post about tracking review state in the repository and reviewing the code locally. As they say in the post, this is a bit cumbersome in Git as it requires a soft reset and use of the staging area, and it is tricky if you need to pause and do something else (unless you use a separate worktree). Some of the reasons they abandoned the idea could actually be improved by using jj instead of Git, such as easier handling of conflicts and a stable change ID to track changes to a commit.

Additionally, there’s some similarity to the “brain” concept in Jane Street’s Iron code review system . A “brain” is effectively a diff representing the portion of the change that the reviewer has already reviewed. The empty commit I’ve been squashing into is essentially my “brain” in Iron’s terminology. There is a lot more to their system than what I've described here, and I would recommend looking into it if you haven't already.

Both of these workflows entail leaving review comments inline in the code—indeed I think matklad took inspiration from Jane Street here—which is something I'm experimenting with. Currently I'm leaving inline comments and notes to myself in my own local review change, then showing them with jj interdiff and manually applying them as comments in the web UI when I'm finished. Applying the comments in a single batch like this saves me having to switch back to the web UI until I've finished reviewing all of the code.

At some point I will look at cobbling together a script to automatically send the review comments to the PR based on my local interdiff of the review change against the original change. For now, though, I quite like this second manual pass as it allows me to revise any comments I may have added towards the start of the review, before I had the context gained from the rest of the review.

Pushing a commit with your inline comments back to the pull request for your teammates to see is an interesting idea. However, this requires the rest of the team to adopt a new workflow, so I’ll hold off going this far until I’ve ironed out the details.

I don't yet have much of a process nailed down for incorporating subsequent updates to the pull request. Fortunately, the updates tend to be much smaller than the original change and it's easy enough to simply look at the diff. In the future, I may come up with something where I duplicate the new version of the change and rebase it onto my reviewed version in order to see the difference, but so far that's felt like overkill.

A downside of using jj over Git is the IDE integration. I use JetBrains's IDEs, and although there is the Selvejj plugin, it is a pre-release version and is not feature-complete according to their documentation. I get around this by using jj in "colocated" mode, which means that my IDE still thinks I'm using Git (in a way I am, I'm just not interacting with it using the git CLI). When I start editing a change with jj edit, the IDE shows the existing changes in my working copy as uncommitted changes, and lets me browse them as if I were getting ready for a Git commit. This works well, except for when I'm working in another jj workspace (which is similar to a Git worktree), because the .git directory is not replicated to the other workspace and therefore the IDE doesn't know about it. I just have to remember to use my primary workspace when I want the Git integration.

Overall I'm pleased with this workflow, and using jj in general. The tooling gets out of my way, leaving me with my full cognitive capacity to think about the code. I love that I can come up with workflow improvements and try them out without needing to inflict them on the rest of my team. I'm very much looking forward to seeing how the ecosystem continues to grow and continuing to refine our methods of collaboration.