In praise of human beings
This book has been written by a human (hello, I'm Arialdo, a pleasure), not AI, because I respect your intelligence.
It aims to be:
- Essential, because your time is precious.
- Practical, because "less talk, more action" is always 👌
- Incremental, because you want to use jj from day 0.
- No hand-holding, because I'd rather you discover than be told.
- Unorthodox, because Jujutsu is extravagant, and who likes boring tutorials?
It is free (as in beer), because I am merely giving back what I was given. Also, because I love beer.
If you still want to pay some bucks for it, please, go make a donation to the charity of your choice. This will make me happy.
I am no authority, just a fellow student. I wrote this book the way I wish someone had explained Jujutsu to me.
English is not my native language. If you want to help, PRs are welcome!
What's This Book About
In your affair with Git, do you recognize yourself in any of these?
- "Detached HEAD" makes you anxious.
- You're not sure of the difference between
git reset --soft,--mixed, and--hard. - You rebase with your fingers crossed. Or you don't dare, too hard.
- You know the reflog exists, but it's an unexplored land.
- When you make a mess, you don't know how to get back.
Or maybe you are on the other side, you've mastered Git and you are intrigued by the extravagant ideas:
- Of working on multiple branches simultaneously.
- Of rebasing interactively in real-time, as you type.
- Of sending changes to past and future commits.
If either Git, deep down, scares you a little, or if you've already hit its ceiling and want to know what comes next, Jujutsu might change your relationship with version control.
What's Jujutsu?
Jujutsu is an open source Versioning Control System, like Git.
Git is amazing. But somehow it makes its model your problem. Jujutsu, on the other hand, has a ridiculously simple model that you'll pick up in a breeze. Despite being simpler, it lets you do things you didn't know were possible with Git.
Best of all: Jujutsu is 100% compatible with Git. If your colleagues
keep using Git, they won't notice you are using Jujutsu. You too can
keep invoking either git or jj commands in the same project.
So, you don't need to switch. Your current faith is perfectly fine, no conversion is necessary.
Is This Book For You?
This book is for two kinds of readers who couldn't be more different.
The first is the Git Wizard: the one who does interactive rebases blindfolded and bisects in their sleep. Who is still hungry for more power and will be delighted to find it in Jujutsu.
The second is the Scared Beginner: the person who freezes at a Git conflict and secretly hopes nobody asks them to rebase anything. And who will be happy to finally find autonomy.
This contradiction stems from one of the most surprising traits of Jujutsu: it is almost impossibly simple and yet spectacularly powerful. It's Dr. Jekyll and Mr. Hyde collaborating amiably in the same lab: a miracle of engineering design.
I hope this book appeals to the experts, inspiring them to learn, experiment and invent new reckless workflows; and that it gives the frightened ones the joy of finally versioning their projects on their own, with confidence.
Finally: this book, just like Jujutsu, surely isn't for everyone. It's for the Git Wizards and the Scared Beginners who share one thing: enough curiosity and enough bravery to step outside the box and to try something niche.
Quick Start
1. Setup
Install Jujutsu. Then, clone the sample repository:
jj git clone https://codeberg.org/arialdo/jj-quick-start.git
cd jj-quick-start
Track all the remote branches:
jj bookmark track "*" --remote origin
2. Git Compatible
This is an ordinary Git repo. You can mix Jujutsu's and Git's commands:
$ git log --oneline --graph --all
* f3d91ac
* 8dda056 (HEAD)
| * f0218b5
| * fa832fc (origin/store, store) Add `done` command
| * c495748 Store tasks in ~/.taskr.json
|/
| * beea00e (origin/dev, dev) WIP: parse --due dates
| * 6b0b88b Add LICENSE
| * 403d415 Sort tasks by priority
| * b6107c0 debug: dump args to stderr
| * b58bf2f Add serach by tag
| * 068609d Add --priority flag
|/
* 06acab9 (origin/main, main) taskr app
The project is a short todo list in Python. We don't care what it does. We will mainly focus on the shape of its history.
3. jj log
Log the history tree with Jujutsu:
jj log

I prefer a more compact template:
jj log -T builtin_log_oneline

Bullet shapes have the following meaning:
| Symbol | Meaning |
|---|---|
○ | Ordinary commits. Consider them editable. |
◆ | Immutable commits that belong to the remote trunk. Git lets you play with them, then it will complain, too late, rejecting your next push. Jujutsu stops you beforehand. |
@ | Your working copy. |
Commits have 2 identifiers:
- One on the right, such as
fa832fce.
This is the ordinary SHA1. Rebase or amend a commit, the SHA-1 will change. - One on the left, such as
spkmxmnw.
This is the Change ID. This identity stays the same no matter the operations you perform.
Notice how Jujutsu highlights the few characters that suffice to unambiguously reference a commit.
4. Commit
@ is both your working copy and a real commit. When you edit files,
you also modify the commit itself. Editing the repository directly
might sound scary, but don't worry: it's a feature, not a bug. You
will learn in The Squash
Workflow and Dispatching Edits
from Megamerges how to leverage this
super-power.
One surprising consequence: in Jujutsu, you typically commit before you code. Here's how it works.
Describe beforehand your work:
jj describe -m "docs: prove I have feelings"
Create a new empty commit:
jj new
Log all current commit's ancestors:
jj log -r ::@

Do you see the 2 empty commits on the top? Think of them like the
working copy and the index, in Git.
Now, code:
$ echo "Written with ❤️" >> README.md
$ jj st
Working copy changes:
M README.md
Working copy (@) : qywvxquo f338a3de (no description set)
Parent commit (@-): plxwmqmu c9ec1ea2 (empty) docs: prove I have feelings
As you see, your changes are already part of the commit. Jujutsu has
no equivalent to git add.
Finish the session by sending your changes 1 commit back, where they
belong:
jj squash

Good, your change landed in nm and you are now in a fresh, empty
commit.
5. The Typo In The Old Commit Message
Look at this commit:

There's a typo in the description. Before, you have used jj description (desc for short) to describe the current commit. You
can use the same command with past commits too:
$ jj desc -r w -m "Add search by tag"
Rebased 4 descendant commits
Working copy (@) now at: ovxurmlk 88f2c0d6 (no description set)
Fixed. Notice the message
Rebased 4 descendant commits
As you manipulate commits, Jujutsu performs the needed rebases on the
fly. Basically, you can treat all the commits as mutable, no matter where they are.
Notice how the SHA-1 values changed, while the Change IDs are still
the same.
6. Rebase
Say you want to rebase dev on top of store:
jj rebase -b dev -o store
Jujutsu replies with
Rebased 6 commits to destination
But then it adds:
New conflicts appeared in 5 commits:
nttpknso 219791e0 dev* | (conflict) WIP: parse --due dates
lwzqwkyz 83c89c2d (conflict) Add LICENSE
unztuwko ee801a30 (conflict) Sort tasks by priority
kvnpnqnw ed1c4a78 (conflict) debug: dump args to stderr
wywpzopw 2ffce574 (conflict) Add search by tag
Hint: To resolve the conflicts, start by creating a commit on top of
the first conflicted commit:
jj new wywpzopw
Then use `jj resolve`, or edit the conflict markers in the file directly.
Once the conflicts are resolved, you can inspect the result with `jj diff`.
Then run `jj squash` to move the resolution into the conflicted commit.
Gosh, conflicts!

Two things to notice:
- First, you rebased a branch while you were somewhere else. Git doesn't let you do that.
- Second, interestingly Jujutsu did not stop because of conflicts: it completed the rebase nevertheless, and now it shows where the conflicts are. You can keep working and resolve the conflicts when you prefer. You'll find plenty of information and tricks in the chapter Conflicts.
Should you want to undo the rebase, you could run jj undo: it's a
passe-partout command that undoes whatever operation you run, no
exceptions. Give it a try:
jj undo

The rebase is gone, and so are the conflicts. You could keep running
jj undo until you are back to the very moment you cloned the repository.
Now try to undo the undo:
jj redo

You are back to the future, to the conflicted state.
You can go back and forth in the operation history with jj undo, jj redo and other powerful sub-commands under the jj op command. Think
of them as Git's reflog on steroids. You will read about them in It's
History All The Way Down.
7. Rebase a Conflicted Branch
On second thought, you wanted to rebase store on top of dev, not
the other way around. So, you need to move the commits from kk to
s on top of n / dev:

Run:
jj rebase -r 'kk::s' -o dev

Much better, only 1 conflict.
Time to resolve the conflict. You move on top of the conflicted commit
s:
jj new s

You'd better fix it later, though: your boss is yelling at you about some SUPER URGENT tasks.
8. Amending a Commit From a Distance
"RED ALERT!", "your boss panics "there's a bug! DROP EVERYTHING, you
MUST fix it NOW!"
It's about this code:
if len(args) >= 2 and args[0] == "--priority":
priority = int(args[1])
args = args[2:]
Nothing alarming, you think: you get an exception if args[1] isn't a
number. You just need to add error handling around it. Which commit
introduced the expression int(args[1])?
$ jj log -r 'diff_lines(substring:"int(args[1])")'
○ rpzkmwvr arialdo 2026-07-16 09:54:27 068609d1 Add --priority flag
│
~
Here's the culprit.
diff_lines(substring:"int(args[1])") is an example of a Revset: an
expression in a powerful (yet intuitive) purely functional language
for selecting commits. In Git, revision selection is a pile of
non-composable fragments (such as HEAD~3, main..feature,
--author=alice, -S"int(args[1])") each available only where it was
bolted on (e.g., git log takes -S, git rebase doesn't). In
Jujutsu it's one single, consistent language that every command
shares.
Anyway, look! The bug hasn't been introduced by your current commit, but 5 commits down the stack. No problem, you'll fix it from a distance.
Wait, what? You are in a conflicted commit! Can you really code while
there are pending conflicts? Yes, you can: after all, the bug fix is
about r, a conflict-free commit.
So, amend cmd_add in commands.py from your current commit:
def cmd_add(args):
priority = 3
if len(args) >= 2 and args[0] == "--priority":
try:
priority = int(args[1])
except ValueError:
print("priority must be a number", file=sys.stderr)
return 2
args = args[2:]
due = None
...
Review it, then send it five commits down:
$ jj squash --into r
Rebased 7 descendant commits
Working copy (@) now at: wmlyyqmm ef6c08d3 (conflict) (empty) (no description set)
Parent commit (@-) : spkmxmnw e4538e55 store* | (conflict) Add `done` command
Cool, your fix landed in r. Seven commits, including your working
copy, rebased on top of it. Boss (slightly) happier.
Jujutsu reminds you that you still have conflicts in s:
Warning: There are unresolved conflicts at these paths:
taskr.py 2-sided conflict
Existing conflicts were resolved or abandoned from 1 commits.
There are still unresolved conflicts in rebased descendants.
Hint: To resolve the conflicts, start by creating a commit on top of
the conflicted commit:
jj new spkmxmnw
Then use `jj resolve`, or edit the conflict markers in the file directly.
Once the conflicts are resolved, you can inspect the result with `jj diff`.
Then run `jj squash` to move the resolution into the conflicted commit.
9. Amending The Last Commit
Your boss (who else?) pushes for dev to be completed. The code in
n is unfinished:

It parses --due but never displays it, which is probably why it
still says WIP. "FINISH IT!", boss commands. Yes, Sir!
The commit dev lays right in the middle of a chain of commits, but
that's not a problem, it's always the same pattern:
$ jj new TARGET # create a working staging area.
$ emacs FILE # write code (choose your editor wisely, no pressure)
$ jj diff # review what you did.
$ jj squash # incorporate in the commit.
So:
jj new dev

Change cmd_list in commands.py to:
def cmd_list(args):
tasks = load()
if not tasks:
print("no tasks")
return 0
ordered = sorted(tasks, key=lambda t: t.get("priority", 3))
for n, task in enumerate(ordered, 1):
suffix = " (due %s)" % task["due"] if task.get("due") else ""
print("%2d. [%d] %s%s" % (n, task.get("priority", 3), task["text"], suffix))
return 0
Review your change before amending the commit:
$ jj diff
Modified regular file commands.py:
...
47 47: return 0
48 48: ordered = sorted(tasks, key=lambda t: t.get("priority", 3))
49 49: for n, task in enumerate(ordered, 1):
50 50: suffix = " (due %s)" % task["due"] if task.get("due") else ""
50 51: print("%2d. [%d] %s%s" % (n, task.get("priority", 3), task["text"], suffix))
51 52: return 0
52 53:
53 54:
...
Fine. Squash your work to n, and amend the description:
$ jj squash -m "Parse --due dates"
Working copy (@) now at: ovxurmlk 3e81b7a4 (empty) (no description set)
Parent commit (@-) : qpvuwxyt 2c5f9e13 Parse --due dates
You'll read about this workflow in Don't Edit.
10. Throwing a Commit Away
You are ready to resolve the conflict when you notice the commit
kv:

What is it about?
jj show kv

Ha! A stray troubleshooting print. The whole commit can be completely abandoned:
$ jj abandon kv
Abandoned commit vkrysnwp 2a9f0c15 debug: dump args to stderr
Rebased 4 descendant commits onto parent of abandoned commit
Working copy (@) now at: pzrtklvq 4a7c1d92 (empty) (no description set)
Looks like your boss isn't the only reason you've been putting off that conflict resolution, eh?
11. Resolve The Conflict
Where was the conflict, by the way?
$ jj log -r 'conflicts()'
× spkmxmnw arialdo 2026-07-16 11:12:20 store* 07aa62de (conflict) Add `done` command
│
~
Let's go there:
$ jj new sp
$ jj resolve --list
taskr.py 2-sided conflict
$ jj resolve --tool meld

Both sides want their command in the list. Both should succeed, so you can replace the conflict with:
COMMANDS = ["add", "done", "list", "search"]
$ jj st
Working copy changes:
M taskr.py
Working copy (@) : pputmlzu 3474f499 (no description set)
Parent commit (@-): spkmxmnw 07aa62de store* | (conflict) Add `done` command
Hint: Conflict in parent commit has been resolved in working copy
Good, no more conflicts.
jj squash

All clear!
12. Moving License
Look at this commit:

It seems that the license file was added too late, only when somebody
noticed it was missing. Move it where it belongs, before commit
v. It should have been there from the start:
jj rebase -r l --before v
Uh oh! It's time for Jujutsu to whine:
Error: Commit 06acab9454ba is immutable
Hint: Could not modify commit: vmztuwwu 06acab94 main | taskr app
Hint: Immutable commits are used to protect shared history.
Hint: For more information, see:
- https://docs.jj-vcs.dev/latest/config/#set-of-immutable-commits
- `jj help -k config`, "Set of immutable commits"
Hint: This operation would rewrite 1 immutable commits.
Good boy! Jujutsu saves you before the damage is done! Git would let you rebase and surprise you with a: "Hello, rejected push!" later.
If you really insist, you can always use --ignore-immutable:
jj rebase -r l --before v --ignore-immutable

Jujutsu makes it clear that the local main and the remote main
diverge.
13. And Git?
$ git log --oneline --graph
git log --oneline --graph
* 5fbe696 (HEAD, store) Add `done` command
* 7123075 Store tasks in ~/.taskr.json
* 83e3f8f (dev) Parse --due dates
* 5691e5a Sort tasks by priority
* 038d354 Add search by tag
* cbc233b Add --priority flag
* 8679eed (main) taskr app
* 18ca92f Add LICENSE
It's still a 100% Git compatible repository. Your teammates won't even notice that you are playing with your brand new toy.
What's not to love?
Why?
Many people I know don't have much motivation to give Jujutsu a try because, they say, "Git works just fine". Fair enough! I assume that you are reading this book because you are curious to see what Jujutsu brings to the table.
A natural question to address is why Jujutsu exists and what
problems it solves.
Spoiler alert: the funniest part is that it solves problems that you
didn't know you had1.
So, the first question is: does Git really work as well as we think?
Isn't Git just enough?
A scenario I like to present in job interviews is: pick your favourite technology and imagine you had a magic wand. What are the 3 things that bother you most and you would change?
Without questioning that Git is a masterpiece of engineering (I'm myself a huge fan!), can we agree on 3 things that could be improved? Try answering this on your own.
Here's my take.
1. Git has too many building blocks
Consequently, some find it difficult.
- Some people find the index a bit mysterious.
detached HEADstresses them out.- They are not sure when to
git resetwith either--soft,--mixedor--hard. - Or when to use
--stagedwithgit restore. - When they mess up, they panic and struggle to undo their changes.
Could Git be simpler, without sacrificing its power?
With my magic wand I would summon a smarter model, based on far fewer,
more intuitive elements designed to be composable. The result:
something smaller yet more powerful than Git itself.
Jujutsu does this magic. With a tiny fraction of building blocks, it makes those operations that were intimidating or even impossible with Git straightforward.
2. Git's interface is not always consistent
"How can I view a list of all tags?"
"git tag", replied Master Git.
"How can I view a list of all remotes?"
"git remote -v", replied Master Git.
"How can I view a list of all branches?"
"git branch -a", replied Master Git.
"And how can I view the current branch?"
"git rev-parse --abbrev-ref HEAD", replied Master Git.
My preferred example of inconsistency is the index, which for many Git novices is a source of confusion. It's the staging area where you forge the next commit. Pro Git calls it the "proposed next commit snapshot". Is it the next commit? Not quite. In fact, it is a quasi-commit.
- It has a SHA1 checksum (
tail -c 20 .git/index | xxd -p), but it's not its address. A branch cannot target it. - It's not a pointer to a tree-object like a real commit. So, you
can't use
git showto inspect its content. You have to usegit diff --cached. - If you want to move a commit around, you use
git rebase. If you want to move the index, you needgit stash.
Index is a special case, so it needs special commands, completely inconsistent with the commands for real commits.
Magic wand, let me turn the index into a real commit, with all its power. Presto! All the commands involving the index and commits become consistent with each other. Well, actually, they collapse to the same commands.
API consistency results from the absence of special cases, and Jujutsu
embraces this idea: it removes all the special cases. You still want
to stash changes, don't you? But you don't want to have special stash pop, stash drop, stash apply commands: you want to reuse the
building blocks you have already learned.
Welcome, composition.
3. It's a leaky abstraction
Git feels hard at first, but it stops fighting you once you learn a bit of its internals.
Why doesn't git diff show staged files? How do you use reset and
checkout to restore changes and start over? How do you undo a rebase?
The answers click once you know a few implementation details such as:
- Branches are mere pointers.
- Git keeps the working directory, the index and the repository as three separate areas.
- Commits are immutable, and any operation manipulating the history will just create new ones.
When people struggle with Git, it's often because they have built a wrong intuition about how it internally works. But, is this their fault? I don't think so.
If I had a magic wand, I'd ask for a tool with a truly opaque abstraction, completely hiding its internals, one that captures what I mean, the what, quietly handling the low-level mechanics, the how.
To see what I mean, take the example of "undoing stuff". You can always consult the Git reflog, check what you did and figure out which low-level operation you need to revert a change.
- You did
git rebase -i HEAD~2. To undo,git reset --hard ORIG_HEAD - Did a
git stash apply? You needgit stash drop. - Did a
git tag? You needgit tag -d - Did a
git add? You needgit restore --staged <file> - Did a
git commit --amend? You needgit reset --soft HEAD@{1}
That is: you need to derive the inverse operation for the command you want to undo, and this often requires knowing Git's internals.
What's the equivalent in jj?
jj undo. Want to undo a fetch? jj undo. Want to undo a rebase, a
merge, a commit, a conflict resolution? jj undo, always jj undo. It's consistently jj undo, no matter what change you made.
jj undo is one of the Jujutsu killer features. I promise, you will
wonder how you could have worked for so many years without undo.
We'll see this in detail later.
First, let me show you an example with deeper consequences. We will talk about a well-designed piece of Git and you will learn your second Jujutsu command.
-
This reminds me of The Blub Paradox (Paul Graham - Beating the Averages). "[People] are satisfied with whatever language they happen to use, because it dictates the way they think about programs.".
That is: until you've used more powerful tools, you don't experience their advantages as missing features of your current tool. In a sense, you can't miss what you've never had. ↩
Amending
You just found a typo in your last commit. How do you fix it?
A---B---C <- main (HEAD)
Of course:
git commit --amend
Voilà, and the last commit is changed!
A---B---C' <- main (HEAD)
This is what I call a beautiful abstraction! We all know that commits
are immutable and that git commit --amend is no exception.
Indeed, amend did not really change any commit: under the hood, it
created a new commit with the same parent as the commit to fix.
A---B---C <- main (HEAD)
\
C' <- new, without any ref
Then, it reset the current branch ref to it:
C
/
A---B
\
C' <- main (HEAD)
The old commit C is still there but it's hidden, so you only see:
A---B---C' <- main (HEAD)
Git amend switched commits, pulling a fast one on you, giving you the impression that the last commit was editable. And this is the key: abstractions are all about providing a simplified view of the world, where details are kept out of sight.
Under the hood, git commit --amend is equivalent to something like:
git reset --hard HEAD^
git cherry-pick -n ORIG_HEAD
git add .
git commit -C ORIG_HEAD
But you don't need to know. The beauty of Git amend's abstraction is that it captures your intention, and it transparently deals with the internal mechanics.
Neat!
Leaky abstraction
How do you amend the second to last commit?
Oops, there's no git commit --amend HEAD~1. Instead, you have to do:
git commit --fixup=HEAD~1
git rebase -i --autosquash HEAD~3
or:
git checkout HEAD~1
git add .
git commit --amend
git cherry-pick main
git branch -f main HEAD
git checkout main
Intimidating... The wonderful surface of abstraction has torn, and underneath you can see all the gears! It's no surprise that few people bother amending the penultimate commit.
What about amending Y?
A---B---C---D---E---F
\ /
X---Y---Z
Oh beautiful abstraction, where art thou?
More consistent abstraction
Can we do better? Although internally commits are immutable, when you amend your intention is to edit a commit. In other words, the metaphor underlying Git amend's abstraction is:
The last commit is editable.
Can we embrace this metaphor and make it universal? Doing so, one
might conceive a command jj edit to modify any point of the
history tree:
A---B---C <- main (HEAD)
Want to amend C?
jj edit C
Want to amend B?
jj edit B
What if you had:
A---B---C---D---E---F
\ /
X---Y---Z
and you wanted to edit Y? You have already literally expressed your intention:
I want to edit Y.
Well, just type it!
jj edit Y
We went back to a beautiful, intuitive UX! jj edit captures your
intention, the what, and deals with the necessary internal rebasing,
resetting, cherry-picking, whatever, the how.
Of course editing Y implies cascading changes to Z, E and F,
but what's the point of giving step-by-step instructions to your
versioning system?
In practice
I promised this tutorial would be down-to-earth. So:
- Install Jujutsu.
- Crack open a shell.
cdto your Git repository.jj git init. No worries, it's safe. Jujutsu happily lives together with Git. You don't need to switch to Jujutsu.- Keep working. Keep using Git.
- Need to amend the penultimate commit or a commit deep down the history tree, and you struggle to do this with Git?
jj edit <SHA1>
# Do your changes
# Then switch back to where you were
git checkout <your-branch>
I'm lying a bit. You might encounter conflicts or other situations you'd better get prepared for. So, better keep reading just a bit more.
I hope you just had your first a-ha moment.
Rebasing with Git
If I could use the magic wand for the 4th time, I would improve
how git rebase works.
I do love workflows based on rebase and, coming from CVS and
Subversion, I still think rebase is one of the killer features of
Git. But I cannot help thinking git rebase is broken.
That's a bold claim. Let me unpack it.
Conflicts
Rebase is fantastic until it's not. Think about it. Git is designed to be very safe and conservative:
- It treats your filesystem as sacred. Unless you explicitly tell it
to
git addfiles, it diligently keeps its hands off them. It won't touch your files unless you say so. - Internally, it is strictly append-only. It never deletes info. No matter the operation you perform, it's never destructive; there's always a way to revert what you did.
This behavior instills serenity. You can work relaxed; your code is in trusted hands. You can happily jump back and forth in your history, move files left and right: Git is always there, solid and stable, to support your needs.
Danger Mode
Until you rebase and there's a conflict. Then Git enters danger mode. It basically stops working, as if it's in panic, and offers you only 2 ways to exit:
- Either you
git rebase --abort. - Or you solve the conflicts, now!, and in the exact order it tells you.
You cannot do anything else. It does not even know, let alone tell you, how many conflicts you'll face. It just stopped at the first conflicted commit and handed it over to you.
While in this panic mode, Git does not work. You cannot jump back and forth in your history anymore. If you think that visiting another commit would help you fix the code, forget it: Git won't let you. You cannot cherry-pick, you cannot bisect. What if you have an urgency (like a hotfix to release) while you are struggling with a difficult conflict resolution? Can you pause the rebase, work, and recover your work later? Nope.
Your precious project does not work anymore, and Git quit supporting you exactly when you needed its help the most.
I know it's less dramatic than this; there are always ways to recover. No wonder many people avoid rebasing and stick with the more comforting merge.
Can we de-escalate this?
If I could ask for the moon, I would like a tool that:
- Tells me at once all the conflicts a rebase will encounter.
- Does not stop working just because of conflicts.
- Lets me keep working on important stuff, postponing the conflict resolution.
- Lets me solve conflicts both by editing files and also via whatever feature may help.
Jujutsu is that tool. You will see in the next pages: conflicts are first-class citizens and can be manipulated in far more convenient ways than just editing a file in an emergency. No stress, no drama. Jujutsu keeps working during conflicts, with all its features available.
And you will use them! Because it opens some interesting scenarios that are hardly conceivable with Git. Have you ever considered solving conflicts by editing a commit other than the ones being rebased? Or by performing another rebase (a rebase within a rebase!).
It's much simpler than it sounds. You'll see in a few pages.
In practice
Here's an experiment you can try. Next time you need to rebase a
branch, make a copy of your repo (better safe than sorry, as long as
you are a jj-newbie), jj git init it then try:
jj rebase -s <FROM> -o <TO>
where:
<FROM>where to cut.<TO>where to paste.
For example, say you want to move the branch 3-4-5-6 on top of 11:
jj log
○ 11 <---- paste
○ 10
○ 9
│ ○ 8
│ ○ 7
├─╯
│ ○ 6
│ ○ 5
│ ○ 4
│ ○ 3 <---- cut
├─╯
○ 2
○ 1
Then:
jj rebase -s 3 -o 11
will get you to:
○ 6
○ 5
○ 4
○ 3
○ 11 <---- paste
○ 10
○ 9
│ ○ 8
│ ○ 7
├─╯
○ 2
○ 1
With the same ease you can select other cut/paste points, even in the middle of the history tree:
○ 11
○ 10 <---- paste
○ 9
│ ○ 8
│ ○ 7
├─╯
│ ○ 6
│ ○ 5
│ ○ 4 <---- cut
│ ○ 3
├─╯
○ 2
○ 1
jj rebase -s 4 -o 10
○ 11
│ ○ 6
│ ○ 5
│ ○ 4
├─╯
○ 10 <---- paste
○ 9
│ ○ 8
│ ○ 7
├─╯
│ ○ 3
├─╯
○ 2
○ 1
And if there are conflicts? jj log will tell you where.
○ 11
│ × 6 <---- conflict
│ × 5 <---- conflict
│ ○ 4
├─╯
○ 10
○ 9
│ ○ 8
│ ○ 7
├─╯
│ ○ 3
├─╯
○ 2
○ 1
Then you can fix the conflicts where they are:
jj edit 5
It's likely that fixing the conflict in 5 will propagate the
resolution to 6. Otherwise, you can continue with:
jj edit 6
In any case, you can count on jj undo.
In practice though, you will probably encounter confusing scenarios and need a few more tools.
So, my recommendation: play with jj log, jj rebase and jj undo
in a safe copy of a real repository but, please, don't stop reading
here; there are a few other things you will love to learn!
Log and Identity
Getting back to Git amend: although I believe it is a very nice abstraction, I still see a little leaky abstraction. See this:
A---B---C <- main (HEAD)
git log --oneline
ddb8cf5 (main) Publish shell script
echo "fix typo" >> myfile
git commit -a --amend --reuse-message=HEAD
git log --oneline
bea2735 (main) Publish shell script
A---B---C' <- main (HEAD)
Within my metaphor, I modified the last commit. Yet git log insists
on screaming at me:
"Hey! That's not the same commit! It's a new one! It was
ddb8cf5before, it'sbea2735now, completely different identity!"
Do I really need this constant reminder? Must Git always be so pedantic?
When I'm writing code and collaborating with colleagues, I think at a
level above SHA1s. The meaning of "the last commit" is defined by how
my team and I refer to it. And honestly, we always call it "the last
commit", never bea2735. Can't we just happily stay in our sweet
metaphor?
A good abstraction hides what you don't need to see. Git is already willing to hide the old commit. Why not hide the change of identity too?
Immutable identity
See how Jujutsu behaves:
jj log
@ m arialdo@ik.me 2026-06-06 15:55:54 2caa192
│ (empty) (no description set)
○ v arialdo@ik.me 2026-06-06 15:55:04 ddb8cf5
│ Log and Identity
○ ym arialdo@ik.me 2026-06-06 15:29:39 429aa18
│ Publish shell script
...
Focus on the line:
○ v arialdo@ik.me 2026-06-06 15:55:04 ddb8cf5
The rightmost ddb8cf5 is the Git Commit ID, a SHA1.
Also notice the v on the left. This is the Change ID, an immutable
reference to what you intuitively call "the last commit".
Let's Git amend, Jujutsu style:
jj edit v
echo "fix typo" >> myfile
jj log
○ v arialdo@ik.me 2026-06-06 15:55:04 bea2735
│ Log and Identity
○ ym arialdo@ik.me 2026-06-06 15:29:39 429aa18
│ Publish shell script
...
Interesting! While the Git SHA1 changed (you expected that), the
Change ID did not. You can keep calling that Change v. It will
always be v. Change IDs are 128-bit, random numbers, immutable and
independent of the content, so they remain stable over time.
As small as this sounds, that's a huge turning point in the way you interact with your history. It's the entry point of a rabbit hole where you manipulate history in terms of what and not in terms of how. You will see tons of similar examples while using Jujutsu.
If you think about it: Git does something similar with branches. After
an amend, main points to a different commit, yet everyone still
calls it main. Jujutsu brings this idea to all commits.
Reverse Hex
Won't Commit IDs and Change IDs clash? Not at all. Git's Commit IDs
are rendered in hexadecimal, with digits 0 to 9 and letters A to
F. Change IDs such as v and ym are rendered in reverse
hexadecimal,
using letters from Z to K. They never overlap: a Change ID can
never be confused with a Commit ID.
Smart, isn't it?
Show me less
Have you noticed that most characters of the IDs in jj log are
grayed out?

Internally, Commit IDs are SHA1s, 160-bit numbers represented with
40-char in hexadecimal. By default Git only displays 7
characters. Why exactly 7? Because it's usually enough; there is
no ambiguity. In fact, often Git could use even less. But it doesn't
try to optimize.
Jujutsu goes a step further. If it realizes that 2 chars are enough,
it highlights 2 chars only. 1 is enough? It uses 1 only. Why
display unnecessary information?
In fact, you can even hide it completely.
Take it to the limit
By default Jujutsu:
- Still displays the Git Commit ID.
- Highlights the strictly necessary characters, graying out the other ones.
- Displays 2 lines per commit.
When something is Good™, it deserves to be taken to the next level. So:
- I just hide the Git Commit ID. It's rarely used and it can be displayed on demand.
- If a Change can be unambiguously identified with w, why highlight it in wnyxkzzm instead of just displaying w? If something is unnecessary, remove it.
- 2 lines per commit waste valuable screen space.
Instead of:
@ xlptstlm arialdo@ik.me 2026-06-06 17:18:46 039a04d
│ (no description set)
◆ poqzxkvk arialdo@ik.me 2026-06-06 16:32:39 main 9376594
│ Review of identity
~ (elided revisions)
│ ○ zzmuxzrl arialdo@ik.me 2026-06-06 16:32:45 pages 29b4782
│ │ typo: need -> needs
│ ○ wuwotnsy arialdo@ik.me 2026-06-06 16:23:08 e655e34
│ │ Review of Why
│ ○ puvzpnwv arialdo@ik.me 2026-06-06 15:19:09 eae4a1f
│ │ PRs are welcome
│ ○ zzsynxzk arialdo@ik.me 2026-06-06 14:26:10 3e3cf7b
│ │ Publish script
│ ○ yroskows arialdo@ik.me 2026-06-06 14:08:55 af28aa37
├─╯ init pages branch
◆ zzz root() 0
I prefer:
@ x
◆ po 🔒 Review of identity main
~ (elided revisions)
│ ○ zzm typo: need -> needs pages
│ ○ w Review of Why
│ ○ pu PRs are welcome
│ ○ zzs Publish script
│ ○ y init pages branch
├─╯
◆ zzz 🔒 empty
I will use this template from now on. You won't regret using it too. Read in Human Friendly Log how to configure your Jujutsu.
In practice
You've learned that Jujutsu and Git happily co-exist.
- Run
jj git initin any Git repo you like. - Git won't even notice: Jujutsu lives in an ignored
.jjdirectory. - You can keep working with Git as usual.
- In a sense, you can consider Jujutsu a Git client on steroids.
- Try using
jj log. It's harmless and often a better option thangit log, because it tends to show only the information you need, hiding the inessential. - You will soon learn the Revset Language, then
jj logwill shine and you will probably never usegit loganymore.
Let's Dive Into The Rabbit Hole
You may not have noticed, but during the "Jujutsu style amend" above, we glossed over some important details. Such as: we never committed. How could that work?
Time to find out. And time to stop talking about what can be improved, and to start learning Jujutsu for real.
Shopping list
Here's my formula to learn Jujutsu:
- Clear the slate, stop having Git as your mental model.
- Embrace a few new assumptions. They'll seem repugnant at first. Try to suspend judgment. They'll pay off.
- Learn the few basic building blocks. It's super easy, it's no more than 5 commands.
- Profit.
After this, you will:
- Be astounded by the new workflows you never thought were possible. Learn them, invent new ones.
I trust you with step 1. Let's go with 2. I'm going to tell you about a few traits of Jujutsu that will make you cringe.
Git Was An Adorable Maverick
Being an old programmer and coming from CVS and Subversion, I clearly remember when Git was presented. It really sparked a chorus of angry protests:
- "Keeping a local copy of whole history! This is so stupid!"
- "Branches are not real branches!"
- "Rewriting the history! Heresy! To the stake!"
- "No central server! The world will collapse into anarchy!"
Looking back, it was a fun show. I just never expected a sequel.
Jujutsu Is Being a Maverick Like Git
Better to hear all the bad news now so we can move on. Let's pull this tooth:
- Jujutsu pushes are forced by default.
- It commits by itself, at every possible occasion.
- Even
jj statuscommits. - Rebases happen automatically.
- By default, Git ignores files. Jujutsu tracks them.
- Commits are mutable.
- Working in detached HEAD is the standard.
- With Git, first you code, then you commit. With Jujutsu it's idiomatic to commit before coding.
jjwon't update Git branches as you commit.- It deletes commits without any warning.
At times it feels like Jujutsu was designed to deliberately upset Git fans (it's not like this, of course, but it's still a funny show). In the same vein, Jujutsu is defined more by what it removes than by what it adds:
- It's branchless.
- There's no index.
- Stash does not exist.
- There's no
mergecommand. - No
cherry-pick, noreset, noadd.
Given this iconoclastic baseline, you'll be dismayed knowing that people use Jujutsu:
- working in the middle of a branch, not at its tip.
- Sending commits to the past.
- Developing on multiple features, then dispatching the changes around.
- Working simultaneously on multiple branches.
Horrified? Wait until you realize you love every bit of it.
Still with me? Good: you are already at step 3. Let's learn the building blocks and finally profit.
Elements of a Grammar
Git has 160+ commands. Some are plumbing operations, such as
hash-object and write-tree. Some are porcelain commands, such as
commit and log. These are built on top of the plumbing commands.
Some porcelain operations could be thought of as combinations of other
porcelain operations. For example: pull is clearly fetch followed
by merge/rebase. cherry-pick is diff + apply + commit. I
like to think of rebase as a series of cherry-picks followed by a
reset.
Here's a challenge. Setting plumbing aside (we never want to break the abstraction), what basic commands would suffice to build an entire versioning system?
Think about it, write them down.
Basic Commands
If you thought of CRUD, you nailed it!
Indeed, here are Jujutsu's basic commands. To manipulate your history tree you need to:
| Operation | Command |
|---|---|
| Create | new |
| Read | show |
| Update | edit |
| Delete | abandon |
the repository Changes.
Notice that I wrote "Changes", not "commits". You remember that when you amend a commit Git creates a different one. Jujutsu lets you keep referencing it with the same Change ID, treating it as the same thing. That thing is the Change, defined as:
"a commit as it evolves over time" — Glossary.
On top of that CRUD, you'd probably also like to:
| Operation | Command |
|---|---|
| Move a Change somewhere else | rebase |
| Move the edits you made somewhere else | squash |
| Copy files from a Change to another | restore |
| Display the history tree | log |
| List / undo / redo operations | op log / undo / redo |
That's... basically it? Learn these and you will master Jujutsu.
Macro Commands
Of course, on top of these, you can have commands that are in fact combinations of other commands. Some examples are:
| Operation | Possible command |
|---|---|
| Split a Change in 2 | split |
| Duplicate a Change | duplicate |
| Juggle Changes, interactively | arrange |
| Bisect (you know, the best of Git commands) | bisect |
| Magically, distribute edits where they belong | absorb |
and some other ones. But, I swear, the very first 2 sets are enough to take you way further than you've ever been with Git.
Worth a try. Open a terminal, grab your keeb and prepare to get your hands dirty.
Two Recommendations Before Typing
-
We will use throwaway repos. Yet, you will want to try some commands on real ones. For safety, the first times make a copy. Jujutsu is very powerful and mistakes can leave you far from what you expected to be.
-
We will do many exercises. Some will look artificial, not something you'd typically do at work. We are here to learn how Jujutsu thinks, pushing it to the limit.
The contrived cases (juggling commits, breaking the history tree apart and putting it back) will help you internalize the model underneath. Then, the practical cases will become muscular memory.
Finally, Change IDs are random: when the ones in your terminal don't match mine, please, adjust your commands accordingly.
Init
You already saw that jj git init adds Jujutsu to an existing Git
repository. If there is no Git repository yet, it will create one.
Let's start from an empty repo:
$ git init
Initialized empty Git repository in ~/jj-playground/init/.git/
Enable Jujutsu in it:
$ jj git init
Initialized repo in "."
Hint: Running `git clean -xdf` will remove `.jj/`!
Multiple backends
Why isn't it just jj init? Because Jujutsu is modular and can work
with different storage backends. We will stick with Git here, so never mind.
Won't jj and Git clash?
Relax. Jujutsu lives in the hidden directory .jj, which Git ignores:
$ ls -a
. .. .git .jj
$ ls -a .jj
. .. .gitignore repo working_copy
$ cat .jj/.gitignore
/*
You probably noticed the line:
Hint: Running `git clean -xdf` will remove `.jj/`!
It makes sense: that git clean command is meant for deleting ignored
directories.
jj git lets you also access other Git commands with jj git clone,
jj git fetch etc.
Want To Remove Jujutsu?
Delete .jj and Jujutsu is gone.
Log
Try a git log:
git log
fatal: your current branch 'main' does not have any commits yet
As expected.
What about jj log?
$ jj log
@ x empty
◆ z 🔒 empty
Jeez! Where Git fatally errors out, Jujutsu shows not 1, but 2 Changes! How can it be?
The Origin of Time
Let's inspect z:
jj show z
Commit ID: 0000000000000000000000000000000000000000
Change ID: zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz
Author : (no name set) <(no email set)> (1970-01-01 02:00:00)
Committer: (no name set) <(no email set)> (1970-01-01 02:00:00)
(no description set)
Looks like a Null Object
Pattern. Indeed
zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz is the "root commit", the original
empty Change, the black hole every repository in the world originates
from. In Git, it's implicit and has no concrete representation, so
orphaned
branches
need special commands and options. That's not the case with Jujutsu:
zzzz is a loving parent, and every orphan has a home.
Fine, zzzz makes sense. But what about the Change on top of it?
This requires a tiny bit of theory.
Collapsing areas
One approach to understanding Git is to visualize the different areas that exist in its model, and how commands operate on them.
In Jujutsu, the stash, the index, the working area and the local repository all collapse to a single area. So, Jujutsu's model is:

This means that the @ you see in:
$ jj log
@ x empty
◆ z 🔒 empty
represents the current file system, the working copy of your project. But this is also a commit in your repo. They just happen to be the same.
Why is it already there? Well, when you run git init the file
system, the empty working copy of your project, was already there.
Naturally, this should already be reflected in the log.
What's inside x?
Is x similar to zzzzz? What does it contain? How can you find out?
Solution
Solution
If you guessed jj show, good job!
$ jj show x
Commit ID: cb39db5ba245a72667c7bbd055b625e9522b74f8
Change ID: xmvkoxkuvlrynmwooqtrxtxptmswruqq
Author : Arialdo <arialdo@ik.me> (2026-06-09 16:10:17)
Committer: Arialdo <arialdo@ik.me> (2026-06-09 17:44:18)
(no description set)
It's a real commit. It has a real SHA1 (cb39db5), and Git itself
acknowledges it has an author:
$ git show cb39db5
commit cb39db5ba245a72667c7bbd055b625e9522b74f8
Author: Arialdo <arialdo@ik.me>
Date: Tue Jun 9 16:10:17 2026 +0200
It's messageless and empty, but it still looks like a legit commit.
Committing
Wait a sec. If @ represents the current file system:
$ jj log
@ x empty
◆ z 🔒 empty
which is empty because you have no file in your project yet, what
happens if you make any change to the disk so the project is not empty
anymore? That's an excellent question, and you could already guess the
answer. Try creating an empty README.md:
$ touch README.md
$ jj log
@ x
◆ z 🔒 empty
Interestingly, x is not marked as empty anymore. If you ask Git,
it will detect that there is a new, untracked file:
$ git status
No commits yet
Changes not staged for commit:
new file: README.md
no changes added to commit.
What's Jujutsu's opinion on the status?
$ jj status
Working copy changes:
A README.md
Working copy (@) : xmvkoxku b727840f (no description set)
Parent commit (@-): zzzzzzzz 00000000 (empty) (no description set)
Two things to notice:
- Jujutsu has already added the file (
A README.md). It's not untracked. Unlike Git, new files are automatically tracked. There's no equivalent ofgit add. - Easy to miss: the SHA1 has changed.
Inspect x again. Will it be empty?
Solution
Solution
Of course it's not! x must contain README.md now. In fact, x and
your filesystem are one and the same: change either, and you change both.
$ jj show x
Commit ID: b727840fcbf04053c43d30b8581876260a240840
Change ID: xmvkoxkuvlrynmwooqtrxtxptmswruqq
Author : Arialdo <arialdo@ik.me> (2026-06-09 16:10:17)
Committer: Arialdo <arialdo@ik.me> (2026-06-09 16:10:17)
(no description set)
Added regular file README.md:
(empty)
Notice the:
Added regular file README.md
Oh dear! Just
creating a file, you have in fact performed a git commit --amend.
Here's the final challenge. How would you add some content to README.md
and amend x again?
Solution
Solution
Just edit README.md!
echo "Hello, world" > README.md
$ jj show x
Commit ID: 126c51d1a7bddbd205721e64a0824233c68a56a0
Change ID: xmvkoxkuvlrynmwooqtrxtxptmswruqq
Author : Arialdo <arialdo@ik.me> (2026-06-09 16:10:17)
Committer: Arialdo <arialdo@ik.me> (2026-06-09 16:13:12)
(no description set)
Added regular file README.md:
1: Hello, world
If you guessed it right, bravo! In Jujutsu the working directory and the repository are just the same area, and they are always matching. The lines:
Added regular file README.md:
1: Hello, world
confirm that x holds README.md with its content.
It's yet another SHA1: under the hood, there was another git commit --amend, while from Jujutsu's perspective, it's still the Change x.
You learned that already, didn't you?
The mental model I suggest is: by editing the file system, you are directly editing the commit. We will soon see how this is somewhat dangerous and how we can protect ourselves with better workflows like the Squash Workflow.
How Many Commits Have You Created and Abandoned, Already?
Does it matter? Those dangling commits are invisible to Git anyway. If
you run git log, Git still throws its hands up, as though nothing
has happened yet:
$ git log
fatal: your current branch 'main' does not have any commits yet
Those commits are real, but since they are not targeted by any branch,
they won't get in your way. When using Jujutsu you can keep calling
that Change x and forget about the low-level minutiae. That's why my
log template doesn't even bother to print the Git SHA1: most of the
time, it's just irrelevant.
For Git to see your commit, it needs a branch (bookmark, in Jujutsu's lingo) targeting it. Add one with:
jj bookmark set main
No need to worry; there's a whole chapter on bookmarks ahead. All in good time!
Where you create a new commit.
Then you start a new branch line. Next, you insert commits right in the middle of existing branches.
From there, you change the message of a commit down in the history tree.
And finally, you revert all you did.
It doesn't sound trivial, does it?
Describe + New + Abandon
$ jj log
@ x
◆ z � empty
First, let's give x a description (a message, in Git's lingo). You
can do this with the command jj description or jj desc for
short. Let's go:
$ jj desc -m "Initial commit"
Working copy (@) now at: xmvkoxku 45467cdf Initial commit
Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set)
$ jj log
@ x Initial commit
◆ z � empty
Yes, it's another Git amend under the hood. Can we start ignoring this fact, already? Deal, I won't repeat it anymore.
New Change
How to create a new commit?
Remember the basic commands:
| Operation | Command |
|---|---|
| Create | new |
| Read | show |
| Update | edit |
| Delete | abandon |
| Move a Change somewhere else | rebase |
| Move the changes you made somewhere else | squash |
| Move files somewhere else | restore |
| Display the history tree | log |
| List / undo / redo operations | op log / undo / redo |
I bet you guessed: you need jj new.
$ jj new -m "A license"
Working copy (@) now at: xpsqkswo 6b9aeffd (empty) A license
Parent commit (@-) : xmvkoxku 45467cdf Initial commit
$ jj log
@ xp empty A license
○ xm Initial commit
◆ z empty
This creates a new, empty Change with the specified message. See
it's marked with @? It means that xp is now the Current Change,
and that you are editing it.
In Jujutsu it's idiomatic (but not mandatory) to commit before coding.
Indeed, you committed A license before actually adding the license:
the Change is still empty. More on this workflow later.
Providing a description is optional. You can always create a
messageless Change and add a message later with jj desc.
Time to add the license file:
$ echo "This software is open source" > LICENSE
You can trust it's already stored in the commit. Ready for your next Change:
$ jj new -m "A dummy React.js application"
$ jj log
@ o empty A dummy React.js application
○ t A license
○ x Initial commit
◆ z � empty
You get the point. Although this is a possible workflow, it's not the most efficient one. I recommend other ones. You'll learn more in a few pages.
Deleting Changes
How to delete a Change? Check the table above, you can't go wrong.
Solution
Solution
Of course! With abandon.
With jj abandon -r <CHANGE-ID> you can delete any Change you like,
no matter where it's located in the history tree.
Let's get rid of the Change introducing the license:
Solution
Solution
$ jj abandon -r t
Abandoned 1 commits:
tputwmoo c2541632 A license
Rebased 1 descendant commits onto parents of abandoned commits
Working copy (@) now at: otsrwyym ecb4d95f (empty) A dummy React.js application
Parent commit (@-) : xmvkoxku 45467cdf Initial commit
Added 0 files, modified 0 files, removed 1 files
$ jj log
@ o empty A dummy React.js application
○ x Initial commit
◆ z � empty
Check on the filesystem: the license is gone.
Hint: as we said, when there's no ambiguity, you can use positional
arguments, so jj abandon t would work as well.
Notice that you manipulated a change while you weren't there. This happens every time with Jujutsu, and it's very powerful. We will talk about it in On Not Being There.
Resurrecting Changes
Did you change your mind? Want to resurrect the license commit?
Solution
Solution
Yes! Undoing things is always jj undo:
$ jj undo
Undid operation: 3a6c93cd543e (2026-06-10 15:48:34) abandon commit c25416325e097a36d0cc1216fb4938e0c44b28a8
Restored to operation: 579bbd1014a4 (2026-06-10 15:41:13) abandon commit 855f900edf5df7275f3b666a7c7c8b64f755c0ed
Working copy (@) now at: otsrwyym 9c73504c (empty) A dummy React.js application
Parent commit (@-) : tputwmoo c2541632 A license
Added 1 files, modified 0 files, removed 0 files
$ jj log
@ o empty A dummy React.js application
○ t A license
○ x Initial commit
◆ z empty
Here we go, back to life!
Adding Changes In Arbitrary Positions
jj new adds a new Change on top of the Current Change. But you
can add Changes in arbitrary positions. Just select the Change you
want to start from, with -r, the same option you used with
abandon:
$ jj new -r x -m "One here"
@ k empty One here
│ ○ o empty A dummy React.js application
│ ○ t A license
├─╯
○ x Initial commit
◆ z empty
Of course, this started a new branch, which you can grow with other
jj new commands.
What if you want to insert a Change right before o (so between
o and t)? Use --before:
jj new --before o -m "One there"
○ o empty A dummy React.js application
@ zv empty One there
○ t A license
│ ○ k empty One here
├─╯
○ x Initial commit
◆ zz empty
You can shorten --before with -B.
Everything Is Dual
Symmetrically, there's also --after / -A. Insert a commit right after
zz:
Solution
Solution
jj new -A zz -m "Initial commit is no more"
○ o empty A dummy React.js application
○ zv empty One there
○ t A license
│ ○ k empty One here
├─╯
○ x Initial commit
@ m empty Initial commit is no more
◆ zz empty
So, in summary:
| Option | Meaning | From | To |
|---|---|---|---|
-r R | Make R its parent. | o-o-R-o-o | o-o-R-o-o \ X |
--after R | Add after R,between R and its children. | o-o-R-o-o | o-o-R-X-o-o |
--before T | Add before T,between T and its parents. | o-o-R-o-o | o-o-X-R-o-o |
Notice: all those moves are perfectly possible with Git. After all, Jujutsu is operating on a Git repository. They just happen to be more cumbersome, since you would need to think about the underlying necessary moves. So, it's likely you have rarely performed them.
The Ubiquitous -r Option
x's message is Initial commit, but now we inserted a Change
before it.
○ o empty A dummy React.js application
○ zv empty One there
○ t A license
│ ○ k empty One here
├─╯
○ x Initial commit
@ m empty Initial commit is no more
◆ zz empty
It's mildly infuriating! How to amend its message?
Here's something I like in Jujutsu: once you have learnt an element of
its grammar, you can use it everywhere it makes sense. -r can be
used with jj new to reference a Change. It can be used with log,
with show and with many other commands.
Including jj desc. So:
Solution
Solution
jj desc -r x -m "Hello, world"
○ o empty A dummy React.js application
○ zv empty One there
○ t A license
│ ○ k empty One here
├─╯
○ x Hello, world
@ m empty Initial commit is no more
◆ zz empty
Fixed. Good job.
What's The Point of Creating Empty Commits?
You are right, these were silly exercises. Who would ever create empty commits like this? Yet, notice:
- Nothing prevents you to
jj editthose commits and add actual content as a second step. - As you'll see, committing before coding is really idiomatic in Jujutsu. There are workflows based on that.
- Finally: editing Changes is not the only way to populate them. You will find yourself moving changes and files around, and you will find that empty commits are a useful tool.
Cleaning up
Here's the chapter closing exercise. Move to o:
jj edit o
Now, get rid of these dummy commits you just created:
k: One herezv: One therem: Initial commit is no more
How would you do that?
Solution
Solution
Of course, combining abandon and -r!
$ jj abandon -r k -r zv -r m
@ o empty A dummy React.js application
○ t A license
○ x Hello, world
◆ z empty
Congrats if you got it!
Indeed, many commands operate on one or multiple Changes, whether
contiguous or sparse.
The following are equivalent:
jj abandon -r k -r zv -r mjj abandon -r 'k | zv | m'jj abandon k zv m
jj abandon k zv m works because Jujutsu allows passing revsets as positional arguments when nothing else could be meant. Commands that
take filesets are a notable exception.
The last one uses the | operator of the Revset
Language, a very powerful
and yet simple language to select sets of Changes. You'll read a bit
more about it in A Glimpse of the Revset Language.
Where To Go From Here?
Admittedly, those exercises were a bit contrived, weren't they? We needed them to learn the basic moves of Jujutsu. Make them your own, and in the next few pages we will see how to apply them to genuinely useful use cases.
In the meantime, be proud! You just made some manipulations that are far from trivial, if just using bare, raw Git.
Intermezzo: A Taste Of a Fanciful Workflow
Indulge me in a little digression.
Why would anyone ever need to create a commit in the middle of an existing branch? Whatever the use case, how frequent can this possibly be?
I'm happy you asked! This is the basis of some interesting workflows you might eventually love. Here's an example. You will experiment with similar ideas in detail in a separate chapter.
Say you have 3 local branches:
@ u Z
○ tn Y
○ tl X
○ y W
│
│ ○ n C
│ ○ q B
│ ○ k A
├─╯
│ ○ m Work C
│ ○ l Work B
│ ○ p Work A
├─╯
○ o
◆ z
You want to know how they work together. They'll eventually be merged
by CI/CD, but why wait? Merge them locally now. After all, you can
always delete the merge commit with jj abandon in a breeze. Without
further ado, here's the Change x, merging the 3 branches together:
@ kx
○ x Megamerge
├─┬─╮
│ │ ○ u Z
│ │ ○ tn Y
│ │ ○ tl X
│ │ ○ y W
│ ○ │ m Work C
│ ○ │ l Work B
│ ○ │ p Work A
│ ├─╯
○ │ n C
○ │ q B
○ │ kt A
├─╯
○ o
◆ z
You might be wondering: how did I create that merge commit
x, if there's nojj mergecommand?
Believe it or not, you actually already have all the ingredients to figure it out. Think about it. You'll read the answer to this quiz in two pages.
Now, say you have to work on the feature A-B-C. Why don't you
work from the megamerge, so you immediately make sure the feature
A-B-C works well with the other 2?
So, do your work in kx. When you are done, you add a new empty
Change on top of A-B-C:
@ kx
○ x Megamerge
├─┬─╮
│ │ ○ u Z
│ │ ○ tn Y
│ │ ○ tl X
│ │ ○ y W
│ ○ │ m Work 2
│ ○ │ l Work 2
│ ○ │ p Work 1
│ ├─╯
○ │ s D <--- new Change
○ │ n C
○ │ q B
○ │ kt A
├─╯
○ o
◆ z
and then you send your work from kz back to that newly created empty
commit s, using jj squash -t s (you'll learn it).
Sounds insane? Yes, it is! I was also shocked the first time I found out it's a popular workflow. Honestly, it didn't take long before it felt more useful than weird. Now it's my daily driver.
You might even find it convenient yourself.
Let's go ahead, you are very close to learning it all.
Clone
Enough with fictional repositories. Clone this one:
https://codeberg.org/arialdo/jj-playground1.git
It contains code from the React.js Tic Tac Toe
tutorial. Clone it only
using jj. Check jj git --help for available options.
Solution
Solution
That was an easy one, right?
$ jj git clone https://codeberg.org/arialdo/jj-playground1.git
$ cd jj-playground1
Running jj log, you'll see Jujutsu shows only 2 Changes and marks
one as a diamond ◆. This means it is immutable.
@ s empty
◆ z Click alternates Xs and Os main
│
~
As a reference, here are the symbols used by jj log to represent
nodes:
| Symbol | Meaning |
|---|---|
○
|
Mutable node |
◆ |
Immutable node |
@
|
The Current Change |
@
|
The Current Change, immutable |
Immutable nodes prevent you from modifying already-published commits. For simplicity, we'll disable this protection, adding a local bookmark (i.e., Jujutsu's branches) for each remote branch:
$ jj bookmark track '*' --remote origin
Good, you are ready to go:
$ jj log
@ p empty
○ zx Click alternates Xs and Os main
○ v Click sets a X
○ s Square has state
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
○ n Square is interactive
○ ru Board() invokes Square()
├─╮
○ │ ym fix name: Square -> Board
○ │ x WIP delete me
○ │ yz LICENSE
○ │ ws A board full of X
├─╯
○ kk Displays X X
○ wx Fails
○ rx Scaffold applicatio
◆ zz 🔒 empty
Merge
A couple pages ago I teased you with a puzzle: you've mastered creating branches of empty commits; but how do you merge them?
Say you want to merge zx with o, and go from:
○ zx Click alternates Xs and Os main
○ v Click sets a X
○ s Square has state
│
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
○ n Square is interactive
│
~
to:
@ vz Here's the merge
├─╮
│ ○ o Play instructions
│ ○ kx Play manual
○ │ zx Click alternates Xs and Os main
○ │ vv Click sets a X
○ │ s Square has state
├─╯
○ n Square is interactive
│
~
Hint
Hint
Remember, if you want to add a Change on top of X you run:
jj new -r X
The -r X part actually means:
Hey, Jujutsu! Create a new Change, having
Xas its parent.
Another hint
Another hint
Most of the commits have just 1 parent.
All merge commits have something special in common.
Solution
Solution
Sure, a merge commit is nothing but a new commit having 2 parents!
Therefore, to give the new Change 2 parents, simply pass both with -r. In this specific case:
jj new -r o -r zx -m "Here's the merge"
If you got it, kudos. That wasn't an easy one!
Unmerge
How to unmerge?
@ vz empty Here's the merge
├─╮
│ ○ o Play instructions
│ ○ kx Play manual
○ │ zx Click alternates Xs and Os main
○ │ vv Click sets a X
○ │ s Square has state
├─╯
○ n Square is interactive
~
That's an easy one! If merging was the last operation, just jj undo:
$ jj undo
@ p empty
○ zx Click alternates Xs and Os main
○ v Click sets a X
○ s Square has state
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
○ n Square is interactive
~
Unmerging with abandon
Otherwise, you could jj abandon the merge Change. Give it a try.
Let's start over:
$ jj redo
Yes! You can redo an undo. Jujutsu's undo works like Emacs': rather
than just going back in time, it appends the revert to an
operation log, so no information is ever lost. If you are curious,
read about it in Emacs' Undo.
You are back to:
@ vz empty Here's the merge
├─╮
│ ○ o Play instructions
│ ○ kx Play manual
○ │ zx Click alternates Xs and Os main
○ │ vv Click sets a X
○ │ s Square has state
├─╯
○ n Square is interactive
~
Now, do yourself a favour: move somewhere else before deleting the merge, or you'll be abandoning your Current Change1:
$ jj edit n
○ vz empty Here's the merge
├─╮
│ ○ o Play instructions
│ ○ kx Play manual
○ │ zx Click alternates Xs and Os main
○ │ vv Click sets a X
○ │ s Square has state
├─╯
@ n Square is interactive
~
Now your turn.
- Unmerge, without using
undo. - Then create a new merge, this time between 3 commits:
zx,oandn.
Solution
Solution
Elementary, my dear Watson!
$ jj abandon vz
○ zx Click alternates Xs and Os main
○ v Click sets a X
○ s Square has state
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
@ n Square is interactive
~
Then:
$ jj new -r zx o n
@ u empty
├─┬─╮
│ ○ │ o Play instructions
│ ○ │ kx Play manual
│ ├─╯
○ │ zx Click alternates Xs and Os main
○ │ v Click sets a X
○ │ s Square has state
├─╯
○ n Square is interactive
○ ru Board() invokes Square()
├─╮
○ │ ym fix name: Square -> Board
○ │ x WIP delete me
○ │ yz LICENSE
○ │ ws A board full of X
├─╯
○ kk Displays X X
○ wx Fails
○ rx Scaffold applicatio
◆ zz 🔒 empty
Was it too easy? See? You already think like a jujutsuka!
In Git, undoing a merge ranges from easy to painful, depending on your experience. I hope you see how Jujutsu makes it trivial regardless.
-
Abandoning the Current Change is perfectly legit, but the first time the behavior might surprise you. If you are curious, read more about this in Pulling the Rug Out Under Oneself ↩
On Not Being There
A quick thought, before moving to more exciting features.
What's the Git command for merging the branch A with the branch B?
Those who mostly use IDE plugins and rarely touch the terminal might
answer, caught off guard:
git merge A B
The correct answer is:
Answer
Answer
It depends.
- If the current branch is
A, then it'sgit merge B. - If the current branch is
B, then it'sgit merge A. - If it's neither, then you need
git checkout A && git merge B.
With git merge the current branch is always assumed.
Why Is It Like That?
Why isn't it git merge A B? Why do commands like cherry-pick,
rebase, pull and am assume the current branch as an implicit
argument?
Because of conflicts. When something goes wrong, Git cannot store the result as a commit. It stops and asks you to resolve the problem first, and the only place where you can do that is the file system. So, you have to be there.
And Jujutsu?
You probably noticed: when merging with Jujutsu you don't have to be
anywhere in particular. You select the parents A and B without
being in either. You also abandon Changes from a distance. You can
even commit from a distance.
This follows from the fact Jujutsu handles conflicts as first-class citizens. You'll learn what a lifesaver this is later on but, for now, just get the gist: you can operate on Changes without being there.
It's History all the way down
- Where you discover there is a meta-repository in which every commit is itself a repository.
- And it's nothing scary: simply, everything you do with
jjis tracked down.
Back to The Start
For the next sections I will ask you to obtain a fresh copy of the same repo you cloned before. There are at least two ways to do this.
- You could delete the project directory and clone the repository again with:
$ jj git clone https://codeberg.org/arialdo/jj-playground1.git
$ cd jj-playground1
$ jj bookmark track '*' --remote origin
- Otherwise, you could keep running
jj undountil you get the status right after you executed that:
jj git remote remove origin
But first, you might wonder where undo and redo fetch their
information from. The next few pages will shed some light on the
topic.
History of a Change
Every Change has its History.
Do this little exercise. Target any Change you like, such as n:
$ jj log
@ p empty
○ zx Click alternates Xs and Os main
○ v Click sets a X
○ s Square has state
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
○ n Square is interactive
○ ru Board() invokes Square()
├─╮
○ │ ym fix name: Square -> Board
○ │ x WIP delete me
○ │ yz LICENSE
○ │ ws A board full of X
├─╯
○ kk Displays X X
○ wx Fails
○ rx Scaffold applicatio
◆ zz 🔒 empty
Make some modifications to it, like:
- Update its description
jj desc -r n -m foojj desc -r n -m barjj desc -r n -m baz
- Check it out and edit some files:
jj edit -r n
- Add a Change before it:
jj new -B n
Under the hood, each of those operations creates a new Git
commit. Each time, Jujutsu makes sure the immutable Change ID n
points to whichever commit is the most recent one. Throughout its
existence, n goes through different states, each a different Git
commit. n was born as commit 27ea11. Then it evolved to a
different commit, then another, and so on. In a sense, n has its own
history tree.
This isn't merely a metaphor. You can actually display that history
with evolog:
jj evolog -r n
○ nvokxlrr arialdo@ik.me 2026-06-12 22:52:51 c655c8d7
│ baz
│ -- operation 2e1a28a42c55 abandon commit 668d65f4f0eea395a4c3d0a7ed890254a60865d4
○ nvokxlrr/1 arialdo@ik.me 2026-06-12 22:52:45 6e2c1c9a (hidden)
│ baz
│ -- operation 500080ebf19a new empty commit
○ nvokxlrr/2 arialdo@ik.me 2026-06-12 22:52:39 983a1599 (hidden)
│ baz
│ -- operation 3075743719f1 describe commit 22c5df185272af6f25b1d4227cd271971aa1baa5
○ nvokxlrr/3 arialdo@ik.me 2026-06-12 22:52:37 22c5df18 (hidden)
│ bar
│ -- operation 3daa0e64a476 describe commit 21a546e29d1cfcebdc0aafd8023c3d60416bff88
○ nvokxlrr/4 arialdo@ik.me 2026-06-12 22:52:34 21a546e2 (hidden)
│ foo
│ -- operation 4c7fc048be20 describe commit a857703785c7971ec20ccd826e36557f8b7d5a72
○ nvokxlrr/5 arialdo@ik.me 2026-06-12 22:52:28 a8577037 (hidden)
│ Foo
│ -- operation a6df8a6d9206 describe commit 03e16e725052f4ba32bb0a1a31a029860a86519a
○ nvokxlrr/6 arialdo@ik.me 2026-06-10 14:33:07 03e16e72 (hidden)
Square is interactive
Consider this the meta-repository that tracks the history of n. Its
commits are called n/1, n/2 etc. There's nothing special about
these commits: you use them in commands just like any other. Meaning:
you don't need to learn new special commands, like with Git reflog.
Want to inspect the changes stored in n/5?
In many cases, you'll use this meta-history to recover files you want to restore.
The History Beneath the History
If every change has its own history, then your whole repository has its own history. It's only logical. Your repo:
- In an initial stage (let's call it
000000000000) did not exist at all. - Then it was created.
- Then you added a Change.
- Then you edited a description.
and so on.
Jujutsu is stubborn, it remembers everything, since almost every command, including jj log, triggers a working-copy snapshot.
Inspect that meta-history with:
jj op log
○ 3075743719f1 arialdo@mbuto default@ 19 minutes ago, lasted 22 milliseconds
│ describe commit 22c5df185272af6f25b1d4227cd271971aa1baa5
│ args: jj describe -r n -m baz
○ 3daa0e64a476 arialdo@mbuto default@ 19 minutes ago, lasted 24 milliseconds
│ describe commit 21a546e29d1cfcebdc0aafd8023c3d60416bff88
│ args: jj describe -r n -m bar
○ 4c7fc048be20 arialdo@mbuto default@ 19 minutes ago, lasted 23 milliseconds
│ describe commit a857703785c7971ec20ccd826e36557f8b7d5a72
│ args: jj describe -r n -m foo
○ a6df8a6d9206 arialdo@mbuto default@ 19 minutes ago, lasted 21 milliseconds
│ describe commit 03e16e725052f4ba32bb0a1a31a029860a86519a
│ args: jj describe -r n -m Foo
○ 192c30f0cf47 arialdo@mbuto default@ 3 hours ago, lasted 5 milliseconds
│ restore to operation 281b30d872a33f3dc6f93982343ab3fa4d2a4adb8b3bb4903421d84003d96e96e266d90bc4e6990526d8c5eea62423b19395d5f6d2639b6be26b77d301958274
│ args: jj op restore '000000000000+++++'
○ 2a2be2f2b8fc arialdo@mbuto default@ 3 hours ago, lasted 5 milliseconds
│ restore to operation 8eea9aa811a1ed9ffd6e795d17573e77caad0ddf41c716489fbe8e7526aad797c10b2cbc4c45de0845b7e1f84c6a458f148e6eab9c1241fa72d7f62c1a2f89d3
│ args: jj op restore '000000000000++++'
○ 281b30d872a3 arialdo@mbuto default@ 3 hours ago, lasted 4 milliseconds
│ remove git remote origin
│ args: jj git remote remove origin
○ 8eea9aa811a1 arialdo@mbuto default@ 3 hours ago, lasted 14 milliseconds
│ check out git remote's branch: main
│ args: jj git clone https://codeberg.org/arialdo/jj-playground1.git
○ eff465d67569 arialdo@mbuto default@ 3 hours ago, lasted 2 seconds
│ fetch from git remote into empty repo
│ args: jj git clone https://codeberg.org/arialdo/jj-playground1.git
○ ee32fa88d5f2 arialdo@mbuto default@ 3 hours ago, lasted 3 milliseconds
│ add git remote origin
│ args: jj git clone https://codeberg.org/arialdo/jj-playground1.git
○ 23c04169eec1 arialdo@mbuto 3 hours ago, lasted 6 milliseconds
│ add workspace 'default'
○ 000000000000 root()
As expected, everything originates from 000000000000, the origin of
times. From there you can travel through all the commands you
ran. Each command created a snapshot of the whole repository.
It's a repository of your repository's history.
Rewind!
Restoring the repository exactly as it was before an operation is
trivial. Each operation has its ID which you can reference with the
command jj op restore.
You want to go back to when you performed:
jj git clone https://codeberg.org/arialdo/jj-playground1.git
cd jj-playground1
jj bookmark track '*' --remote origin
Solution
Solution
- You run
jj op log. - You identify the item for the command
git remote remove origin. It should be the 5th operation, from000000000000. - Find its ID. Then run:
jj op restore <ID>
Spot on? Congrats, this was challenging!
You could also use:
$ jj op restore 000000000000+++++
to indicate the 5th op (+++++) after 000000000000.
Check the file system and the history log: everything is back to the moment you cloned the repo.
Like Emacs, Jujutsu Never Forgets
If you inspect the output of jj op log you will find that your last
jj op restore has been appended to the top of all the other
operation logs. So, you can still undo it by restoring its parent.
Basically:
- Every operation, including
op restoreandundo, appends a full snapshot to the op log. op restorereproduces an old snapshot and appends the result to the history.undorestores the previous op.
We've all dreaded performing risky operations in Git. Armed with these tools, you should never feel that way again.
Ready?
After:
$ jj op restore 000000000000+++++
your repo should be:
○ zx Click alternates Xs and Os main
○ v Click sets a X
○ s Square has state
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
○ n Square is interactive
○ ru Board() invokes Square()
├─╮
○ │ ym fix name: Square -> Board
○ │ x WIP delete me
○ │ yz LICENSE
○ │ ws A board full of X
├─╯
○ kk Displays X X
○ wx Fails
○ rx Scaffold applicatio
◆ zz 🔒 empty
Good. You are ready to play with rebase and squash.
Moving Things Around
- Flip 2 commits in a branch.
- Send 2 commits in 2 separate branches.
- Move a file from a commit to a future one.
- Fix a typo, send it 10 commits ago.
- Rebase 2 branches simultaneously.
- Reflect on the new unprecedented history-bending abilities you just gained. Invent new audacious (but useful) moves.
- Profit.
You can do all sort of black magic sorcery with just 2 commands:
| Operation | Command |
|---|---|
| Move a Change somewhere else | rebase |
| Move the changes you made somewhere else | squash |
Best of all, they only take a few minutes to learn.
Where you learn that rebasing is not necessarily moving a branch to
a different base.
Indeed, you will move 1 single Change inserting it in the middle of the history tree.
Moving Changes
rebase works like this:
- you specify which Changes you want to move
- and where you want to move them.
Simply:
rebase -r X -o Y
moves the Change X on top of Y. You
can also specify the destination using the same --before / -B and
--after / -A options you used with jj new.
You'll find the sample repository has many mistakes and unfinished work. I will invite you to fix all the problems in the next exercises.
First, the License
Oh, no! I've added the license to the repository a bit too late, in
the Change yz:
@ x empty
○ zx Click alternates Xs and Os main
○ v Click sets a X
○ s Square has state
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
○ n Square is interactive
○ ru Board() invokes Square()
├─╮
○ │ ym fix name: Square -> Board
○ │ x WIP delete me
○ │ yz LICENSE
○ │ ws A board full of X
├─╯
○ kk Displays X X
○ wx Fails
○ rx Scaffold applicatio
◆ zz 🔒 empty
$ jj show --summary yz
Commit ID: 997391344e0b704d8ad78a51e8b9d8c367dc6998
Change ID: yzxvwmnznuxqpsqttxntvzuqzyrtwsww
Author : Arialdo <arialdo@ik.me> (2026-06-10 14:08:47)
Committer: Arialdo <arialdo@ik.me> (2026-06-10 14:09:15)
LICENSE
A LICENSE
Ideally, I would add the license as the very first commit.
Somehow yz ended up in the middle of a merged branch.
Can you move yz so it becomes the initial commit?
Things Committed by Mistake
The Change x has a suspicious message: WIP: delete me. What does
it contain?
Solution
Solution
$ jj show x --summary
Commit ID: 22014d7c4604eb00430fe4e7a583e9aaf5afb77e
Change ID: xxuknwmlponouxznpzmnqxqvrooxttru
Author : Arialdo Martini <arialdo.martini@gmail.com> (2026-06-13 15:46:46)
Committer: Arialdo Martini <arialdo.martini@gmail.com> (2026-06-13 16:09:31)
WIP delete me
A node_modules/yaml/LICENSE
A node_modules/yaml/index.js
Ouch! I really made a mess there, committing node_modules.
How to get rid of it?
Why this exercise? It seems unrelated to moving stuff. But Jujutsu
did rebase all descendants of x onto yz under the hood.
Moving things doesn't always look like moving things: Jujutsu commands
capture your intent, not the underlying operations.
Typos
Now that I notice, there's a typo in rx's message: applicatio
instead of application.
How would you fix it?
Solution
Solution
Sure, it's:
$ jj desc -r rx -m "Scaffold application"
Rebased 13 descendant commits
Working copy (@) now at: xxuknwml 770ae789 WIP delete me
Parent commit (@-) : yzxvwmnz 02b946ad LICENSE
Again: the intent is editing a message. The log message Rebased 13 descendant commits reveals that, under the hood, it's still about
moving commits.
Let's see other examples where your intent is really moving Changes.
A Glimpse of the Revset Language
With Git you are probably used to rebasing whole branches. Jujutsu's
rebase makes no assumptions about what you want to move.
You already saw how to rebase a single Change; you can just as easily move multiple Changes, even sparse ones across different branches: just specify them. For example:
jj rebase -r X -r Y -o O
would move both X and Y on top of O.
Instead of listing Changes one by one, you can use expressions in the
Revset Language. Here's a
foretaste:
| Notation | Meaning |
|---|---|
X | Y | All the Changes in set X plus all the Changes in the set Y |
X & Y | The Changes that are both in the set X and the set Y |
~X | Changes that are not in X. |
X:: | X and all its descendants. |
::X | X and all its predecessors. |
X::Y | All the Changes between X and Y. |
Each expression returns a set, so you can keep combining them into more complex ones.
Moving Sets of Changes
Say that for whatever reason, you want to move the license (Change
yz) and the manual (Changes kx and o) on top of main.
@ t empty
○ zx Click alternates Xs and Os main <-- move them here
○ v Click sets a X
○ s Square has state
│ ○ o Play instructions <-- this
│ ○ kx Play manual <-- this
├─╯
○ n Square is interactive
○ ru Board() invokes Square()
├─╮
○ │ ym fix name: Square -> Board
○ │ x WIP delete me
○ │ ws A board full of X
├─╯
○ kk Displays X X
○ wx Fails
○ rx Scaffold application
○ yz LICENSE <--- this
◆ zz 🔒 empty
Solution
Solution
It's either:
jj rebase -r yz -r kx -r o -o zx
or:
jj rebase -r "yz | kx | o" -o zx
If you did it right, cool! Pause a second and think how you would get to the same result in Git. It's technically possible, sure, but would you call it straightforward?
Before moving to the next exercise, just:
jj undo
this last move, please.
Rebasing a Branch
If you run jj undo from the previous page, your history tree should
be:
○ zx Click alternates Xs and Os main <--- this branch
○ v Click sets an X
○ s Square has state
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
○ n Square is interactive
○ ru Board() invokes Square()
~
How would you rebase the branch from s to zx on top of o?
You should get to:
○ zx Click alternates Xs and Os main
○ v Click sets an X
○ s Square has state
○ o Play instructions
○ kx Play manual
○ n Square is interactive
○ ru Board() invokes Square()
~
How would you undo that, using rebase and not undo?
Solution
Solution
The original base of s::zx was n, so the move is:
$ jj rebase -r "s::zx" -o n
○ zx Click alternates Xs and Os main
○ v Click sets an X
○ s Square has state
│ ○ o Play instructions
│ ○ kx Play manual
├─╯
○ n Square is interactive
○ ru Board() invokes Square()
~
Give yourself a pat on the back if you got it right.
Rebasing 2 Branches Simultaneously
Where you discover you are a Jujutsu ninja and you are not surprised at all you can rebase 2 branches in 1 shot.
On top of n we have 3 branches (I'll name them by their tip Change ID):
rt, which introduces CSS.zx, where new functionalities are.o, which contains the manual.
○ rt Minimal CSS css <- this
│ ○ zx Click alternates Xs and Os main <- this
│ ○ v Click sets an X
│ ○ s Square has state
├─╯
│ ○ o Play instructions manual <- this
│ ○ kx Play manual
├─╯
○ n Square is interactive
~
We decide to include CSS in the other 2 branches. So, your goal is to
rebase both zx and o on top of rt.
You could do this with:
jj rebase -r kx:: -o rt
followed by
jj rebase -r s:: -o rt
Would you be able to do this in 1 command?
Solution
Solution
Union set to the rescue!
jj rebase -r 'kx:: | s::' -o rt
○ zx Click alternates Xs and Os main*
○ v Click sets an X
○ s Square has state
│ ○ o Play instructions manual*
│ ○ kx Play manual
├─╯
○ rt Minimal CSS css
│ ~
├─╯
○ n Square is interactive
Rebasing multiple branches is especially handy for keeping them up to
date with trunk. Check out
rebase-all,
an alias by Steve Klabnik, you'll love it:
you just run jj rebase-all so your local work is always based on the latest main.
People invented all sorts of amazing aliases: find them at
https://github.com/jj-vcs/jj/discussions/8484.
Preview of Revsets
You should never fear a rebase: after all, you can always resort to
jj undo. Also, you can always obtain a preview of the Changes
involved in a Revset expression using log:
jj log -r 'kx:: | s::'
○ zx Click alternates Xs and Os main*
○ v Click sets an X
○ s Square has state
│
~
○ o Play instructions manual*
○ kx Play manual
│
~
Universality of Revsets
Oh, and by the way: once you have defined a Revset expression, you can use it with many commands:
- Want to delete all those Changes?
jj abandon -r 'kx:: | s::' - Want to inspect all of them?
jj show -r 'kx:: | s::' - Want to change their author?
jj metaedit -r 'kx:: | s::' --update-author "Joe Doe"
That's a general trait: Jujutsu has few building blocks, but they combine in countless ways.
Moving Edits Around
Changes and Edits
A Change contains your modifications to files such as adding a class,
renaming a function, and the like. You can see them as the diff
shown by jj show (use --git if you want to use the Git format):
$ jj show rt --git
Commit ID: ad4aa4890ec32cf83c51a407a099afaf9af106b9
Change ID: rtsytxmrmouvklxkuuvqxolokmmkzssz
Author : Arialdo <arialdo@ik.me> (2026-06-13 19:05:31)
Committer: Arialdo <arialdo@ik.me> (2026-06-15 08:10:28)
Link to CSS
diff --git a/public/index.html b/public/index.html
index efd73153a2..fd6a1cbbfe 100644
--- a/public/index.html
+++ b/public/index.html
@@ -4,6 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
+ <link rel="stylesheet" href="index.css">
</head>
<body>
<div id="root"></div>
The Jujutsu manual calls them "changes", but that term is already overloaded. Let me call them "edits" from now on, OK?
Finding the Source of a Typo
To narrow down jj log to some files, you can use the Revset Language
function files(). Here's the change history of README.md:
$ jj log -r "files('README.md')"
○ kk Displays X X
~ (elided revisions)
○ rx Scaffold application
│
~
As you see, README.md was created in rx and modified in
kk. Inspect what kk is about:
$ jj show kk --git
diff --git a/README.md b/README.md
index d8ce899bb8..ec822c4346 100644
--- a/README.md
+++ b/README.md
@@ -1,3 +1,3 @@
-# Learning Raect.js
+# Learning React.js
diff --git a/src/App.js b/src/App.js
index 3f8dbe3702..495f426244 100644
--- a/src/App.js
...
Uh oh! Together with a change to the JavaScript code in App.js,
there's also a little fix to the word React.js in README.md. If
you check the content of rx, you will see it's the one that
introduced the typo:
$ jj file show -r rx README.md
# Learning Raect.js
Following the tutorial at https://react.dev/learn/tutorial-tic-tac-toe
So, it must have gone like this:
- I wrote the typo
Raectinstead ofReactinrx. - I missed that so I kept building on top of that.
- Then, while I was coding
App.jsI stumbled upon the typo. - Instead of fixing it by amending
rx, I created a Change,kk, mixing the typo fix with other unrelated changes.
Silly me...
How to move the fix Raect.js -> React.js from kk to rx?
This is really about moving edits.
Sending Fixes Back to the Past
Rebase won't help here: you don't want to move the Change kk, but
the edits it contains.
Enter squash. If you are familiar with git merge --squash and the
git rebase --interactive's squash command, you know that squashing
in Git is about combining multiple commits into a single one.
jj squash generalizes this concept. Think of it as a way to transfer
edits from one Change to another. Though these concepts may seem
unrelated, Jujutsu's squash is general enough to cover both. The
basic syntax is:
jj squash --from A --into B [FILES]
or more concisely:
jj squash -f A -t B [FILES]
The net effect: the edits to [FILES] move from A to B. Your
edits stay the same; only which Change owns them differs.
So, fixing my mess with Raect.js is a matter of running:
Check the content of kk:
$ jj show --summary kk
Commit ID: 3c72f2573cc5235622030aac110e884014e8ec6b
Change ID: kkqvwlzrztzvlmtyqksulvyuwsorpppx
Author : Arialdo <arialdo@ik.me> (2026-06-10 14:02:12)
Committer: Arialdo <arialdo@ik.me> (2026-06-15 14:50:39)
Displays X X
M src\App.js
README.md is not listed anymore. Indeed, in rx the typo is gone:
$ jj file show -r rx README.md
# Learning React.js
Following the tutorial at https://react.dev/learn/tutorial-tic-tac-toe
because the typo fix has been absorbed by the edits already there.
Cool! Next time you are focused on some development task and you find a typo in an unrelated file, you can fix it on the spot and then send it back to the past, where it belongs.
Dispatching Edits from Megamerges
Why did I say "absorb" in the previous chapter? Because you could have
done that squash using jj absorb. Run a jj undo and give it a
try. Go to kk:
jj edit kk
and run:
jj absorb README.md
jj absorb will accomplish what you just did manually: it will
identify the source of the typo and will move the edit there.
If you don't specify the file, jj absorb will analyze all the edits
you did. For each individual edit, it will figure out the closest
ancestor it can safely land in. Got multiple edits across multiple
files? No problem: jj absorb will split them apart and dispatch each
one to its rightful Change back in your history.
If you are anything like me, you're probably grumbling: "no way this works without breaking something". I swear I thought the same, and I swear that Jujutsu proved me wrong.
Is it safe?
First of all, you always have jj undo.
Besides this, yes, it is: jj absorb is very conservative: when the
destination Change can't be unambiguously determined, jj absorb does
nothing rather than risk a mess.
When Do You Use It?
Remember the Megamerge Workflow I told you about in Intermezzo: A
Taste Of a Fanciful Workflow?
That's the typical case where jj absorb shines.
The idea is: you have multiple Pull Requests waiting to be merged:
○
○
○
│
│ ○
│ ○
│ ○
├─╯
│ ○
│ ○
│ ○
├─╯
○
◆
and over time you receive feedbacks on them. Rather than jumping left and right to fine-tune your code, you create an octopus merge of all your PRs, the megamerge, and you sit on top of it, in a new empty Change:
@ <- Your staging area
○ <- The megamerge
├─┬─╮
│ │ ○
│ │ ○
│ │ ○
│ │ ○
│ ○ │
│ ○ │
│ ○ │
│ ├─╯
○ │
○ │
○ │
├─╯
○
◆
This lets you work on all your branches from a single staging area.
How does it work? As feedback comes in on one of your Pull Requests,
you make the necessary change directly on top of the megamerge, which
contains the sum of all your work; then you use jj absorb to
dispatch the change where it belongs.
You won't have to worry about merge conflicts, because:
jj absorbwould stop rather than creating one.- The megamerge already proves all your PRs work together, so conflicts are caught early by design.
You can read more about this workflow in the following two articles. There's a video in the first one if you want to see how this works in practice.
Hammers and Nails
Allow me a brief reflection.
The language we use defines the boundaries of what we can think.
The same applies to the tools and the programming languages we use. By making some operations easy and others hard, they make some concepts thinkable and others invisible.
A workflow requiring 40 fragile steps in Git isn't just avoided: it ceases to exist as a mental category. My thesis is that implementation difficulty leads to conceptual absence. Or, as my grandma would say:
What's hard to do becomes hard to think.
Jujutsu illustrates this point beautifully. Megamerge has always been
technically achievable in Git, hasn't it? Yet nobody invented it. Why?
Because it required a chain of operations so convoluted that
it was just too hard to even ideate them.
By elevating its interface from implementation details to intent,
Jujutsu turned some convoluted operations into trivial ones, and this
allowed people to envision new workflows.
I'm standing on the shoulders of giants with this thought. Ken Iverson, the APL's creator, titled his 1979 Turing Lecture Notation as a Tool of Thought arguing exactly that: notation doesn't carry preexisting thoughts but shapes them.
The first corollary is that the limits of our tools (and our thought!) are invisible from the inside. Remember that I cited Graham's Blub Paradox? It states that we can see what's missing from poorer languages, but not what's missing from ours, compared to more powerful ones.
My personal corollary is maybe counterintuitive (and not necessarily compatible with your career ambitions): always deliberately expose yourself to tools and languages you'll never seriously use. Maybe you will never switch to them, but the space of what will be conceivable by your fantasy will never be the same.
We've always thought in Git. Jujutsu expands the boundaries of our thought. I hope to live long enough to see what will come next.
Moving Chunks Around
jj absorb can dispatch multiple edits within a single file to
different Changes. So far, you've only learned to use jj squash -f FROM -t TO FILES, which moves all the edits in the given files at
once. But moving every edit in a file is not necessarily what you
want. Usually you need to be more selective.
See this case, for example:
$ jj show ym --git
fix name: Square -> Board
diff --git a/src/App.js b/src/App.js
--- a/src/App.js
+++ b/src/App.js
-// Main apication
+// Main application
...
-export default function Square() {
+export default function Board() {
The main goal of the Change was to rename a function from Square()
to Board(). Mixed with this, though, I also fixed another typo
(again!): apication -> application.
The line // Main apication was introduced by Change rx. Doing:
$ jj squash -f ym -t rx src/App.js
is too coarse, it would drag the Square() -> Board() rename into
rx as well, which is not what you want. jj absorb would work, but
there are alternatives which give you more control.
Interactively Select Chunks
Use the --interactive / -i option of jj squash:
jj squash -f ym -t rx -i
An interactive editor will open, with which you can select which chunk
to include in the squash:
[File] [Edit] [Select] [View]
( ● ) src\App.js
[●] Section 1/2
[●] - // Main apication ⏎
[●] + // Main application ⏎
2 // Display a 3x3 board and let the user ⏎
3 // play Tic Tac Toe ⏎
[ ] Section 2/2
[ ] - ⏎
[ ] - export default function Square() {⏎
[ ] + ⏎
[ ] + export default function Board() {⏎
6 return ( ⏎
7 <> ⏎
8 <div className="board-row"> ⏎
⋮
You can use the following keys:
| Key | Purpose |
|---|---|
Up / Down | Move up and down |
Space | Select a line |
f | Fold / unfold a file |
c | Confirm |
q | Quit without confirming |
You can also use -i to select whole files: that's often a convenient
alternative to listing file names in jj squash -f FROM -t TO FILES.
Building On Top Of Squash
By now, you should have got a solid grip on squash. In combination
with rebase, it's one of the sharpest tools in the box.
Take a breath and look at the range of operations now at your fingertips. Odd are, you are already doing things you've never attempted with Git. Be proud of yourself.
It's time for you to see the Squash Workflow, a popular, idiomatic Jujutsu workflow. After this, you won't miss Git's index anymore.
You see how moving changes from a source Change to a target Change might imply abandoning the source.
Squashing Changes
The way jj squash parameter defaults interact is quite clever.The
basic syntax is:
jj squash -f A -t B FILES
- If you omit the list of files, then all the changes in the Change are moved.
- If you omit either
-for-t, then the Current Change@will be implied.
So:
jj squash -f z
moves the edits from z to your current working copy.
If you do:
jj squash -t x
your edits move to x, no matter where it sits in your history,
either in the past or in the future, in the same branch or
elsewhere.
This technique is truly powerful: it lets you edit something here
and send it somewhere else when you're done.
For example, if you notice an unrelated typo while working on a
feature, fix it on the spot and squash it where it belongs.
Or, say you spot something that needs to be done in a different
branch. You can write the change immediately and then move it there
with a jj squash. In a sense, this is the basic move that lets you
work on multiple features simultaneously.
These moves are so idiomatic that Jujutsu offers a "macro command":
absorb. I'll cover it in the following pages with practical
exercises.
Abandoning Changes
When the source Change is left empty after a move, Jujutsu automatically abandons it. For example, say you have:
$ jj log -r "n::zx"
○ zx
○ v
○ s
○ n
If you squash v into n:
then you end up with:
○ zx
○ s
○ n
What Happens To Messages?
As with Git, Jujutsu always preserves information. If squashing
results in deleting a Change, and that Change has a description,
Jujutsu will stop and interactively let you define one. You will
typically merge all the
original messages into one.
Otherwise, you can always set the message with -m, or use
--use-destination-message / -u.
Quiz Time!
Check out this sloppy mistake:
$ jj show yo- --git
--- a/src/App.js
+++ b/src/App.js
...
+ <button
+ className="square"
+ onClick={handleClic}
...
Oops, Clic instead of Click. Have I fixed it in yo-? Not at all!
I committed a fix on top of it, with the very expressive message "Fix":
$ jj show yo --git
--- a/src/App.js
+++ b/src/App.js
@@ -11,7 +11,7 @@
return (
<button
className="square"
- onClick={handleClic}
+ onClick={handleClick}
>
Terrible. Help me, please: get rid of that noisy yo Change and
make yo correct:
Solution
Solution
One way to do that is:
jj edit yo
jj squash -u
Remember? -u is the short for --use-destination-message.
Or, without moving away from your Current Change:
Just jj squash
If you run the bare jj squash, without any argument, then:
- it will move all the changes (indeed, no files are specified)
- from the Current Change to its parent.
- Should your Current Change have no message, it will be abandoned and a fresh new one will be immediately created.
Say you are on the tip of a branch:
@ sw empty
○ zx Click alternates Xs and Os main
○ v Click sets a X
○ sn Square has state
You code, editing @. Then you run jj squash.
Can you see where this goes? It's the equivalent of having the Git
index!
Let me show you how this works.
You learn a new workflow which might look both:
- Exotic: you would commit before coding!
- And familiar: you won't miss the Git index anymore.
The Squash Workflow
Apparently, this is the preferred workflow by Martin von Zweigbergk, the guy who invented Jujutsu. So it is probably deeply rooted in Jujutsu's philosophy.
It revolves around these 2 ideas:
- You start by committing.
- Then, you code in a disposable Change on top of your commit.
When you are done with your work, you squash down the changes. Let me just show you; it's faster than explaining.
$ jj log
○ q Add the CSS file css
○ rt Link to CSS
│ ○ o Play instructions manual
│ ○ kx Play manual
├─╯
│ ○ zx Click alternates Xs and Os main
│ ○ v Click sets a X
│ ○ s Square has state
├─╯
○ n Square is interactive
~
The Changes rt and q are about the CSS. Let's say that you want
to improve the web page layout, adding a mouse hover effect.
First: Commit!
You start by declaring your intent, committing to a goal:
$ jj new -r q -m "Hover effect to board squares"
○ rt empty Hover effect to board squares
○ q Add the CSS file css
○ rt Link to CSS
~
You haven't started coding yet, so of course the Change is empty. Eventually, it will be the next commit in your history. Think of the Git index, it's pretty much the same idea: a Candidate Commit.
Second: Set Up Your Workspace
Then build a disposable Change on top of your Candidate Commit:
$ jj new
○ xm empty
○ rt empty Hover effect to board squares
○ q Add the CSS file css
○ rt Link to CSS
~
That empty, messageless Change may seem odd, but squint a little, it's just like Git. With Git you start with:
| In Git | In Jujutsu |
|---|---|
| An empty Index, the Candidate Commit. | This is your rt Change. |
| A working directory with no pending changes. | This is equivalent to your empty, Current Change xm. |
Third: You Code
Nothing new to see here: Jujutsu reflects every file system change in
your tip Change xm.
Finally: You Squash
When you're done, review and move your changes back to the Candidate Commit.
jj squash
or:
jj squash -i
if you want to do this chunk by chunk.
When jj squash completes, the now-empty tip Change is automatically
abandoned and replaced with a fresh one.
Try It Out
Give it a try. Say you want to add:
.square:hover {
background: #e0f7fa;
cursor: pointer;
transition: background 0.2s;
}
to style.css. Your turn!
Solution
Solution
Most of the work is already done! The Candidate Commit is already
there. You just need to do the funniest part (coding! 😎).
Then:
jj squash -i
or just jj squash.
Moving Edits To The Future
You saw how to send an edit back to a past Change. In fact, there is
nothing in the --from and --into options of jj squash that
constrains their position in the history tree. You can send edits to
the past, to the future, even to unrelated branches.
But why would you send edits to the future?
A common use case is the following. Inspect the Change rv: it
contains 2 unrelated edits, one to CSS and one to the logic:
○ kt empty Hover effect to board squares
○ rv Changes to both CSS and app.js
○ q Add the CSS file css
~
How would you split that Change in two? A possible approach is:
- To create a Change after
rv. - To move part of the edits of
rvin that future Change.
Come on, try it yourself. Split the CSS edits from the app.js edits
into 2 separate Changes:
Solution
Solution
$ jj desc -r rv -m "Changes to CSS only"
$ jj new -A rv -m "Changes to app.js only"
$ jj squash -f @- -i
Split
This move is so common that Jujutsu offers a macro command: jj split. It is equivalent to creating a new Change, either
after (-A) or before (-B), and then running a jj squash -i.
jj undo the previous move and do it again with jj split.
Copying Files
Good things always come in threes. You have met jj squash and jj rebase; meet jj restore. All for one and one for all: together
those three musketeers are all you need to bend the history tree to
your will:
| Operation | Command |
|---|---|
| Move a Change somewhere else | rebase |
| Move the changes you made somewhere else | squash |
| Copy files from one Change to another | restore |
jj restore is a command for copying file contents, and it shares
the same syntax as jj squash (have I already told you that Jujutsu is consistent?)
jj restore -f FROM -t TO FILES
This would copy the content of FILES from FROM to TO.
Think of it as copy-paste: you copy the content of FILES from FROM
and paste them into TO. The original in FROM stays unmodified.
This includes deletions: if a file was deleted in FROM, it will be
deleted in TO too.
As for squash, if you omit either -f or -t, the Current Change
@ will be implied.
If you omit FILES, the whole working copy will be restored.
When Is It Useful?
Days ago your README.md had rich examples and beautiful ASCII
diagrams. Then your PM asked for something "more executive". Fine, you
trimmed it down. PMs are always right. Especially on Friday afternoon.
Today the same PM says (because of course they do): "You know what? Actually, I liked the long version better".
You sigh and call jj restore to the rescue:
- You
jj logand find the ChangeXwith messagedocs: README with beautiful diagrams jj restore --from X README.md
Done.
You see? You are not "going back in time": you are pulling a file out
of an old snapshot and pasting it into the Current Change.
Is This Use Case Really That Common?
Not particularly. But like with jj squash, there are interesting
consequences to the jj restore's default arguments.
Restarting
The jj restore --help page states:
When neither
--fromnor--intois specified, the command restores into the working copy from its parent(s).
That's a neat default! It means that:
jj restore
is equivalent to:
jj restore -f @- -t @ .
which makes the Current Change identical to its parent. It's a convenient way to clean up everything and start over.
Give it a try:
○ rt Link to CSS
○ n Square is interactive
○ ru Board() invokes Square()
├─╮
○ │ ym Rename: Square -> Board
○ │ xx WIP delete me
○ │ yz LICENSE
○ │ ws A board full of X
├─╯
○ kk Displays X X
○ wx Fails
○ rx Scaffold application
Select any Change you like (say: ru) and create a new Change on
top of it:
jj new ru
Make any modification you like:
$ touch unnecessary.txt
$ echo "a bug" >> src/App.js
$ rm src/index.js
$ jj st
Working copy changes:
M public/index.html
M src/App.js
D src/index.js
A unnecessary.txt
2 modified files, 1 deleted and 1 added.
How to start over and get back to an empty Change?
Solution
Solution
But it's trivial!
$ jj restore
Added 1 files, modified 2 files, removed 1 files
$ jj st
The working copy has no changes.
Back to the start.
Isn't This equivalent to git restore?
Not really. In Git, the same information is spread across three
layers, HEAD, the Index, and the working tree, plus the fourth grey
area of untracked files. As a result, you need different commands and
options depending on which subset you want to affect.
| Goal | Git |
|---|---|
| Discard unstaged changes | git restore . |
| Discard both staged and unstaged changes | git restore --staged --worktree . or git reset --hard HEAD |
Clean restart, equivalent to jj restore | git reset --hard HEAD && git clean -fd |
Nuclear cleanup (including .gitignored files) | git reset --hard HEAD && git clean -fdx |
You conclude that the Git Index was a Good Thing®.
So, you learn a micro-workflow that builds on top of the same idea and will save you a lot of headaches.
Don't Edit
In the chapter Committing you read that:
By editing the file system, you are directly editing the commit.
We will soon see how this is somewhat dangerous.
In hindsight, I was a bit dramatic. After all, Jujutsu, like Git,
never deletes information, so there's nothing really dangerous.
But why let accuracy spoil good drama? So, let me reformulate the idea
as follows:
Directly performing open-heart surgery on a Change with
jj editis dramatically more perilous than working from a distance, on a new Change on top of it.
Terrified? Good. (Really, you have no reason to be, but good).
What's Wrong With Editing?
jj edit is ruthless: you edit the filesystem and each and every
change is unconditionally committed to the Current Change. Even
worse: if you are editing a Change deep in the history tree, this
will also recursively propagate the changes to all the descendants,
with an automated rebase.
The problem is that not every change is intentional:
- Building the project can create artifact files. Jujutsu might track
them before you get the chance to add them to
.gitignore. - We all have fat fingers, and we all make mistakes. Combine "mistakes" with "automatically and recursively propagated" and have a recipe for disaster.
- Editors and tools might create stray files.
jj editwon't care: it will commit and rebase anyway.
A good rule of thumb, with Jujutsu just as with Git, is:
Always review the changes before making them part of a commit.
That's why the Git Index exists. jj edit is often just too rushed.
The solution is pretty easy: whenever you are tempted to run jj edit X, run jj new X instead. Rather than starting open-heart surgery on
X (sirens blaring!), jj new prepares a safe working area for you
to work in peace (elevator music...).
Mini-workflow
Here's a little recipe. If you want to edit X:
jj new X- Make your edits.
- When you are done, review your work with
jj diffand squash it back withjj squash. - If you want to start over,
jj restore.
jj diff will show you the changes you made.
jj restore will revert only your changes, leaving the original
target Change intact.
See This In Practice
Again using our sample repository. Say you want to edit v:
○ zx Click alternates Xs and Os main
○ v Click sets a X <-- What you want to edit
○ s Square has state
│ ○ kt empty Hover effect to board squares
│ ○ rv empty Changes to both CSS and app.js
│ ○ q Add the CSS file css
│ ○ rt Link to CSS
├─╯
│ ○ o Play instructions manual
│ ○ kx Play manual
├─╯
○ n Square is interactive
~
Instead of jj edit v, go with $ jj new v:
@ l empty <-- Your Current Change
│ ○ zx Click alternates Xs and Os main
├─╯
○ v Click sets a X <-- What you want to edit
○ s Square has state
│ ○ kt empty Hover effect to board squares
│ ○ rv empty Changes to both CSS and app.js
│ ○ q Add the CSS file css
│ ○ rt Link to CSS
├─╯
│ ○ o Play instructions manual
│ ○ kx Play manual
├─╯
○ n Square is interactive
~
Notice where you landed:
- on an empty, messageless Change
- on top of the Change you want to edit.
Let me call that empty Change the Buffer Change.
If you squint, you could think of that Buffer Change as a Git Index-like structure. Only, it's an ordinary Change, like all the other ones, so it's persistent and operable with the ordinary commands.
Isn't That The Same As jj edit?
Definitely no.
If you did jj edit v directly:
jj diffwould display your changes plus all the changes originally inv, mixed together. Reviewing your work would be less convenient.- Similarly,
jj restorewould revert your changes plus all the changes originally inv, basically reverting everything tov's parents: definitely, not what you want.
The Joy Of Not Having Special Cases
The fact this Buffer Change is an ordinary Change has a couple of interesting consequences.
There's No Need for jj stash
You need to drop everything and switch to something else:
-
With Git, before running
git checkout, you need togit stash pushor you will lose your work. -
With Jujutsu? Fear no more: go safe with
jj newsomewhere else: your Buffer Change won't be lost.
Since you are working with ordinary Changes, there's no separate stash
mechanism to learn: suspended work lives alongside all other Changes
and never gets lost. The git stash machinery collapses into the
Jujutsu primitives you already know.
Multiple Indexes
Have you ever thought you can have multiple indexes? Say that while
you are editing v (from a distance) you come up with another
possible approach to complete your work. No problem: run another jj new v and you'll end up with a second, independent Index-like
Change. Eventually, you will jj abandon one and jj squash the
other. Or maybe you want to mix them? Your choice.
Wandering Around
When you run jj new and you land in a new Change, if the Change you
left behind is empty, Jujutsu abandons it.
That's a particularly happy default: it implies that you can jump back
and forth in your history tree targeting different Changes with jj new.
Target X and run jj new X: by design, the new Change you jump to
has the same content that X has. So, you can use it as a safe copy
to see what's inside X, replicating its content on your disk.
By sitting on top of the Change you want to visit, you are sure that:
- You won't pollute the history with empty leftover Changes.
- There's no risk of unintentional modifications.
In practice, this is a very cheap way to get read-only access to your repository.
Conflict Resolution
Enough with the easy cases. Let's talk about the exact opposite situation.
The mini-workflow (jj new && (jj squash | jj revert)) you saw in the
previous page is particularly handy when resolving conflicts.
So, it's definitely time to tackle this topic.
- You start learning how to resolve conflicts by editing files.
- You learn that conflict resolution propagates to descendants.
- Then you see some cases where you can resolve a rebase conflict by other means, like by doing another rebase or by abandoning Changes.
- Finally, if you are curious, you read how Jujutsu handles conflicts, internally, and you get what it means that they are first-class citizens.
Conflicts
We will spend quite a bit of time on this topic. Not because it's tricky, but because it's one of the scariest topics in Git, and I'm determined to put that fear to rest once and for all.
We will play with conflicts in practice, with a real repository. Clone it with:
jj git clone https://codeberg.org/arialdo/jj-playground-conflicts.git
Then, as before, create a tracked local branch for each remote branch:
jj bookmark track '*' --remote origin
You haven't seen the first command before: it creates a local bookmark for each remote branch. We'll see bookmarks in a few pages.
Here's the history tree:
@ mv hello mundo
│ ○ nz hola world
├─╯
○ vr hello world
○ ypl
○ vw
│ ○ nl
│ ○ ypy
│ ○ mw
│ ○ ww
│ ○ wq
├─╯
○ x
○ vn
○ r
│ ○ no feat-1
│ ○ vt
│ ○ s
│ ○ u
│ ○ vz
├─╯
○ q
◆ z
Ready to go! We will do the following moves, each producing some conflicts, progressively more challenging (but I swear: they are all trivial):
- We merge
mvandnzto get a merge conflict. - Then we rebase the branch
feat-1to get 1 single rebase conflict. - Then the branch
feat-2, to get multiple conflicts.
Then, we will play with some novel conflict resolution techniques that are just not available with Git.
Ready? Hands on the keyboard!
- You start with the simplest case: a merge conflict.
- You see in practice what it means that conflicts are not blocking.
- You learn how to read the conflict markers.
- And, ultimately, how to solve the conflict.
Focus on the Changes:
○ nz merge-b
│ ○ m merge-a
├─╯
○ vr Base of merge-a and merge-b
│
~
They have vr as their shared base. vr contains a text file with
some Lorem Ipsum lines. Among them, you'll find the line:
We will create a conflict here.
If you inspect m / merge-a:
jj show m
you see that it edits exactly that line, adding:
: merge-a added this text.
By the way: you can also reference m using the bookmark
merge-a. Bookmarks are equivalent to Git's branches.
The Change nz / merge-b does the same, only with the text:
: merge-b added this text.
Since the two changes are incompatible, merging them will certainly
result in a conflict.
Exactly what we want!
○ nz merge-b
│ ○ m merge-a
├─╯
○ vr Base of merge-a and merge-b
│
~
So, let's create this conflict. Merge merge-a and merge-b, with
the message Merge conflict. Do you remember how to merge branches?
Solution
Solution
jj new merge-a merge-b -m "Merge conflict"
or:
jj new a nz -m "Merge conflict"
Of course, nothing stops you from swapping the arguments:
jj new merge-b merge-a -m "Merge conflict"
or:
jj new nz a -m "Merge conflict"
Swapping arguments won't change the result, but the conflict details
will be swapped too. Not a big issue at all, but for the sake of
simplicity, I assume you specified a / merge-a as the first argument.
Jujutsu will let you know you have a conflict:
Working copy (@) now at: nlpwoyvx 0670a921 (conflict) (empty) (no description set)
Parent commit (@-) : mvxxwqoo e1acf668 merge-a | (no description set)
Parent commit (@-) : nzsunxwp 0004a47a merge-b | (no description set)
Added 0 files, modified 1 files, removed 0 files
Warning: There are unresolved conflicts at these paths: file.txt 2-sided conflict
Notice: it's warning, not an error. When you are in a conflicted
Change, jj log will display the @ marker in red. Any other
conflicted Change will be displayed as a red ×.
As we've noted before, having a conflict won't prevent you from
continuing your work.
Let's challenge this notion.
- You learn that you can keep working as usual.
- You will even rebase the conflicted Change and commit on top of it.
Living with Conflicts
Let's see if before resolving the
conflict you can rebase the whole branch vr:: from its base q to
r:
@ zo empty Merge conflict
├─╮
│ ○ nz merge-b
○ │ m merge-a
├─╯
○ vr Base of merge-a and merge-b <- current base
│ ○ y empty feat-2
│ ○ vw empty
│ ○ x empty
│ ○ vn empty
│ ○ r Rebase vr:: here <- rebase it here
├─╯
│
~
Solution
Solution
There are a couple of ways to do this.
You can specify the Changes to rebase with vr:: (vr and
all its descendants):
jj rebase -r vr:: -o r
Otherwise, you can specify the cut & paste points, with:
jj rebase -s vr -o r
as you saw in Git Rebase.
The rebase succeeds. Unlike Jujutsu won't whine with something like:
error: cannot rebase: You have unstaged changes.
error: additionally, your index contains uncommitted changes.
error: Please commit or stash them.
or:
fatal: It seems that there is already a rebase-merge directory, and
I wonder if you are in the middle of another rebase. If that is the
case, please try
git rebase (--continue | --abort | --skip)
If that is not the case, please
rm -fr ".git/rebase-merge"
and run me again. I am stopping in case you still have something
valuable there.
Of course, even if jj rebase succeeded, the conflict is still there,
and you will be reminded of that:
Warning: There are unresolved conflicts at these paths:
file.txt 2-sided conflict
But again, it's just a warning, not a showstopper.
Can you even commit on top of a conflicted Change? Of course, why not!
Give it a try:
jj new -m "My parent is conflicted"
jj new
echo "I can keep working" > some-work-file.txt
jj squash
@ l empty
× zm My parent is conflicted
× zo Merge conflict
├─╮
│ ○ nz merge-b
○ │ m merge-a
├─╯
○ vr Base of merge-a and merge-b
│
~
As you see, Jujutsu won't forget that there is a conflict back in history, so it will keep displaying the warning:
Warning: There are unresolved conflicts at these paths:
file.txt 2-sided conflict
and jj log will highlight all the Changes inheriting that conflict using × and red @ symbols.
Time to resolve the conflict.
Where you see how to interpret the conflict markers and you see how a conflict resolution automatically propagates.
Resolving A Conflict by Editing It
The most straightforward and natural way to solve a conflict is by
editing the affected files.
In this case, it's a matter of editing the merge Change zo:
@ l empty
× zm My parent is conflicted
× zo Merge conflict <- Resolve the conflict here
├─╮
│ ○ nz merge-b
○ │ m merge-a
├─╯
○ vr Base of merge-a and merge-b
│
~
Notice: you've surely obtained a different Change ID. For convenience,
let's call it nz+, meaning the nz's direct child.
Did I Tell You Not To Edit Changes?
If you are tempted to run jj edit zo, please, don't! Remember my
heartfelt plea in Don't Edit. Do jj new zo instead. Remember that any change you perform in zo is
automatically propagated. This way, you can review your resolution
before finalizing it and easily undo any mistake.
So, do yourself a favor and create a Buffer Change:
jj new zo
This is what you get:
@ k empty <- Your Buffer Change
│ × zm My parent is conflicted
├─╯
× zo empty Merge conflict <- The Change to fix
├─╮
│ ○ nz merge-b
○ │ m merge-a
├─╯
○ vr Base of merge-a and merge-b
│
~
Of course, even this Buffer Change will be conflicted. That's exactly the point: you want to resolve the conflict from there, so it must inherit it.
So, here's the idiomatic workflow for attacking conflicts:
- Run
jj newtargeting the conflicted Change. - When you've solved the conflict,
jj squash, as per the mini-workflow. - If you want to start over,
jj restore.
This is exactly the mini-workflow you saw in Don't Edit.
Resolving The Conflict
Edit the conflicted file file.txt. You will see this content:
Donec neque quam, dignissim in, mollis nec, sagittis eu, wisi.
Phasellus neque orci, porta a, aliquet quis, semper a, massa.
Etiam vel neque nec dui dignissim bibendum.
Nunc porta vulputate tellus.
Nunc eleifend leo vitae magna.
Sed id ligula quis est convallis tempor.
<<<<<<< conflict 1 of 1
%%%%%%% diff from: vrzponzu b12f7845 "Base of merge-a and merge-b"
\\\\\\\ to: mvxxwqoo e1acf668
-We will create a conflict here.
+We will create a conflict here: merge-a added this text.
+++++++ nzsunxwp 0004a47a
We will create a conflict here: merge-b added this text.
>>>>>>> conflict 1 of 1 ends
Nullam rutrum.
Curabitur vulputate vestibulum lorem.
Nullam eu ante vel est convallis dignissim.
Sed bibendum.
Aliquam posuere.
Let's learn how to read the conflict:
<<<<<<< conflict 1 of 1
%%%%%%% diff from: vrzponzu b12f7845 "Base of merge-a and merge-b"
\\\\\\\ to: mvxxwqoo e1acf668
-We will create a conflict here.
+We will create a conflict here: merge-a added this text.
+++++++ nzsunxwp 0004a47a
We will create a conflict here: merge-b added this text.
>>>>>>> conflict 1 of 1 ends
Interpreting Conflicts
The first and the last lines are obviously the conflict, with the total number of conflicts:
<<<<<<< conflict 1 of 1
>>>>>>> conflict 1 of 1 ends
All the lines above and below those boundaries are fine: both parents
agree on them.
Between those 2 markers, you will find 2 elements:
- How the 1st Change wanted to change the common Base.
- The content of the 2nd Change.
Look:
%%%%%%% diff from: vrzponzu b12f7845 "Base of merge-a and merge-b"
\\\\\\\ to: mvxxwqoo e1acf668
-We will create a conflict here.
+We will create a conflict here: merge-a added this text.
Read it as:
%%%%%%% diff from: v "Base of merge-a and merge-b" <- from the common Base
\\\\\\\ to: mvxxwqoo e1acf668 <- the first Change merge-a
-We will create a conflict here. <- wants to apply this diff.
+We will create a conflict here: merge-a added this text.
Of course, - means deleting a line, + means replacing the line
with some other content.
Resolving The Conflict
Here's a possible resolution:
Sed id ligula quis est convallis tempor.
We will create a conflict here: merge-a added this text, merge-b added this text.
Nullam rutrum.
*Very* interestingly, if you run jj st, you will see this message:
Hint: Conflict in parent commit has been resolved in working copy
Cool! That's how Jujutsu tells you:
Hey, pal! If you squash back this change, you fix the conflict.
Let's do that
jj squash
Perfect, it's all clear!
What about the Buffer Change? No worries, it's an empty, messageless
Change. It will be automatically abandoned once you move away; it was
a temporary working area from the start.
Conflict Resolutions Propagate
Look how beautiful:
@ l empty
○ zm My parent is conflicted
○ zo Merge conflict
├─╮
│ ○ nz merge-b
○ │ m merge-a
├─╯
○ vr Base of merge-a and merge-b
│
~
Not only is zo clear: the conflict resolution propagated to zm and
to @ too.
How cool is that?
Resolutions All The Way Down
That's a general rule. If you build on top of a conflict:
× j <- also in conflict
× i <- also in conflict
× h <- also in conflict
× g <- also in conflict
× f <- also in conflict
× e <- also in conflict
× d <- conflict
○ c
○ b
○ a
The moment you fix the conflict somewhere, the resolution will be
propagated. Say you fixed the conflict in f, you'd get:
○ j
○ i
○ h
○ g
○ f <- resolved here
× e <- conflict
× d <- conflict
○ c
○ b
○ a
Of course, ideally you'd fix the conflict where it originated. In this
case, since you already have a resolution in f you can
jj squash it to d:
○ j
○ i
○ h
○ g
○ f
○ e
○ d
○ c
○ b
○ a
I hope you got the idea.
Ready to see some more challenging cases?
Using the GUI
Before proceeding: do you prefer using a GUI tool such as kdiff3?
No problem:
jj resolve --tool kdiff3
Git Style Conflict Markers
Jujutsu is not an opinionated tool. Do you prefer Git-style conflict
markers?
Configure your repo with:
jj config set --repo ui.conflict-marker-style git
and, presto!, you are back to your familiar format.
You will solve 2 cases:
-
A rebase producing a single conflict. You'll resolve it by editing the file and you'll watch the resolution propagate to all descendants.
-
A rebase producing 2 conflicts: you'll resolve them by editing Changes other than the conflicted ones, something you probably haven't done before in Git.
Rebase Conflicts
I bet this chapter will be a mere formality for you. You already know
how to resolve a rebase conflict. Don't you think? Challenge
yourself. Move the feat-1 branch on top of vr:
○ so
○ o Merge conflict
├─╮
│ ○ nz merge-b
○ │ m merge-a
├─╯
○ vr Base of merge-a and merge-b base
│ ○ y empty feat-2
│ ○ vw empty
│ ○ x empty
│ ○ vn empty
│ ○ r Rebase vr:: here
├─╯
│ ○ no empty feat-1
│ ○ vt empty
│ ○ sy empty
│ ○ u empty
│ ○ vz empty
├─╯
○ q empty main
◆ z 🔒 empty
Solution
Solution
If you are on top of the feat-1 branch:
jj new feat-1
then specifying the new base is enough:
jj rebase -o vr
Otherwise, if you rebase from a distance, specify both source and target:
jj rebase -s vz -o vr
Here we go, a conflict in s:
@ no feat-1
× vt
× s Will conflict
○ u
○ vz
│ ○ y empty feat-2
│ ○ vw empty
│ ○ x empty
│ ○ vn empty
├─╯
○ r Rebase vr:: here
│
~
As you see, the conflict arises in s and is propagated to all
descendants.
How to resolve it? Remember to use the jj new && (jj squash || jj restore) workflow you saw in Don't Edit.
Solution
Solution
jj new -r s
You will find this conflict in feat-1.js:
<<<<<<< conflict 1 of 1
+++++++ unmwmutn 91e9daba "" (rebase destination)
// This comment will get a conflict
%%%%%%% diff from: unmwmutn e6f1eb22 "" (parents of rebased revision)
\\\\\\\ to: nolxxptq 09ad5fbc (rebased revision)
+ // Handle theme color and mode
>>>>>>> conflict 1 of 1 ends
A possible resolution is to replace the whole section with:
// Handle theme color and mode
Then:
jj squash
and you are done!
If you got it right, good job! You will see how the resolution
propagate from s all the way down its descendants:
○ no feat-1
○ vt
○ s Will conflict
○ u
○ vz
│ ○ y empty feat-2
│ ○ vw empty
│ ○ x empty
│ ○ vn empty
│ ○ r Rebase vr:: here
├─╯
~
See? A walk in the park.
Multiple Rebase Conflicts
@ zq empty
○ y feat-2
○ unv
○ vw Will conflict
○ xw
○ xl Will conflict
│ ○ nz merge-b
│ │ ○ m merge-a
│ ├─╯
│ ○ vr Base of merge-a and merge-b base
│ ○ r Rebase here
│ ○ l No idea why
├─╯
│ ○ no feat-1
│ ○ vt
│ ○ s Will conflict
│ ○ unm
│ ○ vz
├─╯
○ q main
◆ zz 🔒 empty
Without further ado, rebase feat-2 on top of r:
@ zq empty
× y feat-2
× unv
× vw Will conflict
× xw
× xl Will conflict
○ r Rebase here
○ l No idea why
│
~
Boom.
First Conflict
What's the conflict in xl?
Will conflict
Created conflict in README.md:
...
4 4: problem has finally been solved. This app lets you write things down
5 5: and then cross them out. So cool.
6 6:
7 7: ## Features You Won't BeleiveBelieve
8 8:
9 9: - Adding tasks. You type words. They appear. Groundbreaking.
10 10:
11 11: - Completing tasks. Click a tiny box and watch your task get a line
12 12: through it, wich is basically like deleting it.
13: <<<<<<< conflict 1 of 1
14: %%%%%%% diff from: qmyyoorn bdcd7f2b (parents of rebased revision)
15: \\\\\\\ to: roulqqom 67bb45ac "Rebase here" (rebase destination)
13 16: +:quit
14 17: +:qw
15 18: +:wq
16 19: +:q!
17 20: +:x
18 21: +:exit
19 22: +:how to quit this editor?
20 23: +:emacs
21 24: +:emacs help
25: +++++++ xlkttytw e08dfcc6 "Will conflict" (rebased revision)
26:
27: - Filters. All, Active, Completed. Choose your poison.
28:
29: - Inline editing. Made a typo? Double-click and fix it. So cool!
30: >>>>>>> conflict 1 of 1 ends
Ha, that's embarrassing! Turns out that in Change r I was wrestling
with Vim and accidentally mangled README.md. Oh well, I'm an Emacs
user, but I should at least learn how to quit Vim.
You could resolve the conflict in x, but why keep those messy Vim
commands in README.md? Why not tidy r up directly? Let's do that
applying the conventional mini-workflow:
jj new r
jj restore -f r- README.md
jj squash
@ r empty
│ × y feat-2
│ × unv
│ × vw Will conflict: deleted file
│ ○ xw
│ ○ xl Will conflict: Vim
├─╯
○ r Rebase here
○ l No idea why
│
~
Cool! That solved the problem in xl and the resolution propagated to
xw too.
This is probably your first time resolving a conflict without touching the conflicted commit itself.
There's still a conflict in vw. Time to tackle it.
Second Conflict
What is it about? If you inspect vw with jj show vw you will see:
Added conflict feat-2.js: 1: <<<<<<< conflict 1 of 1 2: %%%%%%% diff from: xwzyvpnm c8dcd10f (parents of rebased revision) 3: \\\\ to: xwzyvpnm c8dcd10f (rebased revision) 4: -import { useState } from "react"; 5: -import { Plus, Check, X } from "lucide-react"; 6: - [...] 104: -
