Skip to content

Git → Jujutsu, step by step

If you know Git well, jj will feel familiar in some places and upside-down in others. This tutorial walks the path one workflow at a time, always showing what you’d do in Git next to what you do in jj — and, where it helps, the equivalent move in wyrm.

You don’t need to memorize new commands up front. Start with the mental shift, then follow the phases in order.

A handful of ideas explain almost every difference you’ll hit:

  • There is no staging area. Every file change is immediately part of the current commit.
  • The working copy is a commit — marked @ in the log. Every jj command auto-snapshots your files into it.
  • Commits are cheap and disposable. You don’t squash to “clean up before pushing”; you squash when you want to, and jj handles the cascade.
  • Rewriting history is the normal workflow, not a dangerous escape hatch.
  • Anonymous branches are normal. Git’s scary “detached HEAD” is just everyday jj.
  • Bookmarks are shipping labels, not workspaces. You don’t work “on a bookmark” — you work on commits, then label one when it’s time to push.

In wyrm, the top row of the log graph is @. As you edit files, that row updates live — there’s no separate “uncommitted changes” panel to manage.

In Git you create a branch, edit, stage, then commit. In jj you just start editing.

In Git you…

Terminal window
# 1. You're on develop
git checkout -b feature/new-ui # branch BEFORE you work
# 2. Edit files (now in a dirty, unsaved state)
# 3. Stage and commit
git add src/ui/*.ts && git commit -m "feat: new ui"

In jj you…

Terminal window
# 1. You're on develop (same)
# 2. Just start editing. That's it.
# No branch. No staging. Every save is captured in @.
# 3. Name the work when you're ready:
jj describe -m "feat: new ui"
# 4. (Optional, only when pushing) add a bookmark:
jj bookmark create feature/new-ui

Your work is safe the moment you save a file. No branch is needed until you push, and there’s no “staging” — everything lives in @.

You have an old commit with good changes, but develop has moved 20 commits ahead. How do you catch up?

The trick: order doesn’t matter. Rebase first or edit first — jj is fine either way, and conflicts won’t block you.

Option A — rebase first, edit after (most common):

Terminal window
# Move your old commit onto the latest develop:
jj rebase -r old_change_id -d develop
# Then continue working on it:
jj edit old_change_id
# Now @ IS that commit; edits amend it directly.
# The change ID stays the same even after the rebase.

Option B — edit first, rebase later:

Terminal window
jj edit old_change_id # jump to it right now
# ...work...
jj rebase -r @ -d develop # rebase when you're ready

In Git this is “checkout → rebase → resolve conflicts → continue”, and a conflict freezes you mid-rebase. In jj you can rebase, see a conflict, go do something else, and come back to resolve later — the conflict is just a state the commit is in.

Double-clicked an old commit and got “Revision is immutable”? In Git you can check out anywhere, so this feels like jj getting in your way. It isn’t.

jj protects commits that are already public — shared with the world:

Immutable (◆)Mutable (●)
Commits on main@originYour working copy @
Commits on any *@originLocal-only commits
Tagged commitsAnything you haven’t pushed

Why it’s a good thing: in Git you can git push --force over main or amend a shared commit and wreck history others depend on — nothing stops you. jj says “this is already public; you can’t rewrite what the team relies on.” That kills an entire category of “oh no” moments.

What to do instead — don’t edit the immutable commit, build a new one on top:

Terminal window
jj new immutable_change_id

Your new @ carries all the files from that commit; you edit freely, and the original is untouched.

Mutable commit (●)Immutable commit (◆)
jj edit change_idjj new change_id
@ moves to that commitCreates a new child commit
Edits amend it in place@ is the new child

Quick reference: new vs edit vs describe vs commit

Section titled “Quick reference: new vs edit vs describe vs commit”
CommandWhat it doesClosest Git moveWhen to use
jj newNew empty child commit; move @ theregit checkout -b”Done with this, on to the next thing.”
jj editMove @ onto an existing mutable commitgit rebase -i → editGo back to fix an earlier commit.
jj describeSet/change @’s messagegit commit --amend -mName what you’re working on.
jj commitdescribe + new in onegit commit -mFinish this work and start the next.

You edited 10 files. Only 7 belong in this commit; 3 are test junk. Some files even mix real changes with throwaway lines.

Git is opt-in: you stage what you want and discard the rest. jj is opt-out: everything is already in @, so you remove what you don’t want. Same destination, opposite direction.

Method 1 — jj restore (discard whole files):

Terminal window
# Revert these files to the parent's version:
jj restore test-setup.ts dev-nav-hack.svelte .env.test
# @ now has only the 7 real files. Name it:
jj describe -m "fix: navigation and auth"

jj restore is the equivalent of “Discard Changes” — it resets a file to its parent.

Method 2 — jj split (keep some, separate the rest):

Terminal window
jj split # pick what goes into the FIRST commit
# Result:
# ○ @ — leftover (3 test files)
# ● fix: navigation and auth (7 real files)
# ◆ develop
jj bookmark create nav-fix -r @-
jj git push --bookmark nav-fix
jj abandon @ # throw away the test-junk commit

Mixed files (hunk level) — when one file has 10 real lines and 5 test hacks:

Terminal window
jj split -i

This opens an interactive diff of every changed hunk; you choose which hunks land in the first commit and which stay in the second. It’s jj’s git add -p.

Test junk spread across several commits? You don’t need one split per commit:

Terminal window
# Option A — squash everything, then split once:
jj squash --from "commit1::commit5" --into commit1
jj split -i -r that_commit
jj abandon junk_commit
Terminal window
# Option B — jj absorb:
# 1. In @, simply UNDO the test changes (delete the files,
# remove the test lines). jj snapshots those "undo" edits.
# 2. Route them home automatically:
jj absorb

jj absorb looks at each undo hunk in @, finds the ancestor commit that added those lines, and folds the fix into that commit — no interactive rebase, one command. More on it in Step 7.

In Git you…

Terminal window
git rebase -i HEAD~5
# Editor opens; change "pick" → "squash" on 4 lines,
# write the combined message, save.

In jj you…

Terminal window
# One at a time, into the parent:
jj squash -r commit5
jj squash -r commit4
# …or a whole range in one command:
jj squash --from "commit1::commit5" --into commit1

Descendants auto-rebase after every squash. No editor, no --continue, no ceremony.

In Git you…

Terminal window
git checkout feature/new-ui
git fetch origin
git rebase origin/develop
# On conflict: STOP, fix files, git add ., git rebase --continue
# …repeated for EACH conflicting commit.

In jj you…

Terminal window
jj git fetch
jj rebase -r your_change -d develop
# On conflict: nothing stops. The commit is just marked CONFLICT.
jj edit your_change # fix the markers when you're ready — that's it.

Which commits move depends on the flag:

FlagWhat it movesGit analogue
-r (revision)Only this one commit; children stay put(no direct equivalent)
-s (source)This commit and all its descendantsgit rebase --onto
-b (branch)Everything since it diverged from the destinationgit rebase develop

Resolving one is just editing a file:

Terminal window
jj edit conflicted_change
# Open the file. jj's markers show the base plus what each
# side intended, not two opaque full versions:
#
# <<<<<<< Conflict 1 of 1
# %%%%%%% diff from base to side #1
# context
# -old line
# +your change
# +++++++ side #2
# their change
# >>>>>>> Conflict 1 of 1 ends
#
# Fix it, save. jj snapshots. Resolved. No git add. No --continue.

Because the markers describe intent (base + each diff) rather than two final blobs, conflicts are usually easier to reason about. See Conflict resolution for the wyrm workflow.

A complete feature, end to end:

  1. Start working — jj describe -m "feat: new navigation UI"
  2. Snapshot milestones as you go — jj commit -m "wip: nav routing done"
  3. Clean up into one commit — jj squash --from "first::last" --into first
  4. Drop test junk — jj restore test-setup.ts (or jj split -i)
  5. Rebase onto latest develop — jj git fetch && jj rebase -r feature -d develop
  6. Label and push — jj bookmark create feature/x && jj git push --bookmark feature/x --allow-new
  7. Open the PR, get review
  8. Address feedback — jj edit feature_change → make changes → jj git push --bookmark feature/x

No fixup commits, no second squash. Edit the commit, push — jj force-pushes safely on its own.

A jj bookmark is not a Git branch. A Git branch follows HEAD: commit, and it moves with you — you’re always “on” a branch. A jj bookmark is a sticky note on a specific commit. It does not move when you make new commits, and you’re never “on” it. You’re always on @.

Git: write the address on the box before packing it. jj: pack the box, then stick a shipping label on when you’re ready to send.

So you work first and label later. Three common situations when an old bookmark already exists:

A — the old work is obsolete (your new commits replace it):

Terminal window
jj bookmark set feature/nav-redesign -r commit3
jj git push --bookmark feature/nav-redesign # safe force-push

B — keep the old work and build on it:

Terminal window
jj rebase -s commit1 -d feature/nav-redesign # your commits on top
jj bookmark set feature/nav-redesign -r commit3
jj git push --bookmark feature/nav-redesign

C — merge old + new work (different files, want both):

Terminal window
jj squash --from old_work_change_id --into commit1
jj bookmark set feature/nav-redesign -r commit3
jj git push --bookmark feature/nav-redesign
BehaviorGit branchjj bookmark
Moves on commit?Yes — auto-follows HEADNo — stays put; move it manually
Required to work?Yes — always “on” oneNo — work without any
Required to push?YesYes — a push needs a name
”Current” one?Yes — HEAD points to itNo concept; @ is always current
Follows a rebase?SometimesYes — tracks the change ID through rewrites
Detached-HEAD fear?YesNone — that’s just normal
Force pushManual, scaryAutomatic, safe (with-lease)

Because bookmarks track the change ID (not the commit SHA), they survive rewrites: after a rebase you just push, no manual force-push needed to update the remote.

Git has several push shapes (-u first time, plain push, --force-with-lease, --delete). jj funnels them through one command:

Terminal window
jj git push --bookmark my-thing --allow-new # first push
jj git push --bookmark my-thing # every push after
jj git push --all # all local bookmarks
jj git push --delete old-bookmark # delete a remote bookmark

There is no --force flag. jj always behaves like --force-with-lease: if someone pushed to that bookmark since your last fetch, it refuses and tells you to fetch first. You literally cannot clobber a teammate’s work by accident.

Terminal window
jj git fetch # downloads remote state; does NOT merge,
# rebase, or touch your working copy.
# Then YOU decide:
jj rebase -d main@origin # rebase your work onto the update — or don't.

There is no jj pull. Fetching and rebasing are always separate, so you’ll never get an unexpected merge commit.

Address feedback by editing the commit in place and pushing again — no fixup commits:

Terminal window
jj edit feature_change
# ...make the requested changes...
jj git push --bookmark feature/x # auto force-push, safely

A few jj capabilities that have no clean Git equivalent.

jj absorb — auto-route fixes to the right commits

Section titled “jj absorb — auto-route fixes to the right commits”

Reviewer says “line 42 in commit 2 is wrong, and line 88 in commit 4 has a typo.” In Git that’s a git commit --fixup per spot plus git rebase -i --autosquash.

Terminal window
# 1. In @, just fix BOTH lines wherever they live now.
# jj snapshots; @ holds both fixes.
# 2. Route them home:
jj absorb

jj finds, for each changed line, the most recent ancestor that touched it, moves the fix there, and rebases the descendants. Both bugs land in the right commits and @ is empty again.

No stash — every state is already a commit

Section titled “No stash — every state is already a commit”

Boss says “drop everything, hotfix this.” In Git that’s git stash / branch / fix / git stash pop and a prayer.

Terminal window
jj new develop -m "hotfix: prod is down"
# Your previous work is still there as a sibling commit.
# Fix, push, then return:
jj edit feature_a_id # right back where you left off

There’s no stash because there’s nothing to stash — your work was never uncommitted.

Ship four small, reviewable PRs that build on each other instead of one giant one:

● commit 4 ← bookmark feature/tests
● commit 3 ← bookmark feature/ui
● commit 2 ← bookmark feature/api
● commit 1 ← bookmark feature/auth-refactor
◆ develop

In Git, fixing a bug in the base PR means rebasing and force-pushing every PR above it. In jj, edit the base commit, let all descendants auto-rebase, and update every PR at once:

Terminal window
jj edit feature/auth-refactor # fix the base
# ...descendants auto-rebase...
jj git push --all # all four PRs update together

Work lifecycle

Goaljjgit
Statusjj statusgit status
Logjj loggit log --graph
Diff current workjj diffgit diff HEAD
Start new commitjj newgit checkout -b
Set commit messagejj describe -m "…"git commit --amend -m
Commit and move onjj commit -m "…"git commit -m

Editing commits

Goaljjgit
Edit an old mutable commitjj edit <rev>git rebase -i (edit)
Child of any commitjj new <rev>git checkout -b
Fold into parentjj squashfixup + autosquash
Interactive foldjj squash -igit add -p + amend
Split a commitjj splitgit rebase -i (split)
Auto-route fixesjj absorb(none)
Discard a commitjj abandongit reset --hard HEAD~1
Restore a filejj restore <file>git checkout HEAD -- <file>

Remote operations

Goaljjgit
Fetchjj git fetchgit fetch
Pushjj git push --bookmark <name>git push
First pushjj git push --bookmark <name> --allow-newgit push -u
Force push (auto)jj git push --bookmark <name>git push --force-with-lease
Push alljj git push --allgit push --all
Delete remotejj git push --delete <name>git push --delete

Bookmarks

Goaljjgit
Createjj bookmark create <name>git branch <name>
Movejj bookmark set <name> -r <rev>git branch -f <name>
Deletejj bookmark delete <name>git branch -d <name>
Listjj bookmark listgit branch -a

Safety & selectors

Goaljjgit
Undo anythingjj undogit reflog + reset
Operation historyjj op loggit reflog
”Stash” workjj new @- then jj edit backgit stash
RevsetMeaning
@Working copy
@-Parent of the working copy
trunk()Main branch on origin
trunk()..@Your local work not yet in trunk
mine()Your own commits
conflicts()All conflicted commits
bookmarks()All bookmarked commits
description("fix")Commits with “fix” in the message
  • jj Basics — change IDs, the working copy, and the operation log in depth.
  • Log Graph — navigate and filter history in wyrm.
  • Conflict Resolution — resolve conflicts visually with your merge tool.
  • Push Preview — review exactly what will land on the remote before you push.