Working with jujutsu

Scattered, thoughts, tips and tricks working with jj.

I figured now would be a good time to try out jj since I am 5 PRs deep in a stack. As like most of everything here, this is written largely stream of consciousness as I am learning and doing.

Commit = feature

First learning: jj wants you to have a mental model of commit = feature. With git, you would think of a PR being the feature; even if the PR was a mess, you would likely squash and commit into main to keep it clean.

Because of this, I have to cleanup my "mess" of commits like: "yay it works", "rename", "etc". This is reasonable since I put all my effort into PR descriptions, less so individual commits.

So to take my 7 commit mess into 1 I effectively did an interactive rebase with squashing:

# This is saying from this revision set squash the changes into `wutnoxrs`
jj squash --from "wutnoxrs..yzoxlnsx" --into wutnoxrs

Everything is a revision

Second learning: jj is always operating on a commit for everything. I accidentally ran the previous command as:

jj squash --from wut --into yzox

But this is saying take revision wut and teleport it into yzox. I thought it meant from wut to yzox create a new revision and squash them into that. Nope.

But because everything is a revision. I can easily just run jj undo and fix my oopsie.

Even current edits

With git, you have to mentally differentiate between what is staged, dirty, untracked, and committed. With jj, you're never not operating on a revision:

@  omlsswzl nick 2025-12-13 22:23:52 66f2d3c2
│  (empty) (no description set)

Stacking PRs is so much simpler

Third learning: jj effortlessly manages stacked PRs. Let's say I realize I need to make a change to PR-A while working in PR-D. I can easily do jj edit PR-A, make the change, and it will auto-propagate the changes forward across the stack. Then when I push, all of those branches get pushed (effectively auto-rebased with their respective parent and pushed).

While doing my own cleanup and transition from git to jj, I found a revision in PR-D that really should be in PR-B. Now I can take my oopsie command from earlier and actually use it appropriately. I want to take revision qplwptqv and bring it to wutnoxrs; that can be done with:

jj squash --from qplwptqv --into wutnoxrs

And this applies that revision to the other one. This would have been so, so painful in git because I would have had to cherry-pick that commit into PR-B, drop it in PR-D, and then rebase PR-C and PR-D onto the latest PR-B. jj just did all of that for me.

jj made it incredibly easy to take 40 commits across 5 stacked PRs and turn them into only 1 commit per PR and update everything all at once. I think this was like under 30 minutes of effort while learning how this new tool works (thanks Gemini). After I did all this squash surgery, it should be simple enough to push with:

jj git push --all

Fixing remote tracking

This was terrifying to run, so I learned there's a dry-run mode (--dry-run). I'm glad I did because this would push all of my local bookmarks with remote... and I have many. Worse yet, I learned that a number of these branches are not tracked by jj and wouldn't be pushed because of all the changes I made:

Warning: Non-tracking remote bookmark nvd/feature@origin exists
Hint: Run `jj bookmark track nvd/feature@origin` to import the remote bookmark.

Well, I tried that and it got more complicated:

nickdirienzo@Nicks-MacBook-Pro mirage % jj bookmark track nvd/feature@origin
Started tracking 1 remote bookmarks.
nvd/feature (conflicted):
  + zwxzuvzt?? 818f8d8d feat(TSK-1857): some description here
  + oksymzvq ff94891d env vars
  @origin (behind by 1 commits): oksymzvq ff94891d env vars

Hm. Okay. I can tell jj to treat zwxzuvzt as the bookmark right?

nickdirienzo@Nicks-MacBook-Pro mirage % jj bookmark set nvd/feature -r zwxzuvzt
Error: Change ID `zwxzuvzt` is divergent
Hint: Use commit ID to select single revision from: a7d4633daa8d, 818f8d8d4fa1
Hint: Use `change_id(zwxzuvzt)` to select all revisions
Hint: To abandon unneeded revisions, run `jj abandon <commit_id>`

I can, but I can't use the change ID. I must use the underlying commit ID. But we know that from our logs. So we can run a command like this across each of the remote branches:

jj bookmark set nvd/feature -r 818f8d8d

After doing that for each of the bookmarks, I was able to push everything:

jj git push -b nvd/feature-A \
            -b nvd/feature-B \
            -b nvd/feature-C \
            -b nvd/feature-D \
            -b nvd/feature-E

I would love to run jj git push --all, but my dry run listed 74 bookmarks it would push. A lot of those are very, very stale. Not only is jj already making more productive with stacked PRs, but it is also encouraging me to Marie Kondo these all branches from my life.

Cheatsheet

Create new revision from where we are: jj new

Create new revision off main (i.e. need to hotfix something): jj new main

Instead of git commit -m, you would describe a revision: jj describe

Prepare revision for PR:

All done with those changes? Time for another jj new, which locks in those changes locally under the bookmark.

Lean into jj new for experiments, hotfixes, etc. Don't be afraid of having "uncommitted" changes; jj is managing it behind the scenes.

Looking at diffs between local and origin: jj diff --from $bookmark$@origin

Moving files across revisions: jj squash -i --into <revision_id>. This lets you select which files you want to move from the current revision into the provided revision. To commit the squash, type c.

Pulling main and rebasing revisions on top of the new changes from trunk:

jj git fetch
# From each base revision, rebase it on top of the new changes
jj rebase -d main@origin

Who needs bookmarks? jj git push --change @ will take the current revision, create a bookmark, track it, and push it to origin.

Aliases

Here are some aliases I've been using to make my transition to jj a bit easier.

This shows all of the revisions from where we are to main:

[revset-aliases]
"immutable_heads()" = "trunk() | tags()"
# Useful for showing my active work and rebasing their roots with main
active = "mine() ~ ::trunk()"

[aliases]
# Show my chain of revisions on the current stack
stack = ["log", "-r", "trunk()..@"]
# "Show me all my unmerged changes"
active = ["log", "-r", "active"]
# Rebase all of my active work with main@origin. Run after `jj git fetch`.
sync-active = ["rebase", "-s", "roots(active)", "-d", "main@origin"]

Cleaning up

I have a lot of stale git branches which is making it hard to make use of jj rebasing efficiently with trunk, e.g. I can't quickly do jj rebase -s "roots(active)" -d main@origin to sync up my active work with latest main.

I had Gemini cook up a one-liner to show me all of my work branched off trunk with context so I can decide what to abandon:

jj log -r "roots(active)" --no-graph \
  -T 'change_id.short() ++ " " ++ author.timestamp().format("%Y-%m-%d") ++ "   " ++ description.first_line() ++ "\n"' \
  | awk '$2 < "2025-12-10"'

Since that list looks good, I had it produce another one-linear to delete them:

# ONLY run this when you are ready to delete everything listed above
jj log -r "roots(active)" --no-graph \
  -T 'change_id.short() ++ " " ++ author.timestamp().format("%Y-%m-%d") ++ "\n"' \
  | awk '$2 < "2025-12-10" {print $1 "::"}' \
  | xargs jj abandon

It's a bit of a yolo operation, but whatever. What's great about jujutsu is if something does go wrong, I can jj undo the operation since the abandon command is taking in all the revisions at once.

Once that ran and abandoned 677 commits (oops), rebasing all my active work with a new fetch of main was a breeze.