jj (Jujutsu): Working Copy as a Commit

Authors

K19G

grok-bot

Updated

October 30, 2026

jj (Jujutsu): Working Copy as a Commit

Git teaches a three-layer story: working tree, index/staging area, commits. Most day-to-day anxiety — “did I stage that?”, “is this amend or a new commit?”, “how do I undo the last amend?” — lives in the gaps between those layers. Jujutsu (jj) collapses the gap: the working copy is a real commit (@). Every edit is snapshotted into that commit the next time you run a jj command. There is no staging area to reconcile. Undo is an operation-log rewind. This chapter builds that mental model on a colocated Git repo, runs the handful of commands you need every day (jj new, jj describe, jj squash, jj undo), and shows how remotes still speak Git.

Verified against jj 0.46.0 on x86_64 Linux (2026-10-09). Primary docs: https://docs.jj-vcs.dev/latest/.

Learning goals

By the end of this chapter you can:

  • Explain why @ is a commit ID that changes as you type
  • Distinguish change ID (stable) from commit ID (content-addressed, rewritten)
  • Colocate jj on an existing Git checkout with jj git init
  • Start a fresh change (jj new), describe it, fold WIP with jj squash, and rewind with jj undo
  • Push to a Git remote without abandoning Git-speaking collaborators

Thesis

In Git, a commit is what you produce when you are done. In jj, a commit is what you are editing. The working copy is not a waiting room — it is revision @, rewritten in place until you say jj new.

That single flip removes the index. Files you create are tracked automatically (subject to .gitignore and snapshot.auto-track). Files you delete are untracked automatically. jj st both reports and snapshots.

Mental model

Git mental layers              jj mental layers
─────────────────              ────────────────
working tree  ─┐
staging index ─┼─→ commit      working-copy commit @  ←── you edit files here
               ┘               parent commit      @-
                               operation log      (every jj command)
Term Meaning
@ Working-copy commit — rewritten on almost every jj command when files changed
@- Parent of @ (usually the last “finished” change)
Change ID Stable identifier for a logical change (survives rewrites); prefer this in daily use
Commit ID Git-compatible content hash; changes whenever the snapshot changes
Operation One recorded jj action; jj undo rewinds the latest

Case 0 — install and version

# jj 0.46.0 — verify yours
jj --version
jj 0.46.0-7d382314f9420b4b8d932245b49c99492c812b9f

Configure identity once (required before any push to a Git remote):

jj config set --user user.name "Your Name"
jj config set --user user.email "you@example.com"

Case 1 — colocated repo: Git and jj share one working tree

# demo.sh — colocated init and first snapshot
rm -rf /tmp/jj-wc-demo && mkdir /tmp/jj-wc-demo && cd /tmp/jj-wc-demo
jj git init --colocate
echo 'hello' > README.md
jj status
Initialized repo in "."
Working copy changes:
A README.md
Working copy  (@) : rukslwvq 762e295b (no description set)
Parent commit (@-): zzzzzzzz 00000000 (empty) (no description set)

What just happened:

  1. jj git init --colocate created both .jj/ and .git/ in the same directory (the default for Git-backed workspaces).
  2. Adding README.md and running jj status snapshotted the file into @. There was no git add.
  3. @ already has a change ID (rukslwvq…) and a commit ID (762e295b…). The commit ID will change on the next rewrite; the change ID will not.

On an existing Git clone the same command works from inside the checkout: jj git init (colocate is the default). jj imports Git refs and keeps exporting on every command so git log / git status stay coherent — usually with Git in a detached-HEAD-looking state, which is normal for colocated workflows.

Case 2 — describe, then jj new when the change is “done enough”

jj describe -m 'add README'
jj log -r 'all()' --limit 5
jj new
jj log -r '@ | @-' 
Working copy  (@) now at: rukslwvq c4ec738f add README
Parent commit (@-)      : zzzzzzzz 00000000 (empty) (no description set)
@  rukslwvq … c4ec738f
│  add README
◆  zzzzzzzz root() 00000000
Working copy  (@) now at: pslozopm e8dc9b58 (empty) (no description set)
Parent commit (@-)      : rukslwvq c4ec738f add README

jj describe rewrote @ (new commit ID, same change ID, now with a message). jj new created an empty successor and moved @ there. The finished work sits at @-. That is the jj equivalent of “commit what I staged and start fresh” — except nothing was staged.

Case 3 — keep editing, then squash (or edit)

echo 'world' >> README.md
jj diff
jj squash -m 'extend README'
Modified regular file README.md:
   1    1: hello
        2: world
Working copy  (@) now at: lyxlzlzu … (empty) (no description set)
Parent commit (@-)      : rukslwvq 66cd1710 extend README

jj squash moves the changes from @ into its parent (default) and leaves you on a new empty working-copy commit. It is the boring “amend the previous change with my current WIP” path. The alternative is jj edit <change> to resume amending an older change directly in @ — useful when the change is still open; squash-then-diff is clearer when you want a reviewable delta first.

Case 4 — undo is not reflog archaeology

jj undo
jj log -r 'all()' --limit 8
Undid operation: … squash commits into …
Working copy  (@) now at: pslozopm 241153d1 (no description set)
Parent commit (@-)      : rukslwvq c4ec738f add README
@  pslozopm … 241153d1
│  (no description set)
○  rukslwvq … c4ec738f
│  add README
◆  zzzzzzzz root() 00000000

Every jj command records an operation. jj undo rewinds the last one (here: the squash). The working-copy files and the commit graph return to the prior operation. For deeper recovery: jj op log, then jj op undo / restore by operation id. This is why “I amended the wrong thing” stops being an emergency.

Interop with Git remotes

Colocated repos still push with Git semantics:

# Create a bookmark (jj's branch-like movable ref) on the finished change
jj bookmark create my-feature -r @-
jj git push --bookmark my-feature
# Or let jj mint a name from a change id:
# jj git push --change @-

Notes for Git teammates:

  • They clone and work with plain Git; the .jj/ directory is yours.
  • Prefer bookmarks for anything that must be named on the remote.
  • After jj git push, if @ became immutable, jj creates a fresh working-copy commit on top — run jj new yourself if you need a clean empty @ before more edits.
  • Mixing git mutation commands in a colocated workspace works but is easier if you mostly stay in jj and treat git as the remote transport / blame / bisect tool.

The trap

  • Looking for git add. There isn’t one. Ignore files with .gitignore; untrack with jj file untrack after ignoring.
  • Treating commit IDs as stable handles. Use change IDs (jj log shows them first). Commit IDs churn every snapshot.
  • Pushing @ when it is empty WIP. Push @- or an explicit bookmark on the finished change (jj git push --change @-).
  • Expecting Git’s staging partial-hunk workflow by default. jj snapshots the whole working copy; use jj split, jj squash -i, or file-level tools when you need to carve hunks.
  • Forgetting identity config. Until user.name / user.email are set, commits are empty-identity and remotes will reject them.

The boring rule

Edit freely. Let @ absorb the snapshot. When the change is ready enough, jj describe + jj new (or jj squash into the parent). Wrong move? jj undo. Speak Git at the boundary with bookmarks and jj git push.

Try this

  1. jj git init --colocate inside a throwaway Git repo you already know.
  2. Make three file edits; run jj st after each; watch the commit ID of @ change while the change ID stays put.
  3. jj new, edit again, jj squash, jj undo, confirm the diff returns.
  4. jj bookmark create demo -r @- && jj git push --bookmark demo to a personal fork.

Sources