jj agent skill

15 kB · 400 lines · Created 4mo ago
Markdown


Jujutsu (jj) Skill #

You are helping Kieran use jujutsu (jj), a Git-compatible VCS built for safe, concurrent, agent-friendly workflows.

Core Concepts #

  • Working copy is a commit. Denoted @. Auto-amended on every jj command. No staging area. No "dirty working copy" errors.
  • No "current branch." You're always on a commit. Bookmarks (jj's branches) are optional labels that auto-follow rewrites.
  • Change ID (e.g. kntqzsqt): stable across rewrites. Commit ID (e.g. d7439b06): changes on every rewrite. Prefer change IDs.
  • First-class conflicts. Commands never fail due to conflicts. Conflicts are data stored in commits and can be rebased through.
  • Operation log. Every mutation recorded. jj undo undoes the last op. jj op restore <op> restores to any point.
  • Revsets. Functional query language for selecting commits. Used with -r.
  • Colocated repos. Default mode. .jj/ and .git/ share a working copy. Auto-syncs on every command. Mix jj and git commands freely.

Workspaces #

Workspaces are jj's first-class equivalent of git worktree. Multiple working directories backed by a single .jj/repo/ store, each checked out to a different commit. Unlike git worktrees, jj workspaces are not tied to branches — any number can share the same commit.

Important: Workspaces must be siblings to the main repo in the filesystem, not children. Use ../feature-name, not ./feature-name. Creating a workspace inside the repo nests a copy of the repo inside itself and jj will warn.

Key commands:

jj new                           # always jj new BEFORE workspace add
jj workspace add ../<name>       # create sibling workspace at ../<name>
jj workspace add ../<name> -r <rev>  # check out <rev> in new workspace
jj workspace list                # list all workspaces
jj workspace update-stale        # refresh working copy if out of sync
jj workspace forget <name>       # remove workspace from jj's tracking
                                 # (does NOT delete files; use rm -rf after)

How workspaces work internally:

Each workspace has its own .jj/ directory symlinked to the shared .jj/repo/ store, plus its own working_copy/ state. Commits are globally visible across all workspaces — jj log in workspace A shows the @ commit of workspace B. Rebase workspace A's commit from workspace B and jj tracks that A's working copy is now stale, watchable with jj workspace update-stale.

Why workspaces matter for AI agents:

  • Auto-snapshotting. The agent's working copy IS a commit. Nothing is ever lost — every jj command snapshots before running.
  • Lock-free concurrency. Two agents in different workspaces never contend on lock files. jj 3-way-merges divergent operations.
  • Universal undo. jj undo in any workspace. jj op log shows every mutation across all workspaces.
  • Non-blocking conflicts. An agent in workspace A creates a conflict. The agent in workspace B keeps working. Resolution happens later, possibly in a third workspace.

Cleanup:

jj workspace forget <name>  # detach from jj
rm -rf ../<name>            # delete the directory

Practical walkthrough:

# Start with a repo
jj git init
echo "base" > a.txt && jj describe -m "base"

# Always jj new before creating a workspace — gives both
# workspaces a common parent
jj new
jj workspace add ../feature-b

# In default workspace:
echo "work from default" > b.txt
jj describe -m "Add file B on default"

# In feature-b workspace:
cd ../feature-b
echo "work from feature-b" > c.txt
jj describe -m "Add file C on feature-b"

Cross-workspace rebasing (no merge needed):

Workspace commits are globally visible and can be moved freely. To consolidate feature-b's work into default, just rebase from either workspace:

# In feature-b, rebase its commit(s) onto default's tip:
jj rebase -s feature-b@ -d default@

# Now default and feature-b share a linear tree.
# Catch default up:
cd ../main
jj edit feature-b@   # both workspaces now at same revision

This is the key insight: there's no merge workflow. You rebase commits across workspace boundaries and they land linearly. Conflicts become first-class data in the commit — resolve them later, from any workspace.

Colocated Repos & Git Interop #

Clone a remote repo #

jj git clone <URL>
cd <repo>

Colocated by default. All git branches become bookmarks.

Add jj to an existing git repo #

cd /path/to/existing/git/repo
jj git init

History, branches, and remotes preserved. Colocated in-place.

Key interop notes #

  • jj puts git in detached HEAD. Run git switch <branch> before running mutating git commands.
  • Every jj command auto-imports/exports git refs.
  • Push: jj git push --bookmark <name> or jj git push --change <id>.
  • Fetch: jj git fetch.
  • Force-with-lease safety on push. Conflicted bookmarks cannot be pushed.
  • No support for: .gitattributes, hooks, submodules, LFS, annotated tag creation.

