| name | jj-concepts |
| description | Background knowledge about jujutsu (jj) version control for Claude |
Jujutsu (jj) Version Control Concepts
This project uses jujutsu (jj) instead of git. jj is a Git-compatible VCS with a different mental model. Always use jj commands, never git.
Session Setup
At the start of any jj session, run:
jj config list
This reveals user-defined aliases, custom revset aliases, preferred diff tools, and default commands that change how jj behaves. Key things to look for:
aliases.* — user shortcuts (e.g. jj l, jj tug). jj tug is a common community alias that moves the closest bookmark to @-. These may approximate newer built-ins — prefer built-in commands when both exist (e.g. jj bookmark advance supersedes jj tug).
ui.default-command — what jj with no args shows. The value ["log", "-r", "(main..@):: | (main..@)-"] (commits between main and @, plus siblings) is what jj itself suggests in its output, so it's common.
ui.diff-formatter — may use an external tool like difft instead of built-in
ui.paginate — if "never", the --no-pager flag is redundant
Colocated repos (jj alongside git)
jj is Git-compatible: a repo can be colocated, meaning .jj/ and .git/ live in the same directory and operate on the same commits. This is the normal setup when adopting jj on an existing project.
- Created with
jj git init --colocate in an existing git repo (or jj git clone of a git remote). Without --colocate, jj keeps its own internal git store and there is no top-level .git/.
- In a colocated repo, jj auto-imports git refs on each command and auto-exports bookmark changes back to git, so each git branch shows up as a jj bookmark of the same name (see the terminology section).
- Consequence for this session: you may still see a
.git/ directory and git tooling (CI, hooks, editors that read git state). That does not mean you should drive with git — the block-git.sh hook still routes you to jj. Git just observes the state jj writes.
Self-Service Help (use this before guessing)
jj ships authoritative docs for its major subsystems. Prefer these over training-data recall when the question is about syntax or semantics:
jj help <subcommand>
jj help -k <topic>
Available -k topics and when to consult each:
| Topic | When to read |
|---|
tutorial | First exposure to jj concepts; conflict workflow walkthrough |
revsets | Building any non-trivial -r expression beyond @, @-, trunk() |
templates | Customizing jj log / jj show output formatting (-T) |
filesets | Path/glob expressions used with file-aware commands |
bookmarks | Local vs remote tracking, push semantics, divergence |
glossary | Resolving unfamiliar terminology (e.g. "abandoned", "divergent", "hidden") |
config | Settings keys, config file locations, scopes |
Run the relevant jj help -k topic when in doubt rather than guessing at flags or syntax.
Terminology: "bookmark" is not "branch"
jj's vocabulary is deliberate. Use it precisely both in commands and when talking to the user — sloppy terminology produces sloppy commands.
- Bookmark — a named pointer to a commit. This is what git calls a "branch ref". Bookmarks are what you push, list, delete, and advance.
- Branch — in jj's own usage, refers to the shape of the commit graph: a chain of commits, whether or not anything is named. It is a topology word, not a name.
- Anonymous branch — a chain of commits with no bookmark on it or any descendant. jj keeps these around (they appear in
jj log) until explicitly abandoned. This is a normal, named concept in jj — not a warning sign.
Practical consequences:
- When the user says "create a branch", they almost certainly mean a bookmark. Confirm by reaching for
jj bookmark create / jj bookmark set, and use the word "bookmark" in the response.
- "Rebase this branch onto main" is fine —
branch here is a topology, and jj rebase -b <rev> -o main handles it.
- Never write
jj branch … in a command. There is no such subcommand; the verb is jj bookmark. A user who types jj branch is reaching for git muscle memory.
- No "current bookmark." Unlike git's HEAD-attached-to-branch model,
@ is not "on" a bookmark. New commits do not advance any bookmark. You move bookmarks explicitly with jj bookmark set / move / advance, or implicitly via jj git push after a rewrite (rewrites are followed by change ID).
- In a colocated workspace (jj alongside
.git/), each git branch corresponds to a jj bookmark of the same name. That's an implementation bridge, not a conceptual equivalence — keep using "bookmark" when describing user-facing operations.
Mental Model
The working copy is always a commit
@ is a real commit, not a staging area. Every file edit automatically amends @. No git add needed — just edit files.
Change IDs are stable; commit IDs are not
Every commit has two identifiers: a change ID (e.g. kzomqsrt) that stays the same across rewrites, and a commit ID (hash) that changes. Always use change IDs when targeting specific commits — they survive rebases, amends, and squashes. The tutorial says: "We will generally prefer change IDs because they stay the same when the commit is rewritten."
History is freely rewritable
jj edit <change-id> makes any past commit the working copy. Descendants auto-rebase. You can fix a bug five commits ago without stashing, branching, or cherry-picking.
This is a core jj workflow, so use it deliberately:
jj edit <rev> moves @ onto that revision. There is no "detached HEAD" warning and nothing to stash — @ simply is that commit now.
- Edit files normally. Because the working copy is a commit, every change is absorbed into
<rev> in place (its change ID is preserved; it gets a new commit ID).
- Every descendant commit automatically rebases on top of the amended revision the moment you make a change. No
git rebase --continue dance; if a descendant can't apply cleanly, the conflict is stored in that commit and you resolve it whenever you like — your current edit is never blocked.
- Move
@ wherever you want next (jj edit <other>, or jj new <tip> to get back on top). The edits you made stay with the revision you made them on — they don't follow the working copy.
Contrast with jj new <rev>, which creates a new child commit on top of <rev> rather than editing <rev> itself. Reach for jj edit to amend an existing commit; jj new to start fresh work.
Conflicts are data, not blocking states
Conflicts are stored inside commits. A rebase that produces conflicts still succeeds — the conflicted state is committed and you resolve it later. jj log marks conflicted commits with ×.
Commits are immutable — rewrites create new commits
There is no true "amend" in jj. Every rewrite (describe, squash, rebase) creates a new commit ID while preserving the change ID. jj prevents rewriting commits that are in the immutable set (typically anything merged to main/trunk()). Use --ignore-immutable as a last resort escape hatch, but prefer rebasing on top instead.
The operation log is your safety net
Every jj command is recorded. jj undo reverts the last operation — not just the last commit, but any operation including rebases, squashes, and pushes. Much simpler than git reset variants.
Inspecting Changes
jj show
jj status
jj diff
jj show <change-id>
jj log
jj log -r 'all()'
jj log -r '..@'
jj evolog
Creating and Editing Commits
jj new -m "msg"
jj new <change-id> -m "msg"
jj commit -m "msg"
jj commit -m "msg" f1 f2
jj describe -m "msg"
jj edit <change-id>
jj abandon <change-id>
Always use -m "..." with any commit-like command — without it, an editor opens.
Moving Changes Between Commits
jj squash -m "msg"
jj squash file1 file2 -m "msg"
jj squash --from <id> --into <id> -m "msg"
jj squash --into <ancestor> file1 -m "msg"
jj squash -u
jj split file1 file2 -m "msg"
jj split -r <id> file1 file2 -m "msg"
Use jj squash --into <ancestor> when you know exactly which commit a change belongs to. When you don't — "fold these working-copy fixes back into wherever each line came from" — use jj absorb instead (see below), which routes each hunk automatically.
jj squash and jj split open an editor by default (when descriptions need to be combined, or when no filesets are given). Avoid this by always providing filesets and -m.
jj diffedit always opens an interactive diff editor — avoid it. Instead:
- Move whole files:
jj restore <files> --from <id>
- Move specific changes: edit files directly with your editor, then
jj squash <files>
jj restore is also useful for reverting a file to its state in a specific revision without creating a new commit — the change lands in @ as an uncommitted edit:
jj restore <file>
jj restore <file> --from <change-id>
This is preferable to manually editing a file back to a known state.
Rebasing
jj rebase takes one of three source selectors plus a destination. They are not interchangeable:
| Flag | Moves | Mnemonic |
|---|
-r <rev> | just that one commit | "this revision only" — descendants stay put, get rebased onto its old parent |
-s <rev> | <rev> and all descendants | "source subtree" |
-b <rev> | the whole branch containing <rev> that isn't yet in the destination | "branch" |
Destinations:
| Flag | Meaning |
|---|
--onto/-o <rev> | New parent (most common) |
-A <rev> | Insert after <rev> (between it and its current children) |
-B <rev> | Insert before <rev> |
--onto/-o is the current name; -d/--destination still works as a legacy alias but jj help rebase no longer advertises it — prefer -o.
jj rebase -s <feature> -o main
jj rebase -r <fix> -A <target>
jj rebase -b @ -o main@origin
To reorder commits, prefer the explicit non-interactive form (jj rebase -r C --before B) over jj arrange, which opens an interactive TUI.
Rebase never blocks on conflicts — see Resolving Conflicts below.
Commands a git user will miss (don't fall back to git habits)
These are first-class jj commands that replace multi-step git workflows. Reach for these before composing the same effect from jj rebase + jj squash + jj new.
jj absorb — distributes the working-copy changes into the ancestor commits that last modified the same lines. Replaces git commit --fixup + git rebase -i --autosquash in one command. Targets mutable() by default; restrict with --into <revset> or [filesets...].
jj fix — runs configured formatters ([fix.tools]) on the changed files of one or more revisions, then propagates results to descendants without producing conflicts. Replaces format && git commit --amend && rebase.
jj duplicate -r <rev> [-A|-B|-o <dest>] — copies a commit's content as a new change. The canonical "cherry-pick" replacement. (-o/--onto; -d is still an accepted alias.)
jj revert -r <rev> -o <dest> — applies the inverse of <rev> at <dest>. (The old jj backout no longer exists.) -A/-B insertion flags also work; -d is an alias of -o.
jj interdiff --from <a> --to <b> — diffs the diffs of two revisions. Use it to see what changed between two iterations of the same change (e.g. before vs after a force-push: jj interdiff --from push-xyz@origin --to push-xyz). Not the same as jj diff --from --to, which compares file contents.
jj metaedit -r <rev> — change author/email/timestamp without touching content. Use --update-change-id to detach a copy from the original change ID.
jj parallelize <revset> — turn a linear chain into siblings sharing the same parent. Inverse: jj rebase them back into a line.
jj simplify-parents -r <rev> — remove redundant parents from a merge commit when one parent is an ancestor of another.
jj file <subcmd> — list, show <file>, annotate <file> (blame), track <pat>, untrack <pat>, . Prefer these over shelling out to / when you want the working-copy snapshot semantics.
Resolving Conflicts
Conflicts in jj are stored inside commits, so jj rebase always succeeds. The conflicted commit gets an × marker in jj log.
jj resolve --list
jj resolve --tool :ours
jj resolve --tool :theirs
For custom resolution: edit the conflict markers directly in the file. jj auto-detects the resolution on the next command — no need to run jj resolve at all.
Standard workflow:
jj new <conflicted-change-id>
jj squash -m "resolve conflict in <file>"
jj's conflict markers are not git's. They include a %%%%%%% diff block showing how one side evolved relative to the merge base, and a +++++++ block holding the literal content of the other side. See references/conflicts.md for the marker format and worked examples before editing a jj conflict by hand.
Operation Log (Safety Net)
Reach for the simplest tool that fixes the problem. Treat the jj op family — especially jj op restore — as a last resort, not a first move. The op log rewinds whole-repo state and can silently discard unrelated work that happened after the operation you're targeting. Before touching it, ask whether a narrower command does the job:
| What you want | Use this first | Not this |
|---|
| Take back the operation you just ran | jj undo (repeat for more) | jj op restore |
| Re-apply something you just undid | jj redo | jj op restore |
| Cancel the effect of a committed change, keeping history | jj revert -r <rev> -o @ | jj op revert |
| Throw away working-copy edits / reset a file | jj restore [<files>] | jj op restore |
Only when none of those express what you need — e.g. you must surgically undo one operation several steps back while keeping everything after it, or recover from a tangle that simple undo can't reach — drop down to the op log:
jj op log
jj undo
jj redo
jj op show <op-id>
jj op revert <op-id>
jj op restore <op-id>
jj --at-operation <op-id> <cmd>
jj undo is the answer to almost any recent mistake — much simpler than git reset --hard, git reflog, etc. Between the two op-log rewrites, prefer jj op revert (surgical, keeps later work) over jj op restore (discards everything after the target). --at-operation (alias --at-op) is the read-only time machine — safe to use freely, since it only inspects a past repo state without modifying anything.
Bookmarks (vs Git Branches)
Bookmarks map to git branches when pushing, but work differently:
Key differences from git branches:
- No "current bookmark" —
@ is not "on" a bookmark. Bookmarks are just labels pointing at commits, not a current location.
- Bookmarks don't move on commit — in git, committing advances the current branch. In jj, bookmarks stay put. You move them explicitly. This is the most common agent foot-gun: after
jj commit your new work sits above the bookmark, so jj git push pushes nothing new. You must first advance the bookmark — jj bookmark move <name> --to @- (or jj bookmark advance / jj ba) — then push. The /jj-commit command ends with this reminder for exactly this reason.
- Remote tracking is visible —
bookmark@origin is a separate ref. After fetching, you can see local and remote diverge before pushing.
- Moving backwards requires a flag —
jj bookmark move name --allow-backwards (unlike git branch -f).
- Deletion is two-step — delete locally, then push the deletion separately.
jj bookmark create <name> -r <change-id>
jj bookmark set <name>
jj bookmark move <name> --to <change-id>
jj bookmark move <name> --to <id> --allow-backwards
jj bookmark advance
jj bookmark advance <name>
jj bookmark advance <name> --to <change-id>
jj bookmark list --all
jj bookmark delete <name>
jj git push --bookmark <name>
jj git push --deleted
jj git push
jj git fetch
No --force needed after rewrites. jj git push handles rewritten history transparently — it reports "Move sideways bookmark" and force-pushes implicitly. Unlike git, there is no --force-with-lease muscle memory to invoke. jj tracks change IDs separately from commit IDs, so it knows the rewrite was intentional.
bookmark move vs bookmark advance:
move — explicit: you say exactly where the bookmark goes
advance — without args, finds the closest bookmark(s) that are ancestors of @ and moves them forward to @; with a name, advances that specific bookmark to @; useful after squashing/rebasing when the bookmark is lagging behind your work
Bookmark operations do not affect @ — unlike git checkout <branch> which moves HEAD and updates the working tree, moving or advancing a bookmark in jj is purely administrative. The working copy stays exactly where it is.
Divergent change after a merged PR
A divergent change is one change ID that resolves to two or more visible commits — jj log marks them (divergent) and jj log -r <change-id> errors with "Change ID is divergent" (disambiguate with <change-id>/0, /1, …).
The most common way to hit this: you push a feature commit to a branch, the PR is merged (squash/rebase merge) so the same change lands on main as a new commit ID, then jj git fetch imports it. Now your local pushed commit and the merged-into-main commit share one change ID → divergent. The merged commit on main is the keeper; your local copy is a stale duplicate.
Fix — usually just fetch again:
jj git fetch
jj log
jj new main
GitHub typically deletes the PR branch on merge, so a fetch prunes the remote-tracking ref and the duplicate becomes unreachable and is abandoned automatically.
If a fetch doesn't clear it (the remote branch still exists, or the ref lingers as name@origin): the duplicate is held alive — and marked immutable — by that remote-tracking ref, so jj abandon will refuse it until the ref is gone. Drop the ref, then abandon:
jj bookmark forget --include-remotes <push-branch>
jj abandon <commit-id>
If the branch genuinely still exists on the remote and you want it gone everywhere, delete it on the remote (e.g. via the PR/host UI) or jj bookmark delete <push-branch> then jj git push --deleted. Everything here is recorded in the op log — jj undo backs out a wrong step.
Workspace Model
jj workspaces are like git worktrees but better:
- Each workspace has its own
@ (working copy)
- All workspaces share the same commit graph — no merging needed
- Create:
jj workspace add <name> --revision @
- Remove:
jj workspace forget <name>
Revset Syntax
| Symbol | Meaning |
|---|
@ | Current working copy commit |
@- | Parent of working copy |
trunk() | Main branch tip (remote main/master) |
mine() | Commits authored by you |
bookmarks() | All bookmarked commits |
foo- | Parent of foo |
foo+ | Children of foo |
::foo | Ancestors of foo |
foo::bar | DAG range |
foo..bar | Range (like git's) |
Prefer change IDs over relative refs like @- when targeting specific commits — change IDs are stable across rewrites, relative refs shift as the working copy moves.
Reference
references/git-to-jj.md — comprehensive git-to-jj command mapping
references/conflicts.md — jj's conflict marker format and resolution recipes
references/revsets.md — curated revset language: operators, common functions, idioms
references/filesets.md — curated fileset language for file-aware commands
- In-tree
jj help -k <topic> — authoritative docs for revsets, templates, filesets, bookmarks, config, glossary, tutorial