Rewriting History

Lesson 4 · jj for Git Users · ~12 minutes

This is the payoff lesson. Everything from Lessons 1–3 — the working copy as a commit, auto-rebase, conflicts as data — exists to make history rewriting safe and routine. In git, rewriting history is an advanced operation you do carefully. In jj, it's the normal workflow.

You'll learn six operations that cover every history rewriting scenario: jumping into past commits, splitting, squashing, reordering, and automatically distributing fixups. Each one is dramatically simpler than its git equivalent.

jj edit — Jump Into Any Commit

The most powerful command. jj edit moves your working copy into an existing commit so you can modify it directly. Any changes you make amend that commit in place, and all descendants are automatically rebased on top.

# You have a stack: A ← B ← C ← D (@)
# You realize commit B has a typo.

$ jj edit B       # @ moves to B
# Fix the typo in your editor — it auto-amends into B
$ jj new          # Create a fresh commit, returning to the top of your stack
Before: A ← B ← C ← D (@) jj edit B: A ← B (@) ← C ← D Fix typo: A ← B' (@) ← C' ← D' ← auto-rebased! jj new: A ← B' ← C' ← D' ← @ (empty, fresh)

That's it. No interactive rebase. No marking commits as "edit". No --continue. No praying for clean conflict resolution at each step.

Git: fix a commit 3 back

# Fix commit B in a stack of A ← B ← C ← D:
$ git rebase -i B~1
# Editor opens — mark B as "edit"
# Save and close
$ # Make the fix
$ git add -A
$ git commit --amend --no-edit
$ git rebase --continue
# ⚠️ Conflict at C? Resolve, git add, continue.
# ⚠️ Conflict at D? Resolve again.
# Finally done (or gave up and ran --abort)

jj: fix a commit 3 back

# Fix commit B in a stack of A ← B ← C ← D:
$ jj edit B
# Make the fix (auto-amended into B)
$ jj new
# Done. Descendants rebased automatically.
# If conflicts: stored in commits as data.
# Resolve whenever you want — nothing blocks.
edit vs new

jj new creates a fresh empty commit on top of the current one — "start something new." jj edit moves your working copy into an existing commit — "go back and fix something." They are complementary: you edit to jump somewhere, then new to leave.

jj split — Break One Commit Into Two

You made a commit that does two things and want to separate them. jj split breaks the current commit into two sequential commits. You choose what goes in the first; everything else goes in the second.

# Current commit has changes to auth.rs AND readme.md.
# You want those as separate commits.

$ jj split
# Interactive editor opens — select which changes go in the FIRST commit.
# Everything else automatically goes into the SECOND commit.

You can also split by file path without the interactive editor:

# Put auth.rs changes first, readme.md changes second:
$ jj split auth.rs
Before: A ← BigCommit (@) ← C After split: A ← Part1 ← Part2 (@) ← C' Part1 gets what you selected. Part2 gets everything else. Descendants (C) are automatically rebased.

Git: split a commit

# Split the most recent commit:
$ git reset HEAD~1
$ git add auth.rs
$ git commit -m "auth changes"
$ git add readme.md
$ git commit -m "docs update"

# Split a commit 3 back? Buckle up:
$ git rebase -i HEAD~3
# Mark as "edit" in editor
$ git reset HEAD~1
$ git add auth.rs && git commit -m "part 1"
$ git add readme.md && git commit -m "part 2"
$ git rebase --continue
# Hope nothing conflicts...

jj: split a commit

# Split any commit — same ease regardless of position:
$ jj edit <change-id>   # jump to it (skip if already there)
$ jj split               # interactively choose
$ jj new                 # return to top

# Or split by file path:
$ jj split auth.rs

# Split the current commit in one step:
$ jj split
Splitting any commit in the stack

Because jj edit lets you jump to any commit, you can combine it with jj split to break apart any commit in your history. Descendants always auto-rebase. The position of the commit in your stack doesn't affect the difficulty.

jj squash — Move Changes Into Parent