Command Reference #

# Status & Inspection
jj st                          # status
jj log                         # graph log
jj log -r ::@                  # ancestors of working copy
jj diff                        # diff of working copy
jj diff -r <rev>               # diff of a specific revision
jj show <rev>                  # commit details + diff

# Working Copy
jj describe -m "msg"           # set description on @
jj new                         # new empty commit on top of @
jj new <rev>                   # new commit on top of <rev>
jj new x y z                   # merge commit with parents x, y, z
jj commit -m "msg"             # describe @ then new child (shorthand)
jj edit <rev>                  # check out <rev> as @ (auto-amends)
jj abandon <rev>               # delete a commit

# Restructuring
jj squash                      # squash @ into parent
jj squash -i                   # interactive: pick hunks to squash
jj squash --from X --into Y    # move all changes from X into Y
jj squash --from X --into Y -i # interactive: pick hunks from X into Y
jj absorb                      # auto-distribute @ changes into ancestors
jj split                       # split @ into two commits interactively
jj rebase -s <src> -d <dst>   # rebase src onto dst
jj rebase -r <rev> -d <dst>   # rebase single revision onto dst
jj restore --from X --to Y     # restore file content from X into Y

# Bookmarks
jj bookmark create <name> -r <rev>
jj bookmark set <name> -r <rev>
jj bookmark move <name> -r <rev>
jj bookmark delete <name>
jj bookmark list
jj tug                         # ALIAS (not builtin): move nearest bookmark to @
                               # requires two config entries:
                               #   revset-aliases."closest_bookmark(to)" =
                               #     "heads(::to & bookmarks())"
                               #   aliases.tug = ["bookmark", "move", "--from",
                               #     "closest_bookmark(@-)", "--to", "@-"]

# Remotes
jj git fetch [--remote <name>]
jj git push [--bookmark <name>] [--change <change-id>]
jj git import                  # resync jj from git refs

# History & Recovery
jj undo                        # undo last operation
jj op log                      # view operation log
jj op restore <op-id>          # restore repo to past state
jj evolog                      # evolution history of current change

# Conflicts & Files
jj resolve                     # launch merge tool for conflicts
jj file untrack <file>         # stop tracking (must be in .gitignore)

Revset Essentials #

@              working copy
@-             parent(s) of working copy
x::            descendants of x (including x)
::x            ancestors of x (including x)
x::y           DAG range: descendants of x that are ancestors of y
x..y           ancestors of y not ancestors of x (like git x..y)
~x             complement (not in x)
x & y          intersection
x | y          union
x ~ y          set difference
trunk()        main/master on default remote
bookmarks()    all local bookmark targets
mine()         commits by current user
empty()        commits with no file changes
conflicts()    commits with unresolved conflicts
mutable()      commits you can rewrite
immutable()    commits protected from rewriting
heads(x)       tips of set x
roots(x)       roots of set x
latest(x, n)   latest n commits by timestamp
description("pattern")   filter by description
files("path")            filter by modified files

String patterns: "glob", exact:"str", regex:"pat", substring:"str". Append -i for case-insensitive.

Preferred Workflow: New, Then Squash #

Prefer jj new over jj edit for most work. Create a new commit, make changes, then jj squash when ready. This is safer than editing in place because you decide when to fold changes back. Jj auto-abandons empty undescribed commits, so temporary experiments clean themselves up.

For WIP you'd previously stash, at minimum run jj describe -m "wip: ..." so future-you has context.

Workflow: Megamerges #

A megamerge is an octopus merge commit (3+ parents) combining all your active branches. You work on top of it. You never push the megamerge itself, only the individual branches.

Why:

  • Always work on the combined sum of all branches — if it compiles, everything integrates.
  • Rare surprise merge conflicts since you resolve incrementally.
  • Low-friction task switching via jj edit <branch>.
  • Single rebase updates everything against trunk.

Creating:

jj new branch1 branch2 branch3 -m "megamerge"

Working across branches:

jj edit <branch>    # jump to any branch tip, auto-amends
jj edit @           # back to megamerge
jj new <branch>     # add a new commit to a branch

Adding/removing branches:

A single -d accepts a multi-revision revset, so use set operators on the megamerge's current parents (<megamerge>-) instead of retyping every parent. Validated on jj 0.41:

# Add a branch: union the new branch with the existing parents
jj rebase -s <megamerge> -d '<megamerge>- | <new-branch>'

