How I clean up commits
This is one of the workflows that makes jj feel very natural to me.
I do not need to make perfect commits while I am thinking.
I can work a little messily, look at the result, and then shape it into commits that are nice to review.
Sometimes that means splitting one change into two. Sometimes it means squashing a small fix into an older commit. Sometimes it means letting jj absorb do the obvious part for me.
Split the mess after the fact
When I have a few things mixed together in the working copy, I start by looking at the diff:
jj diff
If the current change is really two ideas, I split it:
jj split
Definition: split
jj split takes one revision and splits its changes into two commits. By default it opens a diff editor, so I choose which lines belong in the first commit and leave the rest for the second commit.
For example, maybe I changed both the explanation of bookmarks and the conflict-resolution chapter in the same pass. That is fine while writing, but I probably want those as two separate review commits.
After the split, I check the shape again:
jj log -r main..@
jj diff -r @-
If I want to split an older commit instead of the current working-copy change, I point jj split at it:
jj split -r <change>
Agent-shaped work
This gets especially useful with agents. I can ask an agent to make progress, then use jj split, jj squash, and jj absorb to turn the result into commits that tell a clean story. The agent helps with the typing; jj helps me keep authorship and review shape under control.
Move a fix to the commit it belongs in
Sometimes I am at the top of a stack and I notice something small:
- a typo that belongs in the first PR
- a test fix that belongs in the second PR
- a cleanup that should not become a separate review commit
With jj, I usually make the change in the current working-copy change, then move it later.
Imagine this stack:
main
docs: create mdbook skeleton
docs: explain mental model and aliases
docs: add common jj workflow examples
@ dirty working-copy change
I realize the dirty change actually belongs in docs: explain mental model and aliases.
First I find the change I want to fix:
jj log -r main..@
With my config, that is:
jj stack
Then I move the current working-copy change into that older change:
jj squash --into <change>
Definition: squash
jj squash moves changes from one revision into another revision. If I do not pass --from, the source is the current working-copy change, @.
That is the basic move I use most often:
- Do the work at the top of the stack.
- Decide where it really belongs.
- Squash it into that change.
- Keep going.
After that, I review the target change:
jj diff -r <change>
If I only want part of the current diff, I do it interactively:
jj squash -i --into <change>
Squash a fixup commit
Sometimes I already committed the fixup:
jj commit -m "wip: fix wording in aliases section"
Right after that, the fixup commit is usually @-.
If the fixup should go into its parent, this works:
jj squash -r @-
Careful with -r
jj squash -r <change> does not mean "squash into <change>". It means "take <change> and squash it into its parent". That is perfect for a fixup commit directly above the commit it fixes, but it is not the command I use when the target is somewhere else in the stack.
If the fixup commit belongs in a different earlier change, I am explicit:
jj squash --from @- --into <change> --use-destination-message
I like --use-destination-message for this because the wip: message was only temporary. The older commit already has the real review title.
Absorb
jj absorb is the more automatic version of this idea.
Instead of me choosing the destination commit, jj looks at the current changes and tries to move each chunk into the closest mutable ancestor where those lines were last modified:
jj diff
jj absorb
If I want to limit the destination to my current stack, I can be more explicit:
jj absorb --into 'main..@-'
Mercurial note
If you came from Mercurial, the name is doing a lot of work here. jj absorb does not feel quite as uncanny to me as hg absorb did, but it still handles the boring fixup case well enough that I reach for it.
Review absorb
absorb is a guess. When it cannot choose a destination clearly, it leaves the change where it is. When it does move things, I still review the operation before pushing.
The review command I use is:
jj op show -p
Then I check the stack again:
jj log -r main..@
jj diff -r @-
With my config, the first command is:
jj stack
If the stack was already pushed, this is another moment where I push every temporary PR branch again:
jj git push -b 'push-*'
With my config:
jj ps