jj squash moves all changes from the current commit into its parent. If the current commit becomes empty afterward, it's automatically discarded. This is like git commit --amend, but far more flexible.

# You have: A ← B ← WIP-fix (@)
# You want the WIP-fix changes folded into B.

$ jj squash
# Changes from @ move into B.
# If @ is now empty, it vanishes. Done.

Interactive squash: choose what to move

Don't want to squash everything? Use -i to interactively select which hunks to move into the parent:

$ jj squash -i
# Interactive diff editor opens.
# Select hunks to move into parent.
# Unselected hunks stay in the current commit.

Squash into any ancestor

This is where jj goes far beyond git. You can squash changes into any ancestor commit, not just the immediate parent:

# Stack: A ← B ← C ← D (@)
# Move some changes from D directly into B:

$ jj squash --into B
# Changes from @ move into B. C and D are auto-rebased.

Git: squash into ancestor

# Move changes into an ancestor commit:
$ git stash
$ git rebase -i <ancestor>~1
# Mark ancestor as "edit" in editor
$ git stash pop
$ git add -A
$ git commit --amend --no-edit
$ git rebase --continue
# Resolve any conflicts along the way...

jj: squash into ancestor

# Move changes into any ancestor:
$ jj squash --into <change-id>
# Done. One command. Auto-rebase handles the rest.

Reordering Commits with jj rebase

Need to change the order of commits in a stack? jj rebase has three key flags for different scenarios:

# Stack: A ← B ← C ← D (@)
# You want C to come after D instead.

$ jj rebase -r C -A D
# -r: the single revision to move
# -A: place it After this revision
Before: A ← B ← C ← D (@) After: A ← B ← D' ← C' (@) -r C detaches C from the chain. -A D places it after D. Everything rebases automatically.

You can also move a commit and all its descendants together with -s (source):

# Move commit C and everything after it onto a different base:
$ jj rebase -s C -d A
# Now: A ← C' ← D' (B is orphaned from this branch)
Rebase flag reference

-r moves a single revision, re-parenting its children to its former parent. -s moves a revision and all its descendants together as a subtree. -d sets the destination parent. -A inserts after a specific revision in an existing chain.

Git: reorder commits

# Reorder commits in a stack:
$ git rebase -i HEAD~4
# Editor opens with all 4 commits listed.
# Manually rearrange the lines.
# Save and close.
# ⚠️ Conflicts possible at each step.
# Resolve, add, continue for each one.

jj: reorder commits

# Reorder commits in a stack:
$ jj rebase -r C -A D
# One command. Immediate. Auto-rebase.

# Or move a subtree:
$ jj rebase -s C -d main
# Moves C and all descendants onto main.

Why Auto-Rebase Makes All of This Safe

Every operation above — edit, split, squash, rebase — modifies commits that have descendants. In git, this is where things get terrifying. In jj, every history rewrite immediately and automatically propagates to all descendants.

The key insight: if conflicts arise during auto-rebase, they're stored as data in the affected commits. Your operation always succeeds. You're never stuck in a half-finished rebase state with no clear way out.

Git: the rebase fear loop

# Rewriting commit 5 back in a stack:
$ git rebase -i HEAD~5
# ⚠️ Conflict at commit 3!
# Must resolve NOW or abort everything.
$ git add . && git rebase --continue
# ⚠️ Another conflict at commit 4!
# Resolve again...
$ git add . && git rebase --continue
# Finally done. Or gave up: git rebase --abort
# (losing all progress)

jj: fearless rewriting

# Rewriting commit 5 back in a stack:
$ jj edit <change-id>
# Make changes. Done immediately.
# If descendants conflict:
$ jj log   # Shows which commits have conflicts
# Fix them whenever you want, in any order.
# No blocked state. No --abort. No lost work.
Conflicts are deferred, not ignored

