◢ This post is also available in: 日本語
Jujutsu — the command is jj — is a new version control system
that works on a Git repository as it is. The repository underneath stays a Git repository, so it pushes
to GitHub, and a colleague who keeps using Git notices nothing. What changes is what you do at your own
keyboard.
This blog's repository is managed with jj. Having taken it through the ordinary routine — open a PR, merge it, repeat, every day — this is what that routine taught me about how it differs from Git and which commands to learn first.
Boiled down, the differences from Git are three.
git add and no git stash.jj undo takes back the last operation — not just a commit, but a
rebase or a bookmark move too.Each of these shows up in the real output below.
With mise, it is one line.
mise use -g jjWithout mise, use the package manager for your OS.
brew install jjwinget install jj-vcs.jjAsk it for its version to see that it is there.
❯ jj --version
jj 0.45.1-7c41cdeb16b6b321c64e789a966b6adf723816a5First, set the name and email that go on your commits. They mean the same as Git's user.name and
user.email.
jj config set --user user.name "JamBalaya56562"
jj config set --user user.email "jambalaya.pyoncafe@outlook.jp"jj config path --user tells you where that was written: %APPDATA%\jj\config.toml on Windows,
~/.config/jj/config.toml on macOS and Linux.
From here on, everything is what actually came back in an empty directory.
The first thing to do is jj git init. It is what it looks like, the jj version of git init; the
commands that deal with Git sit under jj git. Make an empty directory and run it inside.
❯ mkdir jjdemo && cd jjdemo
❯ jj git init
Initialized repo in "."
❯ ls -la
total 3332
drwxr-xr-x 1 Jam 197611 0 9月 13 15:18 .
drwxr-xr-x 1 Jam 197611 0 9月 13 15:18 ..
drwxr-xr-x 1 Jam 197611 0 9月 13 15:18 .git
drwxr-xr-x 1 Jam 197611 0 9月 13 15:18 .jjBoth .jj and .git are there. It is a fully valid Git repository too, and git commands work in it as
they are.
❯ git status
On branch main
No commits yet
nothing to commit (create/copy files and use "git add" to track)jj calls this layout colocated. It used to be an option; it is now the default.
Starting in an existing Git repository is the same command, run in that directory.
❯ cd existing-repo
❯ jj git init
Done importing changes from the underlying Git repo.
Setting the revset alias `trunk()` to `main@origin`.
Initialized repo in "."To clone, use jj git clone with the same URL you would give Git. Here it is cloning this blog's
repository.
❯ jj git clone https://github.com/JamBalaya56562/blog.git
Fetching into new repo in "C:\Users\Jam\AppData\Local\Temp\blog"
bookmark: jujutsu-article@origin [new] untracked
bookmark: main@origin [new] tracked
tag: v0.1.0@origin [new] tracked
tag: v0.2.0@origin [new] tracked
tag: v0.3.0@origin [new] tracked
tag: v0.4.0@origin [new] tracked
tag: v0.5.0@origin [new] tracked
tag: v0.5.1@origin [new] tracked
tag: v0.6.0@origin [new] tracked
tag: v0.7.0@origin [new] tracked
tag: v0.8.0@origin [new] tracked
tag: v0.9.0@origin [new] tracked
Setting the revset alias `trunk()` to `main@origin`.
Working copy (@) now at: xqluqkll 226c6559 (empty) (no description set)
Parent commit (@-) : wyykwmnq 61b72c08 main | docs(blog): show the mise articles' transcripts as terminal windows (#1232)
Added 301 files, modified 0 files, removed 0 files
❯ cd blog
❯ ls -la | head -12
total 3569
drwxr-xr-x 1 Jam 197611 0 9月 13 15:50 .
drwxr-xr-x 1 Jam 197611 0 9月 13 15:50 ..
drwxr-xr-x 1 Jam 197611 0 9月 13 15:50 .claude
drwxr-xr-x 1 Jam 197611 0 9月 13 15:50 .devcontainer
-rw-r--r-- 1 Jam 197611 290 9月 13 15:50 .dockerignore
-rw-r--r-- 1 Jam 197611 294 9月 13 15:50 .editorconfig
drwxr-xr-x 1 Jam 197611 0 9月 13 15:50 .git
-rw-r--r-- 1 Jam 197611 1865 9月 13 15:50 .gitattributes
drwxr-xr-x 1 Jam 197611 0 9月 13 15:50 .github
-rw-r--r-- 1 Jam 197611 567 9月 13 15:50 .gitignore
drwxr-xr-x 1 Jam 197611 0 9月 13 15:50 .jjThe remote's bookmarks and tags come in, the files are checked out, and .git sits next to .jj. The
last three lines say that, at the moment of cloning, an empty change was already created on top of
main and you are standing in it. That state — always being inside a change of your own — is what the
next section is about.
Look at the state right after creating the repository. jj status is the counterpart of git status:
it shows the change you are in and the file changes inside it.
❯ jj status
The working copy has no changes.
Working copy (@) : tturtmot 5c4a5295 (empty) (no description set)
Parent commit (@-): zzzzzzzz 00000000 (empty) (no description set)Nothing has been done, and there is already a change called tturtmot. That is what "the working copy
is a commit" means. @ is the symbol for the change you are in, close to Git's HEAD.
Create a file and run jj status again.
❯ echo Hello > README.md
❯ jj status
Working copy changes:
A README.md
Working copy (@) : tturtmot 378e52a9 (no description set)
Parent commit (@-): zzzzzzzz 00000000 (empty) (no description set)No git add was typed. Even so, README.md is now the content of change tturtmot, and the commit ID
has moved from 5c4a5295 to 378e52a9. The change ID is still tturtmot.
Give the change a description. The command that writes the commit message is jj describe, and -m
passes the text.
❯ jj describe -m "Add README"
Working copy (@) now at: tturtmot 4bb47a91 Add README
Parent commit (@-) : zzzzzzzz 00000000 (empty) (no description set)This is the closest thing to git commit -m, but it means something different. describe only writes
the description; nothing is "finalised". Edit a file after this and that edit keeps going into
tturtmot too.
When you move on to the next piece of work, create a new change. That is jj new: it creates an empty
change whose parent is the current one, and moves you into it.
❯ jj new
Working copy (@) now at: oklvxylu a2376dfd (empty) (no description set)
Parent commit (@-) : tturtmot 4bb47a91 Add READMEtturtmot is now the parent and the empty change oklvxylu is where you are. Edits from here on go
into oklvxylu.
jj logHistory is jj log. With no options it prints something close to git log --graph --oneline.
❯ jj log
@ oklvxylu jambalaya.pyoncafe@outlook.jp 2026-09-13 13:20:42 a2376dfd
│ (empty) (no description set)
○ tturtmot jambalaya.pyoncafe@outlook.jp 2026-09-13 13:20:42 4bb47a91
│ Add README
◆ zzzzzzzz root() 00000000@ — the change you are in○ — an ordinary change◆ — an immutable commit, one you cannot rewrite. zzzzzzzz at the bottom is the root of the
repository; commits already pushed to the remote's main show the same wayGit sees the same commits.
❯ git log --oneline
4bb47a9 Add README
❯ git status
Not currently on any branch.
nothing to commit, working tree cleanWhat git log shows is tturtmot's commit ID 4bb47a91; the empty change at @ is not there. jj
points Git's HEAD at the parent of the current change, so from Git's side this looks like a detached
HEAD with a clean working tree. Whatever is in @ is, to Git, an edit that has not been committed
yet.
The leading characters of each ID are shown in colour in a real terminal: that is how many you need to
type for the ID to be unique. The output in this article has no colour, but at home jj new t — one
letter — usually goes through.
Add a line to README.md and describe the change. jj new -m creates the next change with its
description in one go.
❯ echo World >> README.md
❯ jj describe -m "Greet the world"
❯ jj new -m "Add a licence note"
❯ echo MIT > LICENSENow suppose you want Hello in README.md to read Hello!. That fix belongs in the earlier "Greet the
world", not in "Add a licence note", which is where you are.
In Git this is stash, checkout, amend, and come back. In jj you make the fix where you are and send
just that file to the parent. The command is jj squash, which folds the content of the current
change into its parent. Change Hello to Hello! in an editor, then:
❯ jj squash README.md
Rebased 1 descendant commits.
Working copy (@) now at: ovvvnnor 5e6ce646 Add a licence note
Parent commit (@-) : oklvxylu 901a7c31 Greet the worldBecause a file was named, only README.md moved. LICENSE is still in the current change.
❯ jj log
@ ovvvnnor jambalaya.pyoncafe@outlook.jp 2026-09-13 13:21:06 5e6ce646
│ Add a licence note
○ oklvxylu jambalaya.pyoncafe@outlook.jp 2026-09-13 13:21:06 901a7c31
│ Greet the world
○ tturtmot jambalaya.pyoncafe@outlook.jp 2026-09-13 13:20:42 4bb47a91
│ Add README
◆ zzzzzzzz root() 00000000The commit ID of "Greet the world" went from bf873b9b to 901a7c31; its change ID oklvxylu did not
move. "Add a licence note", which sat on top of it, was rebased automatically (Rebased 1 descendant commits.). In Git terms that is an amend and a rebase at once, from one command.
Not the parent this time: suppose .gitignore should have been in "Add README", two changes back.
jj edit moves you straight into that change.
❯ jj edit tturtmot
❯ echo node_modules/ > .gitignore
❯ jj status
Rebased 2 descendant commits onto updated working copy.
Working copy changes:
A .gitignore
A README.md
Working copy (@) : tturtmot 2610b68f Add README
Parent commit (@-): zzzzzzzz 00000000 (empty) (no description set)The moment the file was placed, the content of tturtmot changed and its two descendants were rebased
again.
Go back to the tip when done.
❯ jj edit ovvvnnorjj undoSuppose the squash above went to the wrong place, two changes back. --into names the destination,
and that is where the mistake is.
❯ jj squash README.md --into tturtmot
Rebased 2 descendant commits.
Working copy (@) now at: ovvvnnor 7d39ffbe Add a licence note
Parent commit (@-) : oklvxylu c01a916f Greet the world
New conflicts appeared in 1 commits:
tturtmot 2c598158 (conflict) Add READMEA conflict. More on those later; in jj a conflict does not stop you working. Here the mistake has been noticed, so take it back.
❯ jj undo
Undid operation: 4f4da9c67ec4 (2026-09-13 13:20:54) squash commits into 4bb47a91b947ce2ef51167be9cc712df0afddd9a
Restored to operation: 3160922e4231 (2026-09-13 13:20:54) snapshot working copy
Working copy (@) now at: ovvvnnor 61975902 Add a licence note
Parent commit (@-) : oklvxylu bf873b9b Greet the world
Existing conflicts were resolved or abandoned from 1 commits.The squash, the rebase that came with it, and the conflict are gone together. What was undone is
recorded in jj op log.
❯ jj op log
4f4da9c67ec4 Jam@DESKTOP-U30T4EP default@ now, lasted 402 milliseconds
squash commits into 4bb47a91b947ce2ef51167be9cc712df0afddd9a
args: jj squash README.md --into tturtmot
3160922e4231 Jam@DESKTOP-U30T4EP default@ 1 second ago, lasted 103 milliseconds
snapshot working copy
args: jj diff --statEven read-only commands like jj status and jj diff leave a snapshot working copy operation. That is
what "your edits go into the commit the moment you run a command" actually is.
When an experiment is no longer wanted, jj abandon.
❯ jj new -m "An experiment that did not work out"
❯ echo x > scratch.txt
❯ jj abandon
Abandoned 1 commits:
nknpsvyu c1adb267 An experiment that did not work out
Working copy (@) now at: lrksrpsq 95f579b1 (empty) (no description set)
Parent commit (@-) : ovvvnnor ab228dc9 Add a licence note
Added 0 files, modified 0 files, removed 1 filesscratch.txt disappears from the directory too: what was the content of the change goes with the
change (and jj undo brings this back as well).
So far everything stayed on this machine. To push, you need the counterpart of a Git branch.
jj's equivalent of a Git branch is the bookmark. The difference is that a bookmark does not move
on its own. In Git, committing while on main advances main; a jj bookmark stays on the commit it
was put on, and you move it explicitly when you want it moved.
Put a bookmark on the commit you want to push, then push. jj bookmark create makes one, jj git push
sends it, and --bookmark says which.
❯ jj bookmark create main -r @
❯ jj git push --bookmark main
Changes to push to origin:
bookmark: main [add to ab228dc9c9ac]
Warning: The working-copy commit became immutable; a new commit has been created on top of it.
Working copy (@) now at: upwouywx 309ce578 (empty) (no description set)
Parent commit (@-) : ovvvnnor ab228dc9 main | Add a licence noteThe instant it was pushed, the change you were in became immutable (◆) and a new empty change was
created on top. A commit that has landed on the remote's main is no longer something to rewrite at
home — that is jj's judgement. It is safest to remember "rewritable" as meaning "not pushed yet".
After a colleague pushes to main, jj git fetch brings it in. Then jj log shows what that did.
❯ jj git fetch
❯ jj log
@ upwouywx jambalaya.pyoncafe@outlook.jp 2026-09-13 13:22:13 0be7156b
│ Add contributing guide
│ ◆ zumzptvl teammate@example.com 2026-09-13 13:22:11 main eccc91c7
├─╯ Add the copyright line
◆ ovvvnnor jambalaya.pyoncafe@outlook.jp 2026-09-13 13:21:46 ab228dc9
│ Add a licence note
~Unlike git pull, fetch moves nothing of yours. main has advanced and your change is still on the old
main. Moving it is jj rebase.
❯ jj rebase -d main
Rebased 1 commits to destination.
Working copy (@) now at: upwouywx 56f5616c Add contributing guide
Parent commit (@-) : zumzptvl eccc91c7 main | Add the copyright line-d is the destination. With nothing else given, the current change and its descendants are what
moves, so day to day this one line is enough.
Put a bookmark on your work and push it, and GitHub sees a branch.
❯ jj bookmark create contributing -r @
❯ jj git push --bookmark contributing
❯ gh pr create --head contributingOnce the PR is merged, jj git fetch advances main and the merged commits are abandoned automatically.
There is no local branch to delete.
Here a colleague and I have changed the same line of README.md, and I rebase.
❯ jj git fetch
❯ jj rebase -d main
Rebased 1 commits to destination.
Working copy (@) now at: znwtpvsx 224cb834 (conflict) Greet everyone
Parent commit (@-) : xsxqypzo 29608896 main | Add an exclamation mark
Warning: There are unresolved conflicts at these paths:
README.md 2-sided conflictA Git rebase would stop here and demand --continue or --abort. jj completes the rebase with the
conflict inside it, marks the change (conflict), and lets you carry on. You can run jj log or move to
another change; nothing is blocked.
Resolving it means opening the file and fixing it. Here is what is inside.
❯ cat -n README.md
1 Hello!
2 <<<<<<< conflict 1 of 1
3 %%%%%%% diff from: zumzptvl eccc91c7 "Add the copyright line" (parents of rebased revision)
4 \\\\\\\ to: xsxqypzo 29608896 "Add an exclamation mark" (rebase destination)
5 -World
6 +World!
7 +++++++ znwtpvsx 8046350f "Greet everyone" (rebased revision)
8 Everyone
9 >>>>>>> conflict 1 of 1 endsThe markers are shaped differently from Git's. The top half is a diff of what the other side changed; the
bottom half is your side's content after your change. Read both, replace it all with Everyone!, and
save.
❯ jj status
Working copy changes:
M README.md
Working copy (@) : znwtpvsx 93afbae1 Greet everyone
Parent commit (@-): xsxqypzo 29608896 main | Add an exclamation mark(conflict) is gone. There is no command that declares a conflict resolved: saving a file without
markers is enough, and the next snapshot takes it as resolved.
Here is what the output above showed, put in Git's terms. Three parts of the mental model get replaced.
Git has three stages: the working tree, the staging area, the commit. Edit, pick with git add, seal
with git commit. Of the three, jj has only the commit.
The files in the directory are always the content of the current commit as it is. That is why placing
README.md was enough to change the commit ID in jj status. Edit a file and, the moment you run any
jj command, the edit is taken into the commit — that is the snapshot working copy line in jj op log.
There is nothing corresponding to git add, and no git stash to park work in; to park something, you
move to another change with jj new. It is also why the files swapped when jj edit moved you.
"Always committed" sounds like a recipe for a messy history. It is not, because of the next property.
In jj, writing a commit's description, adding to its content, and reordering all happen to commits
that already exist. jj describe wrote a description without sealing anything, jj squash added to a
parent, jj edit went straight into a change two back. If in Git --amend and rebase -i feel like
expert tools, in jj they are the everyday operation.
To make that work, a commit carries two IDs.
tturtmot. Rewriting the content or rebasing does not change it.4bb47a91. This is the Git commit hash itself, and it changes with every
rewrite.When you think "add one line to that change from earlier", the change ID is what you name. After
jj squash, the commit ID of "Greet the world" went from bf873b9b to 901a7c31 and its change ID
stayed oklvxylu, so the same name kept working.
The exception: a commit that has been pushed to the remote's main becomes immutable (◆) and leaves
the set of things you fix. "Fixable afterwards" applies to what has not been shared yet.
jj records every operation performed on the repository — creating a commit, a rebase, moving a
bookmark, fetching from a remote. jj op log lists them and jj undo takes back the last one.
It resembles Git's reflog, but the unit is the operation, not the commit. Undoing the mistaken
squash removed the squash, the rebase that came with it, and the conflict, in one step. "The rebase
broke things" and "I abandoned the wrong change" are both the same jj undo.
In Git, a conflict during a rebase or merge stops the work. Until you resolve and --continue, or give
up with --abort, you can do nothing else.
In jj a conflict is a state recorded inside a commit. The rebase runs to the end, the affected change
is marked (conflict), and you can move to another change and come back later. Resolving it was just
fixing the file and leaving the rest to the next snapshot.
In a colocated repository, git commands work too. They do — and the conclusion from using it is that
write operations should not be done from the Git side.
Once, out of habit, I typed git stash. jj has no stash, so parking and restoring on the Git side sent
the files back through Git's autocrlf. On Windows, that turned the line endings of files I had not
touched into CRLF, and jj status reported every file as modified. Using only jj's commands, that path
is never taken.
| To do this | Git | jj |
|---|---|---|
| See the state | git status | jj status |
| See the history | git log --graph | jj log |
| See the diff | git diff | jj diff |
| Commit the changes | git add -A && git commit -m | jj commit -m (or jj describe -m → jj new) |
| Add to the last commit | git commit --amend | jj squash |
| Fix an older commit | git rebase -i | jj edit <change ID> |
| Park the work | git stash | not needed (jj new moves to another change) |
| Create a branch | git switch -c feat | jj bookmark create feat -r @ |
| Bring in the remote | git pull --rebase | jj git fetch → jj rebase -d main |
| Push | git push -u origin feat | jj git push --bookmark feat |
| Drop a commit | git reset --hard HEAD~ | jj abandon |
| Undo | git reflog → git reset | jj undo |
jj git init in an existing one and start today.git add and git stash are gone; jj describe names the change and
jj new moves on.jj undo. Rebases and conflicts come back operation by operation.jj rebase -d main. A conflict does not stop the work; fix the file and it is resolved.Sapling (sl), Meta's Git-compatible VCS — installing it, stacks, one PR per commit with sl pr submit, ReviewStack, and the sl web GUI
Declaring packages, dotfiles, tools and a finishing task in one config file, and bringing a new machine up to it with a single mise bootstrap
Writing environment variables that only apply inside a directory into mise.toml, and the traps in loading .env