The Magic of `jj absorb`: Rewriting History Without the Pain
I was fixing a typo that spanned six commits With git, that meant an interactive rebase, editing each commit, resolving conflicts. With jj, it was one command: jj absorb.
I was fixing a typo that spanned six commits With git, that meant an interactive rebase, editing each commit, resolving conflicts. With jj, it was one command: jj absorb.
Why Interactive Rebases Are a Dead End for Cross-Cutting Changes
I've been using git for many years, and I've performed thousands of interactive rebases. They're fine for small touch-ups, squashing a WIP commit, rewording a message, or dropping a debug statement you accidentally committed. But they break down when you need to make the same mechanical change across multiple commits.
The problem isn't just the manual labor of editing each commit. It's the mental overhead of tracking state across a rebase session. Did I already fix the migration file? Which commit touched the context module? Why am I resolving a merge conflict in the middle of what should be a simple find-and-replace?
Worse, if you realize mid-rebase that you missed something, your options are limited. You can abort and start over, or you can push forward and hope the remaining commits don't introduce more issues. The operation is stateful, and that state lives in your working directory, which means you can't easily context-switch to something else while it's in progress.
Jujutsu (jj) approaches this differently. Its core insight is that local commits are mutable drafts, not immutable history.
The Problem with Fixing Mistakes Across Commits
Here's a scenario that happens more often than I'd like to admit. You've built a subscription feature over two days, six carefully structured commits. Migration, schema, context functions, tests - each commit stands on its own. The pull request is ready for review.
Then the comment comes in: "Hey, we use American English. It's canceled, not cancelled."
A quick grep reveals the problem is everywhere. The migration defines a cancelled enum. The schema references :cancelled. The context module has a mark_as_cancelled/1 function. The tests assert on :cancelled. Four commits, all with the same mistake repeated.
In Git, you have two options. You can tack on a "fix spelling" commit at the end, which pollutes your history with a cleanup patch that has nothing to do with the feature itself. Or you can fire up git rebase -i, mark four commits for editing, manually fix each one in sequence, resolve any conflicts that arise, and hope you don't make things worse. I've done both. The first feels sloppy. The second is tedious enough that I sometimes just live with the mistake.
This is the exact problem jj absorb solves.
What jj absorb Actually Does
Jujutsu is a Git-compatible version control system that rethinks some core assumptions. The most important one: local commits are mutable drafts, not carved-in-stone history. This makes operations like history rewriting feel natural instead of dangerous.
The absorb command takes your current working copy changes and automatically distributes them back into the commits that last modified those lines. You make the fix once, and jj figures out where each piece belongs.
A Concrete Example
Let's walk through the subscription spelling issue with real code. You have four commits in your branch:
@ f8a2b1c Add subscription cancellation tests
|
o e7d3c4a Add cancel_subscription/1 to context module
|
o d6b2a9f Add Subscription schema with status field
|
o c5a1e8d Add subscriptions migration with status enum
Each commit contains the British spelling. The migration defines an enum with 'cancelled'. The schema has
field :subscription_status, Ecto.Enum, values: [:pending, :confirmed, :cancelled] The context function is named mark_as_cancelled/1. The tests call this function and assert on :cancelled.
With Git, fixing this means an interactive rebase where you edit each commit in turn, run your replacement, stage changes, continue the rebase, and repeat. If you've done this, you know the friction. It's not hard, but it's enough work that you think twice before doing it.
With jj, you fix everything at once:
# Make all the changes in your working copy
sed -i 's/cancelled/canceled/g' priv/repo/migrations/*_create_subscriptions.exs
sed -i 's/cancelled/canceled/g' lib/my_app/subscriptions/subscription.ex
sed -i 's/cancelled/canceled/g' lib/my_app/subscriptions.ex
sed -i 's/cancelled/canceled/g' test/my_app/subscriptions_test.exs
Then you run a single command:
jj absorb
The output tells you what happened:
Absorbed changes into 4 revisions:
f8a2b1c -> f8a2b1c' Add subscription cancellation tests
e7d3c4a -> e7d3c4a' Add cancel_subscription/1 to context
d6b2a9f -> d6b2a9f' Add Subscription schema with status field
c5a1e8d -> c5a1e8d' Add subscriptions migration with status enum
Rebased 0 descendant commits.
The command analyzed each changed line, found which commit last touched it, grouped the changes accordingly, applied them to the appropriate commits, and rebased descendants. Your history now shows that canceled was the spelling from the beginning. No fixup commits, no manual rebasing.
When This Fits Your Workflow
The sweet spot for absorb is when you have a mechanical change that needs to apply across multiple recent commits. Spelling inconsistencies are the obvious case. Renaming functions or variables that appear in several commits is another. Updating configuration URLs, standardizing on a code pattern after receiving review feedback, or fixing a consistent typo you made ten times, all of these are perfect candidates.
It works less well for structural refactors where the change affects how code is organized, not just text replacement. And if the same line was modified differently in multiple commits, jj will detect the conflict and refuse to proceed, which is the right behavior.
The other important constraint: don't use this on commits you've already pushed to a shared branch. Local history is yours to rewrite; public history is not.
Some Useful Variations
You can limit the scope if needed. To absorb only specific files:
jj absorb lib/my_app/subscriptions.ex test/my_app/subscriptions_test.exs
To restrict how far back jj looks:
jj absorb --into 'ancestors(@, 5)' # Only consider last 5 commits
And if you're unsure, preview the plan:
jj absorb --dry-run
If something goes wrong, jj's undo command is your safety net. It maintains an operation log of everything you've done.
The Draft Mentality
What makes absorb possible is jj's core philosophy: local commits are drafts. Traditional Git teaches that history is sacred, which makes rewriting feel dangerous and transgressive. jj says your local work is malleable and provides first-class tools to reshape it.
When history rewriting is easy, you do it more often. When you do it more often, you keep your commits clean by default. Clean commits make code review smoother, and smooth code review makes better software.
Getting Started
You don't need to abandon Git entirely. Initialize jj in an existing repository:
cd your-repo
jj git init --colocate
Your Git history remains intact, and you can use both git and jj commands. Start with jj absorb on a feature branch and see how it feels. The official documentation is solid, though light on this particular command, so I've also found Steve Klabnik's tutorial helpful for building intuition.
jj absorb won't change how you think about version control overnight, but it might change what you consider reasonable effort for maintaining a clean history. For me, that was enough to make the switch.
Resources:
- jj documentation
- Steve Klabnik's jj tutorial
- git-absorb (Git approximation)