TL;DR: jj (Jujutsu) is a Git-compatible version control system with some interesting ideas — automatic change tracking, universal undo, and a different take on history editing. It works on top of your existing Git repos, so you can try it without committing to anything.
Why I’m trying it Link to heading
The v0.39.0 release hit Hacker News and I finally decided to give it a proper go. I’ve been a happy Git user for years — worktrees, rebases, filter-branch rewrites, the lot. I’m not looking to replace Git, but jj takes some interesting approaches to things like history editing and conflict handling that I wanted to explore.
The nice thing is you don’t have to choose. jj reads and writes Git’s storage format natively, so you can use both side by side in the same repo. Honestly, I wouldn’t have even considered it if there wasn’t a clear coexistence story — I’m not about to migrate decades of muscle memory and tooling overnight. The fact that you can jj when you feel like it and git when you don’t is what made me willing to try.
Installing Link to heading
brew install jj
Shell completions get installed automatically with Homebrew. For other platforms, see the official install guide.
Setting up in an existing repo Link to heading
jj works in “colocated” mode alongside Git. You keep your .git directory and gain a .jj directory:
cd your-repo
jj git init
As of v0.39, --colocate is the default, so jj git init in an existing Git repo just works.
Your Git history, branches, and remotes are all preserved. You can still run git commands — jj and git share the same underlying storage.
The mental model shift Link to heading
The biggest difference: your working directory is always a commit. jj calls it the “working copy commit”. There’s no staging area, no git add, no “changes not staged for commit”. Every edit you make is automatically part of the current change.
This sounds chaotic, but it’s actually simpler. The workflow becomes:
- Make edits
- Describe the change (
jj describe -m "message") - Start a new change (
jj new)
That’s the equivalent of git add -A && git commit -m "message".
Daily commands Link to heading
See what’s going on Link to heading
jj log
This replaces git log --oneline --graph and git status in one view. It shows your change graph with the working copy highlighted, which changes are empty, and which have descriptions.
jj st # short status — what files changed
jj diff # what changed in the working copy
jj show # show the current change in full
Commit workflow Link to heading
# Edit files as normal — changes are tracked automatically
# Describe what you did
jj describe -m "feat: add user authentication"
# Start a new empty change on top
jj new
No staging step — everything in the working directory is part of the current change. Whether that’s better or worse than Git’s staging area is a matter of preference, but it does simplify the workflow.
Starting work on something new Link to heading
# New change based on main
jj new main
# New change based on a specific revision
jj new <revision>
This is like git checkout -b feature main, except you don’t need to name anything yet. Branches (called “bookmarks” in jj) are optional — you only create them when you need to push.
Bookmarks (branches) Link to heading
jj calls branches “bookmarks”. Unlike Git where branches are central to the workflow, jj bookmarks are just named pointers you attach when you need to push or reference a change by name.
# Create a bookmark pointing at the current change
jj bookmark create my-feature
# Move a bookmark to the current change
jj bookmark set my-feature
# List bookmarks
jj bookmark list
# Delete a bookmark
jj bookmark delete my-feature
Working with remotes Link to heading
# Fetch from origin
jj git fetch
# Push a bookmark
jj git push --bookmark my-feature
# Push tracking bookmarks pointing to the current change
jj git push
The killer features Link to heading
Undo anything Link to heading
This is the single best reason to use jj. Every operation is recorded and reversible:
jj undo # undo the last operation
jj op log # see all operations — like reflog but better
jj op restore <id> # jump back to any point in history
Botched a rebase? jj undo. Accidentally squashed the wrong change? jj undo. It even undoes undos.
Edit any commit directly Link to heading
Git’s interactive rebase works great for this, but jj offers a more direct approach — you just jump to the commit:
jj log # find the revision you want to edit
jj edit <revision> # working copy now IS that revision
# make your edits — descendants are rebased automatically as the commit changes
jj new # start a fresh change when you're done
Split and squash Link to heading
# Split the current change into two
jj split
# Squash the current change into its parent
jj squash
# Squash a specific change into its parent
jj squash -r <revision>
jj split opens your configured diff editor where you choose which changes stay in the first commit. The rest go into a new child commit. Similar to git add -p, but the split happens at the commit level rather than the staging level.
Arrange (new in v0.39) Link to heading
New in v0.39 — jj arrange opens a TUI where you can reorder and abandon revisions visually:
jj arrange
It shows your stack of changes and lets you reorder or abandon them interactively. Think of it as git rebase -i but with a TUI instead of a text editor. It’s jj’s take on commit reordering, and it’s quite nice.
Conflicts are first-class Link to heading
One of jj’s more interesting design choices: conflicts are data, not blockers. You can commit a conflicted state, switch to another change, come back later, and resolve it then. The conflict markers are stored in the commit itself.
jj rebase -d main # might create conflicts
jj log # conflicted changes are marked
# resolve when you're ready, or keep working on something else
No stash needed Link to heading
Want to quickly work on something else? Just start a new change:
jj new main # start fresh work from main
# do your thing
jj edit <previous> # go back to what you were doing
Your in-progress work is a commit. It’s always saved. A different approach to the same problem git stash solves.
The PR workflow Link to heading
Here’s what a typical feature workflow looks like:
jj git fetch # sync with remote
jj new main # new change based on main
# ... write code ...
jj describe -m "feat: add rate limiting" # describe the change
jj bookmark create feat/rate-limiting # create a bookmark to push
jj git push --bookmark feat/rate-limiting # push to remote
# create PR via gh cli or GitHub UI
Need to update the PR after review?
jj git fetch
jj edit <revision> # jump to the change
# make edits — they're applied directly
jj rebase -d main # rebase onto latest main if needed
jj git push --bookmark feat/rate-limiting # push the update
jj pushes the current state of the bookmark — similar to amending and force-pushing in Git, but it’s all one step.
Git → jj command mapping Link to heading
| Git | jj | Notes |
|---|---|---|
git status | jj st | |
git log --oneline --graph | jj log | Also shows working copy status |
git diff | jj diff | |
git diff --staged | N/A | No staging area |
git log -p | jj log -p | Patch output for each change |
git add . | N/A | No staging area — all changes are part of the working copy commit |
git commit -m "msg" | jj describe -m "msg" && jj new | Or jj commit -m "msg" as a shorthand |
git commit --amend | jj describe / jj squash | describe edits message, squash folds in changes |
git checkout -b feature | jj new && jj bookmark create feature | Bookmark is optional |
git switch main | jj edit main | Sets working copy to the main revision |
git stash / git stash pop | jj new main / jj edit <previous> | Start fresh elsewhere, then return |
git rebase -i | jj rebase / jj squash / jj split | Each operation is separate |
git reset --hard | jj undo / jj op restore | undo reverts last op, op restore jumps to any point |
git cherry-pick <X> | jj duplicate <X> | |
git fetch | jj git fetch | |
git pull | jj git fetch | Then jj rebase -d main if needed |
git push | jj git push | Pushes tracking bookmarks pointing to @ |
git push --force | jj git push | jj always pushes the current state; no force flag needed |
git push --force-with-lease | jj git push | This is jj’s default behaviour — refuses if remote changed since last fetch |
git reflog | jj op log | Operations, not just ref changes |
Rough edges Link to heading
It’s not all perfect:
- GitHub tooling assumes Git.
ghCLI, GitHub Actions, CI — all speak Git. You’ll still usejj git pushand interact with the Git side for anything remote-facing. - Learning curve. The mental model is different enough that you’ll stumble for a week or two. The “working copy is a commit” thing takes getting used to.
- Colocated mode quirks. Occasionally jj and git get out of sync. Running
jj git importorjj git exportfixes it, but it’s a paper cut. - Fewer integrations. IDE support is growing but still behind Git. VS Code has an extension, but it’s not as mature as GitLens.
Tips for getting started Link to heading
- Start colocated. Don’t go pure-jj until you’re comfortable. Colocated mode lets you fall back to Git anytime.
- Use
jj undoliberally. It’s your safety net. Try things, undo if they don’t work. - Don’t force Git muscle memory. “How do I stage files in jj?” is the wrong question. You don’t — everything is always tracked.
jj logis your home screen. Run it constantly. It shows you everything.
Further reading Link to heading
- jj official tutorial — the best place to start after this post
- jj GitHub repo — docs, issues, and discussion
- Steve Klabnik’s “jj init” — another walkthrough from a different angle