name: jj description: Use Jujutsu (jj) for version control. Covers workflow, commits, bookmarks with Conventional Branch naming, pushing to GitHub, absorb, squash, stacked PRs, workspaces with auto-sync for OpenCode sessions. Use when working with jj, creating commits, pushing changes, or managing version control.
Jujutsu (jj) Version Control
Core Principles
1. The working copy IS a commit
Changes you make are immediately part of the current commit. There's no staging area.
- Always
jj newbefore starting new work - Use
jj squashto fold changes into parent (local commits only — see principle 2) - Use
jj absorbto auto-distribute fixes to ancestors (local commits only)
2. Pushed commits are immutable
Once a commit has been pushed to a remote, treat it as immutable. Never rewrite it. This eliminates force pushes entirely.
Implications:
- Don't
jj squashinto an already-pushed commit - Don't
jj describean already-pushed commit - Don't
jj rebasepushed commits onto a new base — mergemaininto the branch instead - When updating a PR, append a single new commit on top of the remote tip (see "Updating a PR" below)
Enforce this at the tool level by setting immutable_heads() to include remote bookmarks (see "jj config" section).
3. Commit often locally, consolidate once at push time
Between pushes, commit freely — every agent turn, every experiment. Those commits are disposable scratch work. When you're ready to share with the PR, collapse everything since the last push into one new commit on top of the remote tip and push it.
This gives you:
- Rich local history to navigate and recover from
- Clean, immutable, review-friendly history on the remote
- No force pushes, ever
Quick Reference
| Command | Purpose |
|---|---|
jj status |
Check current state (ALWAYS run first) |
jj log |
View commit history |
jj diff |
See changes in current commit |
jj new |
Create new empty commit |
jj describe -m "msg" |
Set commit message (local commits only) |
jj squash |
Move changes to parent (local commits only) |
jj absorb |
Auto-distribute to ancestors (local commits only) |
jj git fetch |
Fetch from remote |
jj git push |
Push to remote (fast-forward only) |
OpenCode Slash Commands
| Command | Purpose |
|---|---|
/pr |
Create new PR: squash local work onto base, push |
/update |
Update existing PR: squash local work onto remote tip, push |
/sync |
Merge main into current branch (no rebase) |
/finish |
Push, create PR, watch CI |
/stack |
Create stacked PR on top of current branch |
/push |
Push current bookmark (fast-forward only) |
/workspace |
Manage jj workspaces |
Creating a PR (initial push)
Agent:
1. jj git fetch
2. Commit local work freely while developing: jj new + jj describe each round
3. When ready to share:
a. Generate a conventional-commit message from the complete diff
(base..@). This is the message for ONE commit representing the
whole PR.
b. Run: jj-pr <type> <description> --message "<msg>"
jj-pr internally:
- Fetches from remote.
- Creates a new empty commit directly on top of the base branch (
main@origin). - Restores the tree from your current
@into that new commit. - Sets the bookmark to the new commit.
- Pushes (fast-forward only).
- Opens the PR via
gh.
Your local scratch commits are orphaned but recoverable via jj op log. After jj-pr, run jj new to continue working on top of the pushed commit.
Updating a PR
Key rule: Never rewrite the remote tip. Always add a new commit on top.
Agent:
1. While working, commit locally every change:
jj new && <make changes> && jj describe -m "wip: whatever"
2. When ready to update the PR:
a. Generate a conventional-commit message describing the full
diff between the remote tip and your current @ — i.e. what
this update round adds to the PR.
b. Run: jj-update --message "<msg>"
jj-update internally:
jj git fetch— refresh<bookmark>@originto the true remote tip.- If no bookmark on
@, or bookmark has no remote tracking, falls back to thejj-prflow (new PR). - If the range
<bookmark>@origin..@is empty AND working copy is clean → exit cleanly. - Create a new empty commit on top of
<bookmark>@originwith the provided message. - Restore the tree from the old
@into the new commit. - Move the bookmark to the new commit.
jj git push(fast-forward only).
Resuming work after jj-update
After a successful jj-update, your old local commits are orphaned (no longer reachable from any bookmark). They are:
- Still in the op-log (recoverable via
jj op log+jj op restore). - Not where you should continue working.
Always run jj new after jj-update to start a fresh empty commit on top of the just-pushed commit. Do not try to check out the orphaned commits — they don't exist on the remote and will only cause confusion.
jj-update will print the new pushed commit ID and a reminder to run jj new.
Syncing with main (merge, not rebase)
Rebasing rewrites the pushed commit, which violates principle 2. Instead, merge main into the branch:
jj-sync # Fetch main, create a merge commit bringing main into your branch
jj-sync develop # Merge develop instead
jj-sync internally:
jj git fetch.- If
main@originis already an ancestor of@, exit cleanly. jj new <bookmark>@origin main@origin— create a merge commit with two parents.- Move bookmark to the merge commit.
- Push (fast-forward only).
Then run jj new to continue working on top.
jj Config: Enforcing Immutability
Add to ~/.config/jj/config.toml (or $XDG_CONFIG_HOME/jj/config.toml):
[revset-aliases]
# Treat all commits reachable from remote bookmarks as immutable.
# This makes jj refuse to rewrite pushed commits.
"immutable_heads()" = "present(trunk()) | remote_bookmarks()"
With this config, attempting to jj squash or jj describe a pushed commit will error out with a clear message, forcing you into the "new commit on top" workflow.
Conventional Branch Naming
Branch format: <type>/<description>
| Type | Purpose | Example |
|---|---|---|
feat/ |
New features | feat/user-auth |
fix/ |
Bug fixes | fix/null-pointer |
hotfix/ |
Urgent fixes | hotfix/security-patch |
release/ |
Releases | release/v1.2.0 |
chore/ |
Non-code tasks | chore/deps-update |
Rules:
- Lowercase alphanumerics and hyphens only
- No consecutive/leading/trailing hyphens
- Include ticket numbers:
feat/gh-123-add-feature
Workspaces (OpenCode sessions)
Workspaces isolate work in flight and enable fast background sync.
All workspaces live in ~/workspaces/ — never as sibling directories inside the repo.
Workspace location
~/workspaces/
feat-auth-20260512-a1b2/ ← workspace directory
fix-bug-20260512-c3d4/ ← another workspace
Creating a workspace
Use fjj (Fast JJ) from any directory:
fjj feat/my-topic # Create workspace from main
fjj fix/bug-name develop # Create workspace from develop branch
fjj list # Show all workspaces
fjj clean # Remove merged/stale workspaces
Or with the /workspace slash command in OpenCode:
/workspace feat/user-auth # Create workspace from main
/workspace fix/login develop # Create workspace from develop branch
/workspace # Enable fast sync in current workspace
Agent workspace naming
Agent workspace names encode agent identity:
feat/agent-<agent-id>-<topic> # e.g. feat/agent-openclaw-auth-fix
fix/agent-<agent-id>-<topic> # e.g. fix/agent-openclaw-lint
Session commands (fast sync)
jj-workspace-session start [type/topic] [base]
jj-workspace-session stop
jj-workspace-session touch # Reset TTL manually
jj-workspace-session status
jj-workspace-session sync # Manual sync
jj-workspace-session prune # Remove expired sessions
What happens when a session starts
- Workspace created:
feat/auth-20260223-a1b2(type/topic-date-id). - Fast sync enabled: repository syncs every 5 minutes (vs hourly).
- Main stays clean: your work is isolated, main auto-syncs with upstream.
- Session TTL: auto-expires after 30 minutes of inactivity (resets on each sync).
Full workflow: Create → Work → Finish → Clean
# 1. Create workspace
fjj feat/agent-openclaw-my-feature
# 2. cd into workspace
cd ~/workspaces/feat-agent-openclaw-my-feature-<date>-<id>
# 3. Work and commit (working copy IS a commit)
# ... make changes ...
jj describe -m "feat: add my feature"
# 4. Validate (devenv tasks run from repo root)
cd /path/to/repo && devenv tasks run check:lint
# 5. Push and create PR
cd ~/workspaces/feat-agent-openclaw-my-feature-<date>-<id>
jj bookmark set feat/my-feature -r @
jj git push --bookmark feat/my-feature
gh pr create --head feat/my-feature
# 6. Clean up after merge
jj workspace forget feat-agent-openclaw-my-feature-<date>-<id>
rm -rf ~/workspaces/feat-agent-openclaw-my-feature-<date>-<id>
Background Auto-Sync
Repositories opt-in via a .jj-autosync config file:
# .jj-autosync — add to repo root and commit
enabled=true # Enable hourly sync
main=main # Main branch name
fast_sync=true # Enable 5-min sync during sessions
| Mode | Frequency | Requires |
|---|---|---|
| Hourly | Every hour | enabled=true |
| Session | Every 5 min | fast_sync=true + active session |
Status:
jj-autosync-status # Show sessions and recent logs
Logs:
/tmp/jj-autosync.log— hourly sync/tmp/jj-fast-sync.log— session sync
Failures trigger cross-platform desktop notifications (via noti, terminal-notifier, or notify-send).
Manual Workflow (without scripts)
Create PR
jj git fetch
# Develop: make many local commits as you go
jj new main@origin
# ... changes ...
jj describe -m "wip"
jj new
# ... more changes ...
jj describe -m "wip 2"
# When ready to push:
OLD=$(jj log -r @ --no-graph -T 'commit_id')
jj new main@origin -m "feat: add user auth"
jj restore --from "$OLD" --to @
jj bookmark set feat/user-auth -r @
jj git push --bookmark feat/user-auth
gh pr create --head feat/user-auth
jj new # Fresh commit to continue work
Update PR
jj git fetch
# Each update round:
OLD=$(jj log -r @ --no-graph -T 'commit_id')
jj new 'feat/user-auth@origin' -m "fix: address review feedback"
jj restore --from "$OLD" --to @
jj bookmark set feat/user-auth -r @
jj git push
jj new
Sync with main
jj git fetch
jj new 'feat/user-auth@origin' 'main@origin' -m "merge main into feat/user-auth"
jj bookmark set feat/user-auth -r @
jj git push
jj new
Common Mistakes
- Working in a described commit — always
jj newbefore making changes. - Forgetting
jj newafter an update — you'll end up squashing onto a pushed commit. Always runjj newafterjj-update,jj-pr, orjj-sync. - Trying to
jj squashinto a pushed commit — violates immutability. Usejj-updateinstead. - Rebasing a pushed branch onto new main — use
jj-sync(merge) instead. - Reaching for
--forceor--allow-backwardsonjj git push— if you need force, something upstream is wrong. Stop and re-evaluate.
Undo
jj undo # Undo last operation
jj op log # View operation history
jj op restore <id> # Restore to a specific point — also useful to recover orphaned local commits