| name | jj-history-cleanup |
| description | Jujutsu history cleanup patterns for rewriting and reorganizing change history. |
| disable-model-invocation | true |
Jujutsu history cleanup
IMPORTANT for AI agents: Commands like jj describe -r and jj split <paths> require -m "message" flag for non-interactive execution. See ~/.claude/skills/jj-workflow/SKILL.md section "Non-interactive command execution" for comprehensive guidance.
Purpose
Transform experimental development history into a clean, reviewable commit sequence where:
- Each commit is atomic: contains one logical change that builds/tests successfully
- Commits are logically ordered: dependencies come before dependents, related changes are grouped
- Intermediate commits are removed: no "WIP", "fix typo", "oops", or checkpoint commits
- Each commit description follows conventional commit format and accurately describes its diff
This prepares bookmarks for PR review by creating a clear narrative of what changed and why.
Principle
Operations execute immediately and atomically.
No special modes, no interactive editors, no rebase sequences.
Every operation is recorded in the operation log and can be undone instantly with jj undo.
Work directly on history without preparation or mode switching.
Core technique
Unlike git's batch rebase mode, jj operates on commits directly:
jj <command> -r <revset>
All operations automatically rebase descendants.
Use the operation log (jj op log) as your safety net instead of backup branches.
Operations
For detailed command mappings from git interactive rebase, see ~/.claude/skills/jj-git-interactive-rebase-to-jj/SKILL.md.
Reorder commits
Move commits to different positions in history:
jj rebase -r C -B B
jj rebase -r C -A A
jj rebase -r C -d <parent>
jj rebase -s C -d <new-base>
Key insight: -B inserts before, -A inserts after, -d sets new parent directly.
Descendants of the moved commit automatically follow.
Squash/fixup commits
Combine commits by moving changes between them:
jj squash -r <commit>
jj squash -r <commit> -m "combined message"
jj squash --from <commit> --into <ancestor>
jj squash -i -r <commit>
jj squash -r 'description(glob:"WIP:*")'
Source commit is emptied after squash and becomes hidden.
Use --keep-emptied if you need to preserve the commit shell.
When using jj squash --from <src> --into <dest>, if both source and destination have non-empty descriptions and the source is not fully emptied, jj opens a description merge editor.
This blocks non-interactive execution.
To prevent this:
- Use
-u (--use-destination-message) to keep the destination's description unchanged.
- Use
-m "message" to explicitly set the resulting description.
-u is preferred when the destination already has the correct description.
Drop commits
Remove commits from history:
jj abandon <commit>
jj abandon 'description(glob:"tmp:*")'
jj abandon 'empty()'
jj abandon <start>::<end>
Abandoned commits become hidden.
Their descendants are automatically rebased onto the abandoned commit's parent(s).
Reword commit descriptions
Change commit messages without touching content:
jj describe -r <commit> -m "new description"
jj describe -r 'description(glob:"WIP:*")' -m "proper description"
jj describe -r <commit> -m ""
CRITICAL: Always include -m flag. Without it, jj describe -r <commit> opens an editor and hangs in automation.
Descriptions can be updated at any time without special preparation.
Edit commit content
Modify the actual changes in a commit:
jj edit <commit>
jj new @-
jj diffedit -r <commit>
jj edit <commit>
jj commit <files>
jj new @-
Split commits
Divide a commit into multiple logical commits:
jj split <paths> -m "description for selected changes"
jj split -r <commit> <paths> -m "description"
jj split -r <commit>
jj split <paths> -m "description"
CRITICAL: jj split requires -m "message" even when providing paths. Without -m, it hangs waiting for editor input after file selection.
First commit gets selected changes with description, second commit gets remainder.
Both commits end up in series with same parent.
Combine multiple operations
Chain operations using revsets:
jj squash -r 'description(glob:"WIP:*")'
jj abandon 'empty()'
jj describe -r 'author("alice")' -m "Alice's changes"
jj squash -r 'author("alice")'
jj rebase -s 'description(glob:"TODO:*")' -d <elsewhere>
Robust patterns
Incremental cleanup workflow
Unlike git's all-or-nothing rebase, clean up history incrementally:
jj log -r 'main..@'
jj squash -r 'description(glob:"fixup*")'
jj squash -r 'description(glob:"oops*")'
jj log -r 'main..@'
jj rebase -r <commit> -A <after> -B <before>
jj abandon 'empty()'
jj squash -r 'description(glob:"WIP:*")'
jj log -r 'main..@'
jj describe -r <commit1> -m "proper message"
jj describe -r <commit2> -m "proper message"
jj log -r 'main..@'
Each step executes immediately.
Use jj undo to back out of any step.
Continue from where you left off.
Use operation log instead of backup branches
jj op log | head -n 3
jj squash -r X
jj rebase -r Y -A Z
jj describe -r W -m "message"
jj undo
jj undo
jj op restore a1b2c3d4
jj op log
jj op restore <specific-operation>
No need to create backup branches - operation log is your backup.
Handle conflicts during cleanup
Conflicts are committed and can be resolved later:
jj log
jj new @
jj squash
jj edit abc123
jj new @-
jj undo
jj log -r 'conflict() & main..@'
Unlike git, conflicts don't stop the workflow.
Resolve when convenient or undo and reorganize.
Verify atomicity
The per-commit test loops below repeatedly move @ with jj new $commit and assume a single linear chain.
If a development join (composite working copy) is active, do NOT run them as written — for the duration of the loop the join would have no [wip] child sitting on it, vanishing the shared editing surface that concurrent editors rely on (and, if @/wip is bookmarked for deploy, dragging that bookmark).
Instead test each commit from a throwaway side change you abandon after, never moving the join's [wip]:
orig=$(jj log -r @ --no-graph -T 'commit_id')
for commit in $(jj log -r 'main..@ ~ @' --no-graph -T 'commit_id ++ "\n"'); do
jj new $commit
cargo build && cargo test || echo "failed: $commit"
jj abandon @
done
jj edit $orig
Note that jj new @- does NOT restore the join — after the loop @- is the last-tested commit, not the join.
Restore explicitly with jj edit <original-@> or jj op restore <pre-loop-op>.
Test each commit independently:
for commit in $(jj log -r 'main..@' --no-graph --template 'commit_id ++ "\n"'); do
echo "Testing $commit"
jj new $commit
cargo build || echo "Build failed in $commit"
cargo test || echo "Tests failed in $commit"
done
jj new @-
Or use external script:
for commit in $(jj log -r "$1" --no-graph --template 'commit_id ++ "\n"'); do
jj new $commit --no-edit
if ! cargo build; then
echo "Build failed: $commit"
jj log -r $commit
exit 1
fi
done
./test-range.sh 'main..@'
Auto-distribute changes with absorb
For fixing earlier commits automatically:
jj absorb
Most powerful for fixing issues found during review without manual squashing.
Complete example
Starting state: 8 commits with various issues
$ jj log -r 'main..@'
@ mno345 WIP: more fixes
○ jkl012 fix typo
○ ghi789 add feature Y
○ def456 WIP: feature Y work
○ abc123 add feature X
○ zzz999 fixup: feature X test
○ yyy888 temp debug
○ xxx777 feature X implementation
Goal: 2 clean commits: one for feature X, one for feature Y
jj squash --from zzz999 --into abc123
jj abandon yyy888
jj squash -r xxx777
jj squash -r def456
jj squash -r jkl012
jj squash -r mno345
jj describe -r abc123 -m "feat: implement feature X with comprehensive tests"
jj describe -r ghi789 -m "feat: implement feature Y with error handling"
jj log -r 'main..@'
jj new abc123 && cargo test && jj new @-
jj new ghi789 && cargo test && jj new @-
jj bookmark set feature-xy -r @
If any step fails, jj undo backs out immediately.
No need to abort and restart - fix the issue and continue.
Verification
After cleanup:
jj log -r 'main..@'
jj diff -r main..@
for commit in $(jj log -r 'main..@' --no-graph --template 'commit_id ++ "\n"'); do
jj new $commit --no-edit
cargo build || echo "FAIL: $commit"
done
jj new @-
jj log -r 'main..@' --template 'description ++ "\n"'
jj log -r 'conflict() & main..@'
jj op log --limit 20
Development join considerations
When in a development join (composite working copy), history cleanup operations on a parent chain (rebase, squash within the chain) trigger auto-rebase of @.
This is safe — jj handles it automatically.
However, if cleanup involves abandoning changes that are parents of @, exit the development join first by removing that chain from @: jj rebase -r @ -d 'all:(@- ~ chain-being-cleaned)'.
Re-add after cleanup is complete.
Inside a development join @ is always the empty [wip] commit sitting directly on the multi-parent join, and that shared [wip] is the coordination point that makes N concurrent editors safe by construction (in this repo it also backs the pushed wip deploy bookmark machines rebuild from).
Auto-rebase of @ triggered by editing its ancestors is safe and jj-managed, and re-anchoring @'s parent set with the destination forms jj rebase -r @ -d 'all:(@- ~ chain)' or jj rebase -r @ -d 'all:(@- | chain)' is the sanctioned apply/unapply primitive that keeps @ an empty working copy on the join.
What you must never do is make @ itself a content commit or relocate it off the join: do not jj describe @ (consumes the wip into a content commit) and do not relocate it via the positional forms jj rebase -r @ --insert-before/--insert-after <target> or jj rebase --revisions @ --insert-before/--insert-after <target> (drops the wip into a chain interval).
Either removes the shared editing surface other actors are concurrently writing and breaks the diamond invariants.
To route a change down a chain while leaving @ empty, use jj absorb (above) — the preferred routing-down verb in a development join, which distributes the working-copy diff into the commits that last touched each path while leaving @ in place and empty — or jj squash --from @ --into <chain-tip> --keep-emptied [-- <paths>] (amend, -m omitted to preserve the tip description) and jj squash --from @ --insert-after/--insert-before <target> -m "msg" --keep-emptied [-- <paths>] (append/splice), each carrying explicit -m for non-interactive safety.
The six diamond invariants and the never-rewrite-@ discipline are canonical in ~/.claude/skills/jj-version-control/SKILL.md and jj-version-control/diamond-workflow.md; defer to them for the join-safe routing and splice recipes before applying any cleanup idiom from this skill.
The linear-chain idioms throughout this skill assume a single chain rooted at main with @ at its tip, and several of them move or reuse @ (the per-commit jj new $commit test loop under "Verify atomicity", jj bookmark set ... -r @, jj squash -r <c> # into current @).
In a multi-parent development join @ is the shared empty [wip] sitting on the join: never jj describe @ and never positional jj rebase -r @/--revisions @ --insert-before/--insert-after, since either drifts the wip off the join, breaks concurrent editing, and drags the pushed wip deploy bookmark below the join.
Route changes downward with jj squash --from @ ... --keep-emptied or jj absorb, and consult jj-version-control/diamond-workflow.md before applying any cleanup idiom here.
jj tidy safety
jj tidy (alias for jj abandon 'mutable() ~ @ ~ ::main') sweeps all mutable changes not in main's ancestry or @.
Always advance main to cover work-in-progress before running tidy.
Preview targets: jj log -r 'mutable() ~ @ ~ ::main' -s.
Recovery: jj undo.
Key reminders
- No backup branches needed - operation log is your safety net
- Operations execute immediately - no todo file, no editor
jj undo reverses any operation instantly
- Descendants auto-rebase when ancestors change
- Conflicts are committed, not blocking
- Use revsets to operate on multiple commits at once
jj op restore <id> returns to any prior state
- Test incrementally instead of at the end
jj revert reverses a change. Requires explicit placement: --onto, --insert-before, or --insert-after.
- Reference
~/.claude/skills/jj-git-interactive-rebase-to-jj/SKILL.md for detailed command mappings
Advanced patterns
Linearize merge commits
Convert merge-heavy history to linear sequence (computing a linear extension of the commit partial order):
jj log -r 'merge() & main..@'
jj rebase -s <branch-head> -d <main-branch>
Extract commits to separate branch
Move unrelated work to different branch:
jj log -r 'description(glob:"*unrelated*") & main..@'
jj bookmark create unrelated-work -r <first-unrelated-commit>
jj rebase -s <first-unrelated-commit> -d main
jj log -r 'main..@'
Reorder and group by semantic category
Group commits by type (feat/fix/refactor/test/docs):
jj log -r 'main..@' --template 'description ++ "\n"'
jj rebase -r <feat-commit-1> -d main
jj rebase -r <feat-commit-2> -A <feat-commit-1>
jj rebase -r <fix-commit-1> -A <feat-commit-2>
jj rebase -r <refactor-commit-1> -A <fix-commit-1>
jj log -r 'main..@'
Batch operations with shell loops
Process multiple commits programmatically:
for commit in $(jj log -r 'description(glob:"WIP*") & main..@' \
--no-graph --template 'commit_id ++ "\n"'); do
jj describe -r $commit -m "feat: $(jj log -r $commit --no-graph --template 'description')"
done
jj abandon 'empty() & main..@'
for commit in $(jj log -r 'description(glob:"fixup:*") & main..@' \
--no-graph --template 'commit_id ++ "\n"'); do
jj squash -r $commit
done
Integration with bookmarks
Set bookmarks after cleanup:
jj log -r 'main..@'
jj bookmark set feature-complete -r @
jj bookmark set feature-partial -r <commit>
jj git push --bookmark feature-complete
jj bookmark set feature-complete -r @ --allow-backwards
Session workflow
Typical cleanup session:
jj op log | head -n 1
jj log -r 'main..@'
jj log -r 'empty() & main..@'
jj log -r 'description(glob:"WIP*") & main..@'
jj abandon 'empty() & main..@'
jj squash -r 'description(glob:"WIP*") & main..@'
jj squash -r 'description(glob:"fixup*") & main..@'
jj log -r 'main..@'
jj log -r 'main..@'
jj bookmark set feature-name -r @
jj git push --bookmark feature-name
jj op log --limit 20
Each step is undoable.
Stop at any point and continue later.