When auto-rebase creates conflicts, jj marks those commits clearly in jj log (you'll see "conflict" markers). You still need to resolve them before pushing. The difference: you choose when to resolve, and you can continue working on other commits in the meantime. You're never forced to stop everything mid-operation.

jj absorb — Auto-Distribute Fixups

jj absorb is the cherry on top. You have uncommitted changes in your working copy that are clearly fixups to specific earlier commits. Instead of manually figuring out which commit each fix belongs to, absorb does it automatically.

# You've made small fixes to 3 files.
# Each fix touches lines introduced by different prior commits.

$ jj absorb
# jj analyzes each changed line, finds which commit introduced it,
# and squashes each hunk into the appropriate ancestor commit.
# All descendants auto-rebase.

Think of it as an automatic git commit --fixup + git rebase --autosquash, but in a single command with no manual commit creation or rebase step.

Git: fixup workflow

# Fix belongs in commit abc123:
$ git add file.rs
$ git commit --fixup=abc123
# Repeat for each fix...
$ git commit --fixup=def456
$ git add other.rs
$ git commit --fixup=abc123

# Now autosquash them all:
$ git rebase -i --autosquash HEAD~5
# Hope no conflicts...

jj: absorb

# All fixes are in your working copy:
$ jj absorb
# Done. Each hunk sent to the right commit.
# No fixup commits. No rebase step.
When to use absorb

Use jj absorb when you've accumulated small fixes in your working copy that logically belong in different prior commits. It's most useful when preparing a stack for code review — you address feedback, then absorb sends each fix to the right place automatically.

Putting It All Together

Here's a realistic workflow showing these commands in sequence — preparing a feature stack for review:

# Your feature stack:
$ jj log --short
@  vrukpmsm  (empty) working copy
○  zsqolypn  add API endpoint
○  tpylkqrs  add database migration
○  kwmxnslp  add data model

# Reviewer says: "Split the API endpoint commit — tests are mixed in."
$ jj edit zsqolypn
$ jj split tests/
$ jj describe -m "add API tests"
$ jj new

# Reviewer says: "The model commit has a typo in a field name."
$ jj edit kwmxnslp
# Fix the typo
$ jj new

# You notice two small fixes that belong in different commits:
$ jj absorb
# Each fix sent to the right ancestor automatically.

# All done. Every descendant auto-rebased after each operation.
# Force-push to update the PR:
$ jj git push

Check Your Understanding

What is the key difference between jj edit and jj new?
Correct! jj edit makes an existing commit your working copy — changes amend it in place. jj new creates a fresh empty commit on top of wherever you are. They're complementary: edit to jump back, new to move forward.
Not quite. jj edit moves your working copy into an existing commit so any changes amend it. jj new creates a fresh empty commit on top of the current one. Think: "edit = go back and fix," "new = start something fresh."
How do you split a commit that contains changes to 3 files into two commits?
Correct! jj split opens an interactive editor where you select what goes in the first commit. Everything you don't select automatically becomes the second commit. You can also pass file paths directly: jj split file1.rs file2.rs.
Not quite. jj split is the dedicated command for this. It opens an interactive selector (or accepts file paths as arguments) to choose what goes in the first commit. The rest goes in the second. No rebase or undo needed.
After jj edit modifies a commit mid-stack, what happens to its descendants?
Correct! Auto-rebase is the key feature that makes all jj history rewriting safe. Descendants are immediately rebased. If conflicts arise, they're stored as data in the affected commits — your operation always succeeds and you resolve conflicts on your own schedule.
Not quite. jj's auto-rebase automatically propagates changes to all descendants whenever an ancestor is modified. If conflicts arise, they're stored as data in the affected commits. Nothing blocks — you resolve conflicts whenever you choose.
Primary Source

Official jj Tutorial — Moving content between commits — covers edit, split, squash, and absorb with additional examples and edge cases.

Questions? This lesson covers the core of what makes jj special. If anything about edit, split, squash, rebase, or absorb is unclear — or you want to walk through a specific scenario from your own git workflow that would be simpler in jj — ask me.
← Prev Next →