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> # Execute immediately on specified commits
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:
# Move commit C before commit B (where C currently comes after B)
jj rebase -r C -B B
# Move commit C after commit A (where C currently comes before A or elsewhere)
jj rebase -r C -A A
# Move commit to new parent
jj rebase -r C -d <parent>
# Move entire subtree
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:
# Squash commit into its parent (equivalent to git fixup)
jj squash -r <commit>
# Squash with specific message
jj squash -r <commit> -m "combined message"
# Squash commit into specific ancestor
jj squash --from <commit> --into <ancestor>
# Interactive squash (select hunks)
jj squash -i -r <commit>
# Squash all WIP commits into their parents
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. -uis preferred when the destination already has the correct description.
Drop commits
Remove commits from history:
# Abandon specific commit
jj abandon <commit>
# Abandon multiple commits by description
jj abandon 'description(glob:"tmp:*")'
# Abandon empty commits
jj abandon 'empty()'
# Abandon range of commits
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:
# Reword single commit (ALWAYS use -m for non-interactive)
jj describe -r <commit> -m "new description"
# Reword multiple commits by pattern
jj describe -r 'description(glob:"WIP:*")' -m "proper description"
# Clear description (useful for commits that will be squashed)
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:
# Approach 1: Edit in place (checkout commit)
jj edit <commit>
# Make changes to files
# Changes automatically amend the commit
jj new @- # Return to previous location
# Approach 2: Edit without checkout
jj diffedit -r <commit>
# Opens diff editor, changes apply directly to commit
# Approach 3: Move specific changes
jj edit <commit>
# Make partial changes
jj commit <files> # Move some changes to new child
# Remaining changes stay in edited commit
jj new @- # Return to original location
Split commits
Divide a commit into multiple logical commits:
# Non-interactive split by paths (ALWAYS use -m)
jj split <paths> -m "description for selected changes"
# Specified paths go to first commit with description, rest to second
# Split specific commit by paths (non-interactive)
jj split -r <commit> <paths> -m "description"
# Interactive split (TUI to select hunks) - avoid in automation
jj split -r <commit>
# Opens diff editor - cannot be non-interactive
# Split current working copy (non-interactive)
jj split <paths> -m "description"
# Without -r, splits @ commit
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:
# Squash all WIP commits, then abandon empty commits
jj squash -r 'description(glob:"WIP:*")'
jj abandon 'empty()'
# Reword all commits by author before squashing
jj describe -r 'author("alice")' -m "Alice's changes"
jj squash -r 'author("alice")'
# Move all unfinished commits to separate branch
jj rebase -s 'description(glob:"TODO:*")' -d <elsewhere>
Robust patterns
Incremental cleanup workflow
Unlike git's all-or-nothing rebase, clean up history incrementally:
# Phase 1: Review what needs cleaning
jj log -r 'main..@'
# Phase 2: Squash obvious fixups (commits with "fixup", "oops", etc)
jj squash -r 'description(glob:"fixup*")'
jj squash -r 'description(glob:"oops*")'
# Phase 3: Reorder if needed
jj log -r 'main..@' # Identify order issues
jj rebase -r <commit> -A <after> -B <before>
# Phase 4: Abandon or squash temporary commits
jj abandon 'empty()'
jj squash -r 'description(glob:"WIP:*")'
# Phase 5: Reword remaining commits
jj log -r 'main..@'
jj describe -r <commit1> -m "proper message"
jj describe -r <commit2> -m "proper message"
# Phase 6: Verify
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
# Before starting cleanup, note operation ID
jj op log | head -n 3
# @ a1b2c3d4 ...
# Do cleanup operations
jj squash -r X
jj rebase -r Y -A Z
jj describe -r W -m "message"
# If unhappy with results
jj undo # Undo last operation
jj undo # Undo one more
jj op restore a1b2c3d4 # Or restore to beginning
# Alternative: View operation log and selectively restore
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:
# After operation that creates conflict
jj log
# Shows: @ abc123 (conflict) my commit
# Option 1: Resolve immediately
jj new @
# Fix conflicts in working copy
jj squash # Move resolution into conflicted commit
# Option 2: Resolve in place
jj edit abc123
# Fix conflicts (automatically amends)
jj new @-
# Option 3: Undo and try different approach
jj undo # Undo the operation that caused conflict
# Try different operation order
# Check for any conflicts in range
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') # the [wip] on the join
for commit in $(jj log -r 'main..@ ~ @' --no-graph -T 'commit_id ++ "\n"'); do
jj new $commit # side change for testing only
cargo build && cargo test || echo "failed: $commit"
jj abandon @ # discard the side change
done
jj edit $orig # return to the original [wip]
# confirm: jj log -r @ shows an empty change whose parent is the merge
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:
# Build/test each commit in range
for commit in $(jj log -r 'main..@' --no-graph --template 'commit_id ++ "\n"'); do
echo "Testing $commit"
jj new $commit
# Run build
cargo build || echo "Build failed in $commit"
# Run tests
cargo test || echo "Tests failed in $commit"
done
# Return to original location
jj new @-
Or use external script:
# test-range.sh
#!/bin/bash
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
# Usage
./test-range.sh 'main..@'
Auto-distribute changes with absorb
For fixing earlier commits automatically:
# Make fixes in working copy
# Fix bug in file1.txt (last modified by commit A)
# Improve file2.txt (last modified by commit B)
# Refactor file3.txt (last modified by commit C)
# Automatically move each change to the commit that last touched it
jj absorb
# jj analyzes blame info and distributes changes
# file1.txt fix goes to commit A
# file2.txt improvement goes to commit B
# file3.txt refactor goes to commit C
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
# Step 1: Review and identify cleanup strategy
# - Squash zzz999 into abc123
# - Drop yyy888 (debug)
# - Squash mno345 and jkl012 into ghi789
# - Squash def456 into ghi789
# - Potentially reorder or combine X and Y features
# Step 2: Squash feature X fixup
jj squash --from zzz999 --into abc123
# Step 3: Drop temporary debug commit
jj abandon yyy888
# Step 4: Combine feature X commits
jj squash -r xxx777 # Squash into abc123
# Now abc123 contains all feature X work
# Step 5: Combine all feature Y commits
jj squash -r def456 # Into ghi789
jj squash -r jkl012 # Into ghi789
jj squash -r mno345 # Into current @
# Now ghi789 contains all feature Y work
# Step 6: Reword both commits
jj describe -r abc123 -m "feat: implement feature X with comprehensive tests"
jj describe -r ghi789 -m "feat: implement feature Y with error handling"
# Step 7: Verify
jj log -r 'main..@'
# @ ghi789 feat: implement feature Y with error handling
# ○ abc123 feat: implement feature X with comprehensive tests
# Step 8: Test each commit
jj new abc123 && cargo test && jj new @-
jj new ghi789 && cargo test && jj new @-
# Step 9: If all good, update bookmark
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:
# View final history
jj log -r 'main..@'
# Verify diff against target base is unchanged
jj diff -r main..@
# Should show same total changes as before cleanup
# Check each commit builds
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 @-
# Verify descriptions follow conventions
jj log -r 'main..@' --template 'description ++ "\n"'
# Check for conflicts
jj log -r 'conflict() & main..@'
# Should be empty
# Review operation history
jj op log --limit 20
# Shows all cleanup operations performed
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 undoreverses 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 revertreverses a change. Requires explicit placement:--onto,--insert-before, or--insert-after.- Reference
~/.claude/skills/jj-git-interactive-rebase-to-jj/SKILL.mdfor detailed command mappings
Advanced patterns
Linearize merge commits
Convert merge-heavy history to linear sequence (computing a linear extension of the commit partial order):
# Identify merge commits
jj log -r 'merge() & main..@'
# For each merge, decide to keep or linearize
# To linearize: rebase one branch onto the other
jj rebase -s <branch-head> -d <main-branch>
Extract commits to separate branch
Move unrelated work to different branch:
# Identify commits to extract
jj log -r 'description(glob:"*unrelated*") & main..@'
# Create new bookmark for extracted work
jj bookmark create unrelated-work -r <first-unrelated-commit>
# Rebase unrelated work onto main
jj rebase -s <first-unrelated-commit> -d main
# Original branch now has hole - descendants rebased appropriately
jj log -r 'main..@'
Reorder and group by semantic category
Group commits by type (feat/fix/refactor/test/docs):
# List all commits with types
jj log -r 'main..@' --template 'description ++ "\n"'
# Reorder so similar types are adjacent
# feat commits first
jj rebase -r <feat-commit-1> -d main
jj rebase -r <feat-commit-2> -A <feat-commit-1>
# Then fix commits
jj rebase -r <fix-commit-1> -A <feat-commit-2>
# Then refactor commits
jj rebase -r <refactor-commit-1> -A <fix-commit-1>
# Review grouping
jj log -r 'main..@'
Batch operations with shell loops
Process multiple commits programmatically:
# Reword all commits matching pattern
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
# Abandon all empty commits in range
jj abandon 'empty() & main..@'
# Squash all fixup-style commits
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:
# After cleaning up history
jj log -r 'main..@'
# Set bookmark on final commit
jj bookmark set feature-complete -r @
# Or set bookmark on specific commit
jj bookmark set feature-partial -r <commit>
# Push cleaned history
jj git push --bookmark feature-complete
# If bookmark already exists and needs updating
jj bookmark set feature-complete -r @ --allow-backwards
Session workflow
Typical cleanup session:
# 1. Start session
jj op log | head -n 1 # Note starting operation for possible restore
# 2. Survey work
jj log -r 'main..@'
jj log -r 'empty() & main..@'
jj log -r 'description(glob:"WIP*") & main..@'
# 3. Clean in phases
jj abandon 'empty() & main..@'
jj squash -r 'description(glob:"WIP*") & main..@'
jj squash -r 'description(glob:"fixup*") & main..@'
# 4. Reorder if needed
jj log -r 'main..@'
# Manually rebase commits into logical order
# 5. Update descriptions
jj log -r 'main..@'
# Manually describe each commit
# 6. Verify atomicity
# Test each commit individually
# 7. Set bookmark and push
jj bookmark set feature-name -r @
jj git push --bookmark feature-name
# 8. View operation summary
jj op log --limit 20
Each step is undoable. Stop at any point and continue later.