Adopting
Jujutsu
How I've been switching from Git to Jujutsu
I’ve always loved the safety net that a VCS like Git provides, but I’ve never loved git’s user interface/experience, nor the mental model that I’ve tried to hold in my head while I’m using it.
Jujutsu has been on my radar for a while now, and over the past few months I’ve been using it instead of Git as much as possible.
It took me a while to understand some of the concepts (and I’m still learning), and I wish I had found some better examples of people moving from my workflow to Jujutsu sooner. So if your Git workflow is at all like mine, then hopefully this post will be useful to you.
- Jump to
- Further reading
- My
gitworkflow and myjjworkflow - Glossary
- Before we start —
jj undo - A new change —
jj new - The Working Copy —
@ - Saving edits
- Pushing —
jj git push -c @ - Switching work —
jj edit - Rebasing and conflicts —
jj rebase - Updating my local repo —
jj git fetch - Reviewing other people’s changes
- Customizing Jujutsu
- That’s everything
Further reading
Normally people end their posts with a “further reading” section, but in this case I think it’s best to start with one.
This blog post is not intended to be a fully fledged introduction to Jujutsu. It is a very opinionated post about how I am using Jujutsu.
If you would prefer a fully fledged introduction then here are some options:
- I really liked Chris Krycho’s ‘jj init’
- A lot of people like Steve Klabnik’s Jujutsu tutorial — I’m a big fan of Steve’s work, but for whatever reason I didn’t quite “get” this tutorial.
My git workflow and my jj workflow
In Git I do things in the following way:
- Create a feature branch off main:
git checkout maingit checkout -b baxter/feature_name
- Edit some files and add them:
git add [-p]
- Commit and push those changes:
git commit -m "Feature description ..."git push
- Create the PR & get feedback
- Address feedback and push those changes
git checkout baxter/feature_name(if I’ve started working on something else in the mean time)git add [-p]git push
- Once the PR looks good, merge it
- Update
main:git checkout maingit pull
- Rebase any other changes I’m working on:
git checkout baxter/a_different_branchgit rebase main
- Deal with any conflicts:
git rebase --continue
- Done!
In Jujutsu my workflow currently looks like this (and I’ll explain more after I go through the steps):
- Create a new change off main:
jj new main -m "Feature description ..."
- Edit some files and add them:
jj status, or any otherjjcommand, implicitly adds any new changes to the working copy
- (Optional) Update the description of my changes:
jj describe -m "Feature description ..."
- Push those changes:
jj git push -c @
- Create the PR & get feedback
- Address feedback and push those changes
jj edit <change ID>(if I’ve started working on something else in the mean time)jj git push -c @
- Once the PR looks good, merge it
- Update
main:jj git fetch(presumingmainis set up as a “tracking bookmark”)
- Rebase any other changes I’m working on:
jj edit <other change ID>jj rebase -d main
- Deal with any conflicts:
- In
jjthis means just fixing the files. I’ll go into more detail below.
- In
- Done!
You’ll notice that some of the terminology I’m using here is not Git terminology. Like in the Git workflow I said “create a feature branch” but in the Jujutsu workflow I said “create a new change”.
So it’s probably good to explain what I mean.
Glossary
- A commit in Git is an immutable snapshot of the state of a repo at a certain point in time. If you change the state of the repo then the commit ID also changes. Git’s primary unit of abstraction is the commit.
- A revision is the term Jujutsu tends to use to refer to a commit. It is also immutable. Jujutsu tends to use Git as its storage layer, so this makes sense.
- A change is a mutable piece of work that evolves over time. When edits are added to a change, Jujutsu will create a new commit/revision but it won’t create a new change — the edits are added to the existing change. Changes have stable identifiers that do not change when work is added. Jujutsu’s primary unit of abstraction is the change.
- A branch is a pointer to a commit in Git. It automatically advances whenever a new commit is added to that branch.
- A bookmark is a pointer to a revision (so, a commit) in Jujutsu. Tracking bookmarks will automatically advance when their remote counterparts are updated (e.g., when you do a fetch and
main@originmoves forward, your localmaintracking bookmark moves too). However, non-tracking bookmarks behave more like tags - they stay put unless you explicitly move them.1
Before we start — jj undo
Every action that Jujutsu performs is recorded in the operation log, which can be viewed with jj op log.
And almost every operation can be undone with jj undo. I’ve not had to use it much, but when I have it has been incredibly useful. Don’t forget it’s there.
A new change — jj new
Usually, when I start working on something new, I run jj new.
$ jj new main -m "Feature description ..."
jj newCreate a new, empty change and (by default) edit it in the working copy
This creates a new change, with its parent being main, and sets a description for the change as “Feature description …”.
This is different from Git. In Git you would tend to start working on something, edit some files, which would initially be “unstaged”, then you would add those changes to the index with git add, and then create a commit along with a description.
In Jujutsu you tend to create the change first, and then add things to it. I’ll explain how to add things in the “Saving edits” section.
The Working Copy — @
Before we go on to saving edits, if I run jj status after running the jj new command above, it will show a result like this:
$ jj status
The working copy has no changes.
Working copy (@) : wkyuorzp c84a280f (empty) Feature description ...
Parent commit (@-): smquzzqr fbcd29ad main | An existing change
There’s a few things going on here. I’ll go through them line by line.
The working copy has no changes.
The “working copy” is a name used to describe the current thing that is being worked on. In this case it’s the change that we just created and, since it was just created, it has nothing in it.
Working copy (@) : wkyuorzp c84a280f (empty) Feature description ...
This line shows us the state of the working copy. The symbol @ is a special symbol in Jujutsu that refers to the working copy.
It then shows us some characters. wkyuorzp is the Change ID of the working copy. c84a280f is the current Commit ID of the working copy.
It then also says that this change is currently (empty) and prints the subject of this change, “Feature description …”.
Parent commit (@-): smquzzqr fbcd29ad main | An existing change
The next line shows us the parent of the “working copy”, which can be referenced using Jujutsu’s expressions for revisions as “@-”.
Again we see some characters. smquzzqr is the Change ID of the parent of the working copy. fbcd29ad is the Commit ID of the parent of the working copy.
We can also see that the parent of the working copy has a bookmark of main, which makes sense because earlier I ran jj new main -m "Feature description ...", which instructed Jujutsu to create a new change with main as its parent.
Saving edits
In Jujutsu, every edit is committed to history as soon as possible. Running jj status (or any other jj command) will add any new changes to the current commit.
$ touch my_file.txt
$ jj status
Working copy changes:
A my_file.txt
Working copy (@) : wkyuorzp df1bb0ae Feature description ...
Parent commit (@-): smquzzqr fbcd29ad main | An existing change
Here, my_file.txt has been committed even though I only ran jj status.
You can also see that the change ID for the working copy, wkyuorzp, has stayed the same, while the commit ID for the working copy is now df1bb0ae.
This is because, as I said above, a change ID will remain the same as you edit files, but the commit ID (or revision ID) will vary as work is added or edited.
This is one of the key fundamental differences in philosophy between Git and Jujutsu. By committing as soon as possible, Jujutsu avoids the concept of a staging area or an index. This simplifies a few things, like switching between work and dealing with conflicts.
Sometimes as I make progress on a change I realise that the description needs updating. jj describe -m "New description" can update the subject in a single command, or you can use jj describe with no arguments to open an editor and give a full description.
Pushing — jj git push -c @
Presuming my change looks good, and my description looks good, then I’ll want to push my change.
In my repo I have a Git remote called origin. I can use the command jj git push -c @ to push the working copy, i.e. @, to my origin. The -c <REVSET> flag accepts a change ID and, if present, will auto-generate a Jujutsu bookmark and a Git branch on the remote for it.
$ jj git push -c @
Creating bookmark baxter/change-wkyuorzppylx for revision wkyuorzppylx
Changes to push to origin:
Add bookmark baxter/change-wkyuorzppylx to df1bb0ae37eb
Now if I check my remote I will see there is a Git branch called baxter/change-wkyuorzppylx.
Switching work — jj edit
Now that I’ve pushed my changes I want to work on something else.
I can create a new change with jj new main -m "Yet another change".
$ jj new main -m "Yet another change"
Working copy (@) now at: ypkklntw 2ca234a9 (empty) Yet another change
Parent commit (@-) : smquzzqr fbcd29ad main | An existing change
Added 0 files, modified 0 files, removed 1 files
That last line says “removed 1 files”. That file is the one that I created in my previous change. Since it’s not yet on main, it’s not visible within this change.
Now’s probably a good time to run jj log and see how everything looks.
$ jj log
@ ypkklntw (me) 2025-10-22 16:17:09 2ca234a9
│ (empty) Yet another change
│
│ ○ wkyuorzp (me) 2025-10-21 16:18:31 baxter/change-wkyuorzppylx df1bb0ae
├─╯ Feature description ...
│
◆ smquzzqr (me) 2025-09-24 17:57:34 main fbcd29ad
│ An existing change
~
You can see that I now have 2 changes that have main as their parent.
I’m going to add a file to this change, my_other_file.txt, and run jj status to ensure it’s added it to my working copy.
Now let’s say that I’m halfway through my work on this change when I get some feedback on my previous change and need to switch back.
You can see from the log that my previous change had a change ID of wkyuorzp, so to go back to editing it I can run:
$ jj edit wkyuorzp
Working copy (@) now at: wkyuorzp df1bb0ae baxter/change-wkyuorzppylx | Feature description ...
Parent commit (@-) : smquzzqr fbcd29ad main | An existing change
Added 1 files, modified 0 files, removed 1 files
And now I’m back editing my previous change.
Rebasing and conflicts — jj rebase
Let’s say I want to merge my change, but first it needs to be rebased. I’ll start by fetching from the remote:
$ jj git fetch
remote: Enumerating objects: 1, done.
remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
bookmark: main@origin [updated] tracked
And then I’ll try to rebase my change onto main.
$ jj rebase -d main
Rebased 1 commits to destination
Working copy (@) now at: wkyuorzp 734b8c3e baxter/change-wkyuorzppylx* | (conflict) Feature description ...
Parent commit (@-) : xvxtpzyr 5d1ee7fc main | (empty) Merge branch 'baxter/change-oounzounknwo' into 'main'
Added 0 files, modified 1 files, removed 0 files
Warning: There are unresolved conflicts at these paths:
my_file.txt 2-sided conflict
New conflicts appeared in 1 commits:
wkyuorzp 734b8c3e baxter/change-wkyuorzppylx* | (conflict) Feature description ...
Hint: To resolve the conflicts, start by creating a commit on top of
the conflicted commit:
jj new wkyuorzp
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.
That’s a lot of text, but basically there’s a conflict. In Git I would be in a “detached HEAD” state during the rebase, and I would need to either fix things and git rebase --continue, or give up and git rebase --abort.
In Jujutsu, the conflicted file was simply added to the working copy. There is no special state. I can, as the warning suggests, create a new commit on top of the conflicted commit, fix it, and then squash it. I can also just edit the file in the current commit if I wanted to. But let’s do as the warning message suggests:
$ jj new wkyuorzp
Working copy (@) now at: zupsxwtv 63455418 (conflict) (empty) (no description set)
Parent commit (@-) : wkyuorzp 734b8c3e baxter/change-wkyuorzppylx* | (conflict) Feature description ...
Warning: There are unresolved conflicts at these paths:
my_file.txt 2-sided conflict
$ cat my_file.txt
<<<<<<< Conflict 1 of 1
%%%%%%% Changes from base to side #1 (removes terminating newline)
+Hello world!
+++++++ Contents of side #2 (no terminating newline)
Hello, world.
>>>>>>> Conflict 1 of 1 ends
Someone already edited this file and changed the contents. You can see that the syntax Jujutsu uses for conflicts is similar to the syntax Git uses.
I can fix the conflict and save the file, and then as soon as I type jj status:
$ jj status
Working copy changes:
M my_file.txt
Working copy (@) : zupsxwtv 3b347bb7 (no description set)
Parent commit (@-): wkyuorzp 734b8c3e baxter/change-wkyuorzppylx* | (conflict) Feature description ...
Hint: Conflict in parent commit has been resolved in working copy
This says that the conflict in the parent commit has been resolved in the working copy. Great!
Now I can squash this into its parent.
[baxter:~/Code/jj-example-2]$ jj squash
Working copy (@) now at: toynrvuv 7d6b59a3 (empty) (no description set)
Parent commit (@-) : wkyuorzp 0d2cd7ec baxter/change-wkyuorzppylx* | Feature description ...
Existing conflicts were resolved or abandoned from 1 commits.
You can see that the parent commit no longer shows “(conflict)”.
I can jump back to that change with jj edit and push my changes to the remote:
$ jj edit wkyuorzp
Working copy (@) now at: wkyuorzp 0d2cd7ec baxter/change-wkyuorzppylx* | Feature description ...
Parent commit (@-) : xvxtpzyr 5d1ee7fc main | (empty) Merge branch 'baxter/change-oounzounknwo' into 'main'
$ jj git push -c @
Changes to push to origin:
Move sideways bookmark baxter/change-wkyuorzppylx from df1bb0ae37eb to 0d2cd7ec4935
Updating my local repo — jj git fetch
With my conflicts resolved, and my change pushed to the remote, I can merge my PR on GitHub/GitLab/wherever.
Once I’ve done that I want my local repo to reflect the remote.
$ jj git fetch
remote: Enumerating objects: 1, done.
remote: Total 1 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
bookmark: baxter/change-wkyuorzppylx@origin [deleted] untracked
bookmark: main@origin [updated] tracked
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Working copy (@) now at: vznqvuon 0e6130d7 (empty) (no description set)
Parent commit (@-) : wkyuorzp 0d2cd7ec Feature description ...
That worked but we got a warning. I’ll go through some of the key points in the message:
bookmark: baxter/change-wkyuorzppylx@origin [deleted] untracked
I always delete branches when I merge them, so here Jujutsu deleted the local bookmark because the remote branch was deleted.
bookmark: main@origin [updated] tracked
main is set up to be a tracking bookmark, so it was automatically updated to reflect my change being merged into main.
Warning: The working-copy commit in workspace 'default' became immutable, so a new commit has been created on top of it.
Remember before how I said some commits are treated by Jujutsu as being immutable? Well the working copy I had open just became an immutable commit. I can no longer edit it, so Jujutsu did the sane thing and created a new change on top of it.
Reviewing other people’s changes
Often I’ll need to review other people’s changes locally. To get changes from my remote I can run jj git fetch:
$ jj git fetch
remote: Enumerating objects: 4, done.
remote: Total 3 (delta 0), reused 0 (delta 0), pack-reused 0 (from 0)
bookmark: someone_else/a_new_thing@origin [new] untracked
Here a new bookmark has appeared, someone_else/a_new_thing@origin.
By default, this bookmark won’t be visible when you run jj log. jj log has some defaults to try to show you things that are relevant. But we can still see it if we pass a query to jj log using Jujutsu’s query language.
$ jj log -r "remote_bookmarks()"
◆ zyyrvzlw (me) 2025-10-23 11:06:03 someone_else/a_new_thing@origin def90aa1
│ Something new
~
So let’s imagine that this new bookmark is some code that I want to review. You might think I can just type jj edit zyyrvzlw and start working on it, much the same way you might do git checkout <branch_name>.
$ jj edit zyyrvzlw
Error: Commit def90aa19f55 is immutable
Hint: Could not modify commit: zyyrvzlw def90aa1 someone_else/a_new_thing@origin | Something new
Hint: Immutable commits are used to protect shared history.
Hint: For more information, see:
- https://jj-vcs.github.io/jj/latest/config/#set-of-immutable-commits
- `jj help -k config`, "Set of immutable commits"
Hint: This operation would rewrite 1 immutable commits.
Jujutsu has the concept of immutable commits. If I want to check out this other change I should instead create a new change on top of theirs:
$ jj new zyyrvzlw
Working copy (@) now at: tlkkqlqt f0ab4796 (empty) (no description set)
Parent commit (@-) : zyyrvzlw def90aa1 someone_else/a_new_thing@origin | Something new
Added 1 files, modified 1 files, removed 0 files
And then when I’m finished I can use jj edit to go back to whatever I was working on.
Doesn’t this mean that I’ve now left a dangling new change with nothing in it?
No, because if you leave behind a change with no contents and no description, it is automatically abandoned:
$ jj edit wkyuorzp
Working copy (@) now at: wkyuorzp e99956fa baxter/change-wkyuorzppylx* | Feature description ...
Parent commit (@-) : smquzzqr fbcd29ad An existing change
Added 0 files, modified 1 files, removed 1 files
$ jj edit tlkkqlqt # <- the change ID of the empty change I created above
Error: Revision `tlkkqlqt` doesn't exist
Customizing Jujutsu
Jujutsu is pretty easy to customize. It uses a template based system, and all of the templates are defined in its GitHub repo under config/.
You can configure your Jujutsu setup with jj config edit --user which will edit your user’s config.toml file.
Jujutsu has a lot of built-in operators and functions that you can use, as well as many Types. So for example, if you’re wanting to change how an author’s signatures are formatted you can look at the Signature type documentation.
Here are some things that I’ve found especially useful.
Short IDs
Throughout this guide I’ve used the 8 character “short” ID for changes and commits, but Jujutsu is actually happy to work with the shortest unique ID. In other words, if there are two changes in your repo, one with an ID of abbbbbbb and one with an ID of accccccc, you can reference them by ab and ac respectively.
Adding this to your config will replace all instances of change IDs and commit IDs in your logs and in jj status with their shortest unique ID:
[template-aliases]
'format_short_id(id)' = "id.shortest()"
Less email addresses
I hate seeing my own email address again and again, so I replaced the standard “short signature” function with this:
[template-aliases]
'format_short_signature(signature)' = '''
coalesce(
if(signature.email() == config("user.email").as_string(), label("author me", "(me)")),
signature.email(),
email_placeholder
)
'''
This will replace whatever email you have configured within Jujutsu with the string “(me)”.
Cleaner logs
I found the default Jujutsu logs very dense and busy.
Jujutsu’s built-in “comfortable” log style is a little less crowded:
[templates]
log = 'builtin_log_comfortable'
I’ve actually customized my logs quite a bit but I’ll save that for a separate post.
Better automated bookmark/branch names
The default bookmark/branch names created by jj git push -c @ are not ideal. I use the following template to prefix my name and the short change ID into a nicer format (note, not the shortest ID):
[templates]
git_push_bookmark = '"baxter/change-" ++ change_id.short()'
That’s everything
I hope this has helped you on your Jujutsu journey. If you have any feedback or questions, you can find me on Bluesky at https://bsky.app/profile/baxter.sh.