How I use jj
I like jj.
This book is me sharing how I use it with friends, teammates, and anyone curious about the workflow.
It is not a complete tutorial. It is not a command reference. It is just the stuff I actually do and why I enjoy it.
The short version: jj makes it cheap to change my mind.
I can start messy, commit early, split work later, move fixes into the right commit, push small PRs, and undo local experiments when they were not useful. That fits the way I like to work.
I assume you know Git enough to understand commits, branches, rebases, and pull requests. You do not need to know Jujutsu yet.
Some friends I wrote this for are used to Facebook- or Google-style tooling where commits or changes are the main unit of work. If that is you, a lot of this may feel familiar.
If you are completely new, keep the official docs and the linked references nearby. This book is about how I use jj, not a replacement for the manual.
The examples use main as trunk.
I start with the ideas and plain commands. My config and aliases come after the workflow makes sense.
Why jj clicked for me
The thing I like most about jj is not a command.
It lowers the cost of mistakes.
I can try something before I know if it is the right shape. I can share work before it is perfect. I can split it later. I can throw it away. I can let an agent try a fix, review the diff, and undo it if I do not like it.
That changes my behavior. I stop trying to design the perfect path up front. I start moving, looking, reshaping, and asking for feedback sooner.
The first idea that makes this work is simple: my working copy is a change. Its name is @.
I can diff @. I can commit it. I can split it. I can squash it into another change. I can abandon it. I can rebase it. Even unfinished work has an address in the graph.
The second idea is guardrails.
I can be messy locally, but jj still protects the shared parts:
- trunk, tags, and untracked remote bookmarks are protected from accidental rewrites
- my draft
push-*review branches can still move, but pushes go through safety checks
That balance is why I like it. I can move fast locally, and jj still makes me check the remote before I push.
This fits stacked PRs very well. I can split work into small reviewable commits, push them early, keep moving while review happens, and avoid turning every feature into one giant pull request.
When the shape is wrong, I reshape it. When the experiment is bad, I undo it. When the piece is ready, I push it.
That is why commands like duplicate, squash, restore, and undo matter so much to me. They make it easier to be wrong for a while. That is useful when the work is unclear, when review starts early, or when I need to learn by trying.
There is one small bit of syntax worth learning early.
@ is the current working-copy change:
jj diff -r @
That means “show me the diff for my current working-copy change”.
@- is the parent of @:
jj diff -r @-
That means “show me the diff for the parent of @”.
The -r flag means “revision”. In jj, the value passed to -r can be a revset: a small expression that selects changes.
For example:
jj log -r 'main..@'
That means “show me the changes reachable from @ that are not in main”.
After this command:
jj commit -m "feat: add token validation"
@- is now usually the change I just committed, because it is the parent of the new empty @.
That is why I run this all the time:
jj diff -r @-
It shows the last completed change. That is exactly what I want after committing, resolving conflicts, or letting an agent modify code.
For a fuller introduction, Stavros’ tutorial, Jujutsu for everyone, and Steve Klabnik’s tutorial are good companion explanations. The official Jujutsu docs are the place to check exact command behavior when this guide is too informal.
How I stack PRs
This is the loop I use most: turn one idea into a stack of small PRs, push them before they are polished, and keep coding while review catches up.
The example uses this book as the work item. Imagine I want to build it as three small pull requests:
- Create the mdBook skeleton.
- Add the mental model and aliases.
- Add common workflow examples.
The stack will look like this:
@ empty working-copy change
docs: add common jj workflow examples
docs: explain mental model and aliases
docs: create mdbook skeleton
main
Stack
A stack is a line of small commits where each commit builds on the one below it. In this guide, I usually turn each commit into its own small PR.
Trunk
In this book, main is trunk: the shared branch I keep my work based on.
For this example I start from a clean repo with main fetched. The files are from this book, but the command loop is the part to copy.
Start from main
I fetch remote changes and create a new empty change on top of main:
jj git fetch
jj new main
This creates an empty working-copy change. In jj, the current working-copy change is @.
@
@ is the current working-copy change. It exists in the jj graph even before I commit it.
Before editing, I like to check that I am starting clean:
jj status
First PR: create the book
First I make the smallest useful piece: the book exists and has a table of contents.
book.toml
src/SUMMARY.md
src/introduction.md
This is the small loop I repeat all day: look, commit, look again, push.
jj diff
jj commit -m "docs: create mdbook skeleton"
jj diff -r @-
jj git push -c @-
jj diff -r @- is the check I care about here. I want to review the change I just committed before I push it.
@-
@- means the parent of @. Right after jj commit, that is usually the change I just committed.
jj creates a temporary bookmark with a generated name like push-abc.... That is the branch name GitHub sees.
Bookmark
A bookmark is the jj name that maps to a branch. For review, the important part is that GitHub has a branch name to point at.
I do not need to name it perfectly. I just need a small PR my team can review.
I usually look at the graph after pushing:
jj log
I expect to see the new push-* bookmark on the commit I just pushed and an empty @ above it.
Second PR: explain the model
After jj commit, @ is already an empty child of the first PR. I can keep writing, and the next commit naturally stacks on top.
I edit the next logical unit:
src/mental-model.md
src/config-and-aliases.md
Then I repeat the same loop:
jj diff
jj commit -m "docs: explain mental model and aliases"
jj diff -r @-
jj git push -c @-
Now I have two push-* bookmarks, one for each PR in the stack.
Each PR has one job. Reviewers can look at the book skeleton first, then the model, then the examples.
Third PR: add common workflows
I edit the workflow examples:
src/common-things.md
src/wip-private-commits.md
src/conflict-resolution.md
src/reviewing-teammate-work.md
Then I repeat the loop again:
jj diff
jj commit -m "docs: add common jj workflow examples"
jj diff -r @-
jj git push -c @-
The stack has three pushed PR branches now.
At this point the work is visible. It does not have to be final.
Inspect the stack
I check the local stack:
jj log -r main..@
I check the pushed temporary branch bookmarks:
jj log -r 'bookmarks("push-*")'
The shape should be roughly:
@ empty working-copy change
push-c... docs: add common jj workflow examples
push-b... docs: explain mental model and aliases
push-a... docs: create mdbook skeleton
main
bookmarks("push-*") is my quick way to see the review branches jj created for this stack.
Then I keep moving
At this point the interesting thing has already happened: I split one idea into reviewable pieces, pushed them early, and kept the local graph easy to read.
Once review starts, the loop is the same shape: fetch, rebase the pushed stack when needed, edit the change that needs feedback, and push the push-* bookmarks again.
The next chapters add my small config layer and then show how I keep the open PRs moving.
My small jj config
The workflow so far works with plain jj.
This is the small config I put on top. It is not a framework. It is just enough personal vocabulary to make the common moves feel natural.
[ui]
paginate = "never"
default-command = "log"
diff-formatter = ["difft", "--color=always", "$left", "$right"]
[user]
name = "Your Name"
email = "you@example.com"
username = "your-handle"
[aliases]
tug = ["bookmark", "advance"]
stack = ["log", "-r", "stack"]
bough = ["log", "-r", "bough"]
wip = ["log", "-r", "wip"]
private = ["log", "-r", "private"]
empty = ["log", "-r", "empty_stack"]
rs = ["rebase", "-b", "bookmarks('push-*')", "-o", "trunk()"]
ps = ["git", "push", "-b", "push-*"]
[revsets]
bookmark-advance-to = "@-"
[revset-aliases]
'stack' = 'trunk()..@'
'bough(x, m)' = 'descendants(ancestors(x) ~ ancestors(m))'
'bough(x)' = 'bough(x, trunk())'
'bough' = 'bough(@)'
'wip' = 'subject("wip:*") & stack'
'private' = 'subject("private:*") & stack'
'empty_stack' = 'empty() & stack ~ @'
[git]
private-commits = "subject('private:*')"
The difft line is optional. If you do not use difft, delete it and keep jj’s default diff.
The plain examples in this book use main. In my config aliases I use trunk() because it follows whatever the repo considers trunk.
Why I like this config
jj by itself becomes my graph view:
jj
That is just default-command = "log". I like it because I look at the graph constantly.
Without my config, I spell the bookmark move explicitly:
jj bookmark advance --to @-
The same explicit command with jj’s short command names is:
jj b a --to @-
With this config:
[revsets]
bookmark-advance-to = "@-"
I can drop --to @-:
jj b a
My alias is:
jj tug
So jj tug is just my name for jj b a with bookmark-advance-to = "@-" set. Since @- is the parent of @, right after jj commit it points at the change I just committed, so tugging moves the bookmark there.
The plain stack maintenance loop is:
jj git fetch
jj rebase -b 'bookmarks("push-*")' -o 'trunk()'
jj git push -b 'push-*'
My aliases are:
jj git fetch
jj rs
jj ps
That is intentionally broad. If I already pushed a generated push-* bookmark for review, I usually want to keep it current on the remote.
I do not alias jj git push --all. It is useful, but it is broad enough that I want to type it deliberately:
jj bookmark list
jj git push --all
--all means all bookmarks. It does not create bookmarks for anonymous local changes.
Logs I actually use
These are just ways to ask “what am I looking at?”
The plain commands are:
jj log -r 'trunk()..@'
jj log -r 'subject("wip:*") & trunk()..@'
jj log -r 'subject("private:*") & trunk()..@'
jj log -r 'empty() & trunk()..@ ~ @'
My aliases are:
jj stack
jj wip
jj private
jj empty
stack is the normal view: work from trunk to @.
For bough, the plain revset is not something I want to type by hand. That is exactly why it is an alias:
jj bough
bough is wider. It is useful when I have side experiments or local integration commits around the same branch of thought. I use it for looking around, not as a push target.
wip and private are message-prefix searches inside the current stack.
empty shows empty changes in the stack, excluding the current empty @. That is handy after squash merges or cleanup.
That is the whole point of the config: keep the actual workflow visible, but make the moves I do all day feel good to type.
Accounts and SSH
If I need different author identities per folder, I use config scopes:
[[--scope]]
--when.repositories = ["~/dev/work"]
[--scope.user]
email = "you@work.example"
username = "work-handle"
[[--scope]]
--when.repositories = ["~/dev/personal"]
[--scope.user]
email = "you@personal.example"
username = "personal-handle"
SSH keys stay in ~/.ssh/config, because jj uses Git for remote operations:
Host github.com
HostName github.com
User git
IdentityFile ~/.ssh/id_ed25519_oss
IdentitiesOnly yes
Host gerrit.example.com
HostName gerrit.example.com
User my-google-account
IdentityFile ~/.ssh/id_ed25519_google
IdentitiesOnly yes
If two accounts live on the same host, I use host aliases like github-oss and github-work, then put that alias in the remote URL.
Keeping PRs moving
Once the stack is pushed, the work becomes normal review work: open the PRs, let CI run, fix feedback, rebase when main moves, and push again.
The nice part is that my local graph can stay comfortable while GitHub sees ordinary branches.
Open the PR
When I push a completed change with -c, jj creates a generated bookmark for that revision:
jj git push -c @-
On GitHub, the remote usually prints a pull-request link:
$ jj git push -c @-
Creating bookmark push-omrwltwrpyzx for revision omrwltwrpyzx
Changes to push to origin:
Add bookmark push-omrwltwrpyzx to a9f4f7097dd4
remote:
remote: Create a pull request for 'push-omrwltwrpyzx' on GitHub by visiting:
remote: https://github.com/example-org/example-repo/pull/new/push-omrwltwrpyzx
remote:
Most of the time I click that link and create the PR. The generated name is not precious. It only needs to be stable enough for GitHub, CI, and reviewers to talk about the change.
Keep the stack pushed
If I already pushed push-* bookmarks, I usually want to keep them current on the remote:
jj git fetch
jj rebase -b 'bookmarks("push-*")' -o main
jj git push -b 'push-*'
With my aliases:
jj git fetch
jj rs
jj ps
That broad push is intentional. If I opened a generated review branch, I usually want remote CI to keep running on the latest version of it.
If I have not fetched recently and the remote moved, jj git push rejects the update. Good. I fetch, rebase if needed, and push again.
I do not alias this broader command:
jj bookmark list
jj git push --all
--all pushes all bookmarks. It does not create bookmarks for anonymous local changes.
Fix feedback where it belongs
Sometimes feedback belongs in an existing change, even if that change is in the middle of the stack.
I edit that change directly:
jj edit <change>
# make the fix
jj diff
jj git push -b 'push-*'
When that change gets a new commit id, jj rebases its descendants locally. I do not need to manually walk the stack and fix every child PR one by one.
With my aliases, the last command is:
jj ps
This is one of the reasons stacked work feels good in jj. I can fix the commit where the problem belongs, and the rest of the stack follows.
If the review tool can show interdiffs between versions of the same change, this direct-edit style is even nicer. On GitHub I may still add a review commit when I want the response to be very visible, but locally I do not need special ceremony.
When a PR lands
When the bottom PR in a stack lands, I fetch and rebase the remaining stack onto main:
jj git fetch
jj rebase -b 'bookmarks("push-*")' -o main
jj git push -b 'push-*'
With my aliases:
jj git fetch
jj rs
jj ps
If the review tool landed the same commit, or a rebase-style merge that preserves the commit identity, the local graph usually lines up with main naturally.
If GitHub squash-merges a PR, my local copy of that review change may hang around as an empty commit. I do not try to get clever. I look for empties, make sure they are old review commits, and abandon them one by one:
jj empty
jj abandon <change>
The plain revset is:
jj log -r 'empty() & main..'
I still look before abandoning. The point is cleanup, not cleverness.
Clean up old push bookmarks
Generated push-* bookmarks are cheap, but I do not want stale review branches hanging around forever.
I start by looking:
jj bookmark list 'push-*'
If I only want to stop caring about a local bookmark, I forget it:
jj bookmark forget push-abc...
If I want to delete the bookmark and propagate that deletion to the remote:
jj bookmark delete push-abc...
jj git push --deleted
Most of the time this is boring cleanup after the review branch is no longer useful.
Name bookmarks only when it helps
I only name bookmarks when the name buys me something.
Maybe the PRs will live for a week and teammates will say the names out loud:
jj bookmark create auth-0 -r <change-a>
jj bookmark create auth-1 -r <change-b>
jj bookmark create auth-2 -r <change-c>
jj git push -b 'auth-*'
If the branch is just a short-lived review handle, the generated push-* name is good enough.
Keep the graph useful, not clever
The mistakes I try to avoid are usually old habits:
- overengineering bookmark names
- treating bookmarks as the main unit of work instead of commits and changes
- avoiding
jj squashandjj split - turning everything into one large PR instead of a stack
- drifting away from trunk-based development, where reviewed commits land on
mainthrough whatever merge queue or protection the team uses
The goal is not to make the graph impressive. The goal is to keep the work easy to review, easy to reshape, and easy to land.
Common things I do
After the main stack flow, most of my day-to-day usage is a handful of small moves.
Use this section like a toolbox:
- I made a mess and want to back out: read
Undo is why I experiment. - I have a spike that might become real: read
WIP and private commits. - I mixed two ideas in one diff: read
How I clean up commits. - I need two branches together locally before they land: read
Working ahead with merge points. - I have a tracked file that must stay local: read
Keeping tracked files local. - A rebase conflicted and I want an agent to help: read
Letting agents help with conflicts. - I want to inspect someone else’s branch in my editor: read
Reviewing teammate work locally.
The pattern is always the same: make the state visible, do the smallest useful move, inspect the diff, and keep going.
Undo is why I experiment
This is one of the reasons I am comfortable being creative with jj.
If I make a mess, I usually do not clean it up by hand. I undo the operation.
jj undo
jj keeps an operation log: every commit, rebase, split, squash, bookmark move, and undo I asked it to do. jj undo steps back through that log.
It is not a time machine for the remote, but it is enough to make local experiments feel cheap.
If I undo too far, I can move forward again:
jj redo
When I reach for it
The simple case is after I run a command and immediately dislike the result:
jj squash
jj undo
This also pairs really well with agents.
If I ask an agent to resolve a conflict or reshape some code and the result is not going in the right direction, I do not need to manually reconstruct the old state. I can usually do:
jj undo
jj status
jj diff
Then I try again with a narrower prompt.
Why this changes behavior
Undo makes experimentation cheap. I can let an agent try something, inspect the diff, and back out quickly if the result is worse than the starting point.
When undo is not enough
If I need more context than “undo the last thing”, I look at the operation log:
jj op log
If I want to see what an operation changed:
jj op show -p
And if I want to restore a specific earlier operation:
jj op restore <operation-id>
Most of the time I only need the short version:
jj undo
That one command makes a lot of the other workflows feel less scary.
WIP and private commits
Sometimes I do not know what I am building yet.
I still commit.
Not because the work is done, but because I want to make the idea durable. Once it is a commit, I can move it, compare it, abandon it, rebase it, or come back to it later.
jj diff
jj commit -m "wip: try stacked push flow"
If the work is not meant for review, I mark it more clearly:
jj commit -m "private: notes from debugging auth callback"
Private does not mean secret
private: is just a label in my commit message. It helps me search and remember intent. It is not access control, and I do not put secrets there.
Keep private commits off the remote
Once I started using private: as a real parking lot, I wanted one extra guardrail: if I ever try to push those commits, jj should refuse.
This config does that:
[git]
private-commits = "subject('private:*')"
With that set, jj git push refuses to push commits whose subject matches private:*. It also refuses to push descendants that would require pushing the private commit.
The command-line form is:
jj config set --user git.private-commits "'''subject('private:*')'''"
The nested quotes are only there so the revset quotes survive the shell.
I leave wip: pushable because sometimes the whole point of a WIP commit is to get CI or a teammate’s eyes on it. private: is the stronger word in my vocabulary.
Find the messy stuff later
Because I use a prefix, I can search for these commits later:
jj log -r 'subject("wip:*")'
jj log -r 'subject("private:*")'
That searches all visible history, so it can return old commits from other people too.
Most of the time I only want WIP commits in my current stack. For that, I intersect the subject search with trunk()..@:
jj log -r 'subject("wip:*") & trunk()..@'
jj log -r 'subject("private:*") & trunk()..@'
In my config, those stack-scoped searches are:
jj wip
jj private
Direction matters
trunk()..@ means commits reachable from my current working copy that are not already in trunk. @..trunk() is the opposite direction and is usually not what I want for "my current work".
Promote it when it becomes real
At some point a WIP commit becomes real.
When I write <change> below, I usually copy the short change id from jj log.
First I rename it:
jj describe -r <change>
or directly:
jj describe -r <change> -m "docs: explain private commit workflow"
Then I move it where it belongs.
If it should sit on top of main:
jj rebase -r <change> -o main
If it should become part of an existing stack:
jj rebase -r <change> -o <bookmark>
If it is a stack of WIP commits:
jj rebase -s <first-wip-change> -o <bookmark>
Then I inspect it:
jj log -r main..@
jj diff -r <change>
With my alias, the first command is:
jj stack
And push it when it is ready:
jj git push -c <change>
If the promoted work is the last completed change, I usually use:
jj git push -c @-
Why this feels good
I do not need to decide up front whether an idea is real. I can give it a cheap name, keep moving, and promote it only after it proves useful.
How I clean up commits
This is one of the workflows that makes jj feel very natural to me.
I do not need to make perfect commits while I am thinking.
I can work a little messily, look at the result, and then shape it into commits that are nice to review.
Sometimes that means splitting one change into two. Sometimes it means squashing a small fix into an older commit. Sometimes it means letting jj absorb do the obvious part for me.
Split the mess after the fact
When I have a few things mixed together in the working copy, I start by looking at the diff:
jj diff
If the current change is really two ideas, I split it:
jj split
Definition: split
jj split takes one revision and splits its changes into two commits. By default it opens a diff editor, so I choose which lines belong in the first commit and leave the rest for the second commit.
For example, maybe I changed both the explanation of bookmarks and the conflict-resolution chapter in the same pass. That is fine while writing, but I probably want those as two separate review commits.
After the split, I check the shape again:
jj log -r main..@
jj diff -r @-
If I want to split an older commit instead of the current working-copy change, I point jj split at it:
jj split -r <change>
Agent-shaped work
This gets especially useful with agents. I can ask an agent to make progress, then use jj split, jj squash, and jj absorb to turn the result into commits that tell a clean story. The agent helps with the typing; jj helps me keep authorship and review shape under control.
Move a fix to the commit it belongs in
Sometimes I am at the top of a stack and I notice something small:
- a typo that belongs in the first PR
- a test fix that belongs in the second PR
- a cleanup that should not become a separate review commit
With jj, I usually make the change in the current working-copy change, then move it later.
Imagine this stack:
main
docs: create mdbook skeleton
docs: explain mental model and aliases
docs: add common jj workflow examples
@ dirty working-copy change
I realize the dirty change actually belongs in docs: explain mental model and aliases.
First I find the change I want to fix:
jj log -r main..@
With my config, that is:
jj stack
Then I move the current working-copy change into that older change:
jj squash --into <change>
Definition: squash
jj squash moves changes from one revision into another revision. If I do not pass --from, the source is the current working-copy change, @.
That is the basic move I use most often:
- Do the work at the top of the stack.
- Decide where it really belongs.
- Squash it into that change.
- Keep going.
After that, I review the target change:
jj diff -r <change>
If I only want part of the current diff, I do it interactively:
jj squash -i --into <change>
Squash a fixup commit
Sometimes I already committed the fixup:
jj commit -m "wip: fix wording in aliases section"
Right after that, the fixup commit is usually @-.
If the fixup should go into its parent, this works:
jj squash -r @-
Careful with -r
jj squash -r <change> does not mean "squash into <change>". It means "take <change> and squash it into its parent". That is perfect for a fixup commit directly above the commit it fixes, but it is not the command I use when the target is somewhere else in the stack.
If the fixup commit belongs in a different earlier change, I am explicit:
jj squash --from @- --into <change> --use-destination-message
I like --use-destination-message for this because the wip: message was only temporary. The older commit already has the real review title.
Absorb
jj absorb is the more automatic version of this idea.
Instead of me choosing the destination commit, jj looks at the current changes and tries to move each chunk into the closest mutable ancestor where those lines were last modified:
jj diff
jj absorb
If I want to limit the destination to my current stack, I can be more explicit:
jj absorb --into 'main..@-'
Mercurial note
If you came from Mercurial, the name is doing a lot of work here. jj absorb does not feel quite as uncanny to me as hg absorb did, but it still handles the boring fixup case well enough that I reach for it.
Review absorb
absorb is a guess. When it cannot choose a destination clearly, it leaves the change where it is. When it does move things, I still review the operation before pushing.
The review command I use is:
jj op show -p
Then I check the stack again:
jj log -r main..@
jj diff -r @-
With my config, the first command is:
jj stack
If the stack was already pushed, this is another moment where I push every temporary PR branch again:
jj git push -b 'push-*'
With my config:
jj ps
Working ahead with merge points
Sometimes I am waiting on approval, but I still need to keep moving.
Or my work depends on someone else’s branch.
Or I have two streams of my own work that should stay reviewable separately, but I need to know whether they work together.
This is where a local merge point can be useful.
Definition: merge point
A merge point is a local change with multiple parents. I use it as an integration surface for branches or review commits that are still separate pieces of work.
The idea is simple:
- Keep the real work in separate commits or branches.
- Create a local merge that combines them.
- Work on top of that combined view.
- Move finished pieces back into the right parent when needed.
What the graph looks like
Imagine I have one PR waiting for approval and another branch I depend on:
main
feature: auth flow
main
refactor: client setup
I want to keep both reviewable on their own, but I also want to continue as if both existed together.
I create a merge change with both tips as parents:
jj new <auth-flow-tip> <client-setup-tip> -m "private: integrate auth flow and client setup"
Then I create a normal working change on top:
jj new -m "wip: continue on integrated work"
The graph is roughly:
main
feature: auth flow ----.
\
private: merge auth flow and client setup
\
wip: continue on integrated work
/
refactor: client setup-'
A merge commit is just another change with more than one parent.
Definition: merge change
In jj, jj new A B creates a new change with both A and B as parents. That is how I create a local merge surface.
Why I like it
The useful part is mundane: I can run the code as if both PRs had landed.
If the auth branch and client refactor fight each other, I find out now, while both are still easy to change. The merge point is not for review; it is my local test bench.
Local integration surface
I usually treat the merge point as local scaffolding. The separate commits are the things I want reviewed. The merge point is for testing whether they work together.
Moving work back
While working on top of the merge point, I might make a change that actually belongs in one of the parent branches.
If everything in the current working change belongs in the auth flow branch:
jj squash --into <auth-flow-tip>
If only part of it belongs there:
jj squash -i --into <auth-flow-tip>
Then I review what happened:
jj log -r main..@
jj diff -r <auth-flow-tip>
With my config, the first command is:
jj stack
This is the same shaping loop as before, but the merge lets me shape work across several active streams.
When an agent is helping
This is a very natural place to use agents.
I can create the merge point, ask an agent to make the combined state pass tests, then decide where each part belongs:
jj diff
jj split
jj squash --into <target-change>
The agent does not need to understand my review plan perfectly. I can use jj afterwards to split the result into good commits.
Push only the real branches
After moving work back into the actual review branches, I push those branches again:
jj git push -b 'push-*'
With my config:
jj ps
or, for one new change:
jj git push -c @-
Do not accidentally review the scaffolding
The merge point is often not the PR. It is a local test commit. Before pushing, I check which bookmarks I am moving and which commits I actually want other people to review.
The escape hatch is still simple:
jj undo
That is why I am comfortable trying this. The graph can be creative locally, and I can still keep the review surface clean.
Keeping tracked files local
Sometimes the file is already tracked by the repo, but my version should not be pushed.
Maybe it is a local config file, a credentials-shaped test file, or some machine-specific setting. With jj, I do not leave that as a mysterious dirty file forever. I make it a private change and work with it deliberately.
First I create the private change:
jj new main -m "private: local config"
# edit the local-only tracked file
jj diff
Then I create a merge working copy that combines my real work and the private config:
jj new <work-change> <private-change>
The shape is:
main
work change --------.
\
@ combined working copy
/
private: local config'
Now my working tree has both sets of files. When I make real feature changes from this combined view, I move them back into the review change:
jj diff
jj squash -i --into <work-change>
jj diff -r <work-change>
The private change stays local. If I accidentally try to push a commit that includes the private change as an ancestor, git.private-commits stops me. It does not scan file contents, so I still review the target diff before pushing.
This is not secret management. If a real secret escaped to a remote, I would still rotate it. This pattern is for local-only tracked-file changes that I need in my workspace while I work.
Letting agents help with conflicts
Sometimes rebasing is boring.
Sometimes it conflicts.
When that happens, I try not to turn conflict resolution into a heroic manual exercise. I make the state explicit, then I ask an agent to help.
The normal start is:
jj git fetch
jj rebase -o main
If I am maintaining a stack of temporary PR branches, I use the bookmark revset:
jj git fetch
jj rebase -b 'bookmarks("push-*")' -o main
With my config, that is:
jj git fetch
jj rs
If there is a conflict, I first inspect the state:
jj log -r 'conflicts()'
Conflicts may not be at @
After rebasing a stack, the conflict can live in an older commit while my working-copy change looks clean. I do not assume jj status is enough; I ask jj which revisions have conflicts.
Then I list the conflicted files for the specific revision:
jj resolve --list -r <conflicted-change>
If the conflicted change is already @, I edit the files directly.
Most of the time in a stack, I create a working change on top of the conflicted change:
jj new <conflicted-change>
Then I give that state to the agent and ask for a narrow fix.
What I ask the agent for
I do not ask it to redesign the change. I ask it to resolve the conflicted files while preserving the intent of my branch and the new shape of main.
After the agent edits files, I review the result:
jj status
jj diff
If I created a new change on top of the conflicted revision, I squash the resolution back into the conflicted change:
jj squash
jj diff -r <conflicted-change>
If the stack was already pushed as push-* branches, I push everything again:
jj git push -b 'push-*'
With my config:
jj ps
The important habit is not the exact command. The habit is to stop, expose the state, fix the conflict, and review the final diff before pushing.
Conflicts are not failures. They are just places where the graph needs a human decision.
Reviewing teammate work locally
One trick I like is using jj restore to review someone else’s branch as dirty local changes.
I start from a clean change on top of main:
jj git fetch
jj new main
Then I restore a teammate’s remote branch into my working copy:
jj restore -f feature/example-change@origin
What this does
This restores the file contents from that remote branch into my current working-copy change. It is a tree restore, not "apply only the PR patch". If the branch is based on a different point, the dirty diff may include more than I expected.
Now the restored branch contents are a diff in my workspace:
jj status
jj diff
jj diff --git
That is neat because I can review it like normal code, but I can also reshape it locally.
For example, I can commit a local version:
jj commit -m "review: inspect teammate branch locally"
jj diff -r @-
Or split the change while I am trying to understand it:
jj split
If I never committed the local review, I can throw away the restored files by going back to main:
jj restore -f main
If I did commit a local review change, jj commit left me on a new empty @, so the review commit is @-:
jj abandon @-
Why I use this
GitHub shows me a diff. This gives me a working tree. I can run tests, move code around, make experimental commits, and then throw it all away.
I anonymized the branch name here on purpose. In real work it is usually some feature branch from a teammate or a generated PR branch.
There are other shapes for the same idea.
I can duplicate the teammate’s commit and experiment on my copy:
jj duplicate <change>
Or I can create a new change on top of their work:
jj new feature/example-change@origin
That is the safer beginner move when I want to run the branch as-is before making local experiments. The restore trick is for when I specifically want their branch contents as dirty changes on my own base.
This is why I like doing reviews with jj: the branch is no longer a read-only diff in a browser. It is local code I can test, split, duplicate, abandon, and undo without disturbing the author’s branch.