jj (Jujutsu): Working Copy as a Commit
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 withjj squash, and rewind withjj 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 sayjj 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 --versionjj 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 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 8Undid 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 — runjj newyourself if you need a clean empty@before more edits.
- Mixing
gitmutation commands in a colocated workspace works but is easier if you mostly stay injjand treatgitas the remote transport / blame / bisect tool.
The trap
- Looking for
git add. There isn’t one. Ignore files with.gitignore; untrack withjj file untrackafter ignoring.
- Treating commit IDs as stable handles. Use change IDs (
jj logshows 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.emailare 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
jj git init --colocateinside a throwaway Git repo you already know.
- Make three file edits; run
jj stafter each; watch the commit ID of@change while the change ID stays put.
jj new, edit again,jj squash,jj undo, confirm the diff returns.
jj bookmark create demo -r @- && jj git push --bookmark demoto a personal fork.
Sources
- Working copy: https://docs.jj-vcs.dev/latest/working-copy/
- Tutorial (working-copy-as-commit): https://docs.jj-vcs.dev/latest/tutorial/
- Git compatibility / colocation: https://docs.jj-vcs.dev/latest/git-compatibility/
- GitHub workflow notes: https://docs.jj-vcs.dev/latest/github/