# Remove a branch: subtract it from the existing parents
jj rebase -s <megamerge> -d '<megamerge>- ~ <old-branch>'

# Swap a branch: parenthesize the difference, then union the replacement
jj rebase -s <megamerge> -d '(<megamerge>- ~ <old-branch>) | <new-branch>'

Note: the old all: prefix modifier (e.g. all:<megamerge>-) was removed in recent jj. A bare multi-revision revset in -d now works without it; passing all: errors with ": is not an infix operator". If you ever need to force multi-revision resolution explicitly, use the all() function.

Tip: run jj log -r '<megamerge>-' --no-graph first to see current parents with their change IDs and bookmarks. After changing parents, update the megamerge description with jj describe -r <megamerge> -m "..." to keep it accurate.

Rebasing onto updated trunk:

jj rebase -s 'roots(main..@)' -d main

This rebases the root of each arm onto main and jj automatically drags all descendants (including the merge commit) along while preserving the multi-parent structure. No flattening, no manual per-branch rebasing.

Important: The megamerge is local-only. Push individual bookmarks. When a branch lands upstream, remove it from the megamerge and rebase.

Optional aliases (cut the ceremony):

These community aliases turn the multi-step megamerge chores into one word each. Validated on jj 0.41. Add to ~/.config/jj/config.toml:

[revset-aliases]
# The nearest merge commit at or below `to` — i.e. your megamerge.
"closest_merge(to)" = "heads(::to & merges())"

[aliases]
# jj stack <revset>  — splice a loose change in as a new megamerge parent.
stack   = ["rebase", "--after", "trunk()", "--before", "closest_merge(@)", "--revision"]

# jj stage  — pull the whole stack above the megamerge into it.
stage   = ["stack", "closest_merge(@)+:: ~ empty()"]

# jj restack  — rebase every arm onto the latest trunk, then tidy parents.
restack = ["rebase", "-o", "trunk()", "-s", "roots(trunk()..) & mutable()", "--simplify-parents"]

With these, jj restack replaces the manual jj rebase -s 'roots(main..@)' -d main invocation, and & mutable() keeps it from touching commits you don't own. The add/remove-parent set-revset forms above stay handy for one-off branch juggling.

Workflow: Laundry-Pile Commit Cleanup #

Don't try to make clean commits during development. Make a messy pile, then sort it at the end.

  1. Work freely. Make WIP commits without worrying about cleanliness.
  2. Identify logical categories (types, UI, tests, docs).
  3. Create empty placeholder commits:
    jj new -B messy-first -m "types"
    jj new -A types -m "ui"
    jj new -A ui -m "tests"
    
  4. Squash all messy work into one combined commit:
    jj squash --from messy-first..messy-last --into messy-first
    
    In a non-TTY context (no $EDITOR), prefix with JJ_EDITOR=true so the combined-description prompt doesn't block: JJ_EDITOR=true jj squash ....
  5. Interactively distribute into placeholders:
    jj squash -i --from messy-first --into types
    jj squash -i --from messy-first --into ui
    jj squash -i --from messy-first --into tests
    
  6. Repeat until the combined commit is empty. Abandon it.

Why this beats jj split: With jj split, missing a hunk means splitting again and squashing. The laundry approach lets you sort the easiest hunks first without ordering worries. The final state is guaranteed conflict-free.

Workflow: Conflict Resolution #

Conflicts in jj are data, not errors. Commands succeed even with conflicts.

jj log                    # find conflicted commit (marked with ×)
jj new <conflicted-rev>   # create resolution commit on top
# Edit files to resolve, or:
jj resolve                # launch external merge tool
jj squash                 # fold resolution back

Descendants of resolved commits automatically rebase. No --continue.

Tips for AI-Assisted jj Usage #

  1. Prefer change IDs over commit IDs — they're stable across rewrites.
  2. jj log -r <revset> to inspect state before and after operations.
  3. jj st to check working copy state.
  4. When unsure, jj undo is always safe.
  5. Never push without explicit consent.
  6. In colocated repos, jj auto-syncs with git. If something goes wrong, jj undo handles both sides.
  7. Use JJ_EDITOR=true jj squash to avoid editor prompts in non-TTY contexts.
  8. jj absorb to auto-distribute changes into correct ancestor commits.
  9. jj tug (a user alias, not a builtin — see Bookmarks section) to move the nearest bookmark forward after finishing work.
  10. jj file untrack <file> to stop tracking files (must be in .gitignore first).
Sign up or Sign in to add to the discussion.