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:

  1. Make edits
  2. Describe the change (jj describe -m "message")
  3. 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

GitjjNotes
git statusjj st
git log --oneline --graphjj logAlso shows working copy status
git diffjj diff
git diff --stagedN/ANo staging area
git log -pjj log -pPatch output for each change
git add .N/ANo staging area — all changes are part of the working copy commit
git commit -m "msg"jj describe -m "msg" && jj newOr jj commit -m "msg" as a shorthand
git commit --amendjj describe / jj squashdescribe edits message, squash folds in changes
git checkout -b featurejj new && jj bookmark create featureBookmark is optional
git switch mainjj edit mainSets working copy to the main revision
git stash / git stash popjj new main / jj edit <previous>Start fresh elsewhere, then return
git rebase -ijj rebase / jj squash / jj splitEach operation is separate
git reset --hardjj undo / jj op restoreundo reverts last op, op restore jumps to any point
git cherry-pick <X>jj duplicate <X>
git fetchjj git fetch
git pulljj git fetchThen jj rebase -d main if needed
git pushjj git pushPushes tracking bookmarks pointing to @
git push --forcejj git pushjj always pushes the current state; no force flag needed
git push --force-with-leasejj git pushThis is jj’s default behaviour — refuses if remote changed since last fetch
git reflogjj op logOperations, not just ref changes

Rough edges Link to heading

It’s not all perfect:

  • GitHub tooling assumes Git. gh CLI, GitHub Actions, CI — all speak Git. You’ll still use jj git push and 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 import or jj git export fixes 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

  1. Start colocated. Don’t go pure-jj until you’re comfortable. Colocated mode lets you fall back to Git anytime.
  2. Use jj undo liberally. It’s your safety net. Try things, undo if they don’t work.
  3. Don’t force Git muscle memory. “How do I stage files in jj?” is the wrong question. You don’t — everything is always tracked.
  4. jj log is your home screen. Run it constantly. It shows you everything.

Further reading Link to heading