Picking Where jj absorb Lands: Adding --into to jjui

jj absorb is the command I miss most in git. Adding --into support to jjui so I can target specific ancestors from the TUI

I wrote about jj absorb recently, and how it takes your working copy changes and quietly redistributes them into the right ancestors. It's the command I miss most when I'm forced back to git. But there's a wall I kept hitting, and last week I finally did something about it.

The Problem with "Every Mutable Ancestor"

jj absorb defaults to mutable() for its destination set. Every mutable revision in your repo becomes a candidate for receiving your changes. Most of the time, this is what you want, since the algorithm only absorbs into ancestors of the source, so the list narrows itself naturally.

But "every mutable ancestor" gets unwieldy fast. I work in stacks. A typical morning looks like this:

@  follow-up fixes touching backend and UI
o  Add UI module
o  Add backend handler
o  ... five other commits I'm still iterating on

Sometimes the working copy has a fix that belongs in Add UI module , but absolutely shouldn't touch Add backend handler. Maybe that backend commit is queued for a separate PR, and I'm being careful not to mix scopes. Or maybe one of the older commits is technically mutable, but I want to keep it frozen until I get review feedback.

The CLI handles this with --into:

jj absorb --from @ --into pmmspwst

Pass it once or many times, and the destination set shrinks to exactly those revisions. But I use jjui for almost all my interactive jj work. Pressing shift+a ran a bare jj absorb, with no way to constrain. Every time I needed --into, I dropped to a terminal, navigated, typed out change IDs, and hoped I picked them right.

Building the Picker

I opened an issue. The maintainer came back with a clean design: convert absorb into an "operation" like set_parents or abandon. Mark the source revision with << absorb >>. Mark each candidate with << into >>. Bind space to toggle. Bind enter to apply.

The crucial detail: if you make no selections, it should still run plain jj absorb. Preserve the existing shift+a then enter flow exactly. Just one extra keypress if you need it.

Here's what shipped:

Three states visible. The source (working copy, sptvnwlk) shows << absorb >> in cyan. Default candidates (pmmspwst, lypnkxpn) show << into >> in red. Move the cursor, hit space on a candidate, and the marker dims to << default >>, which excludes it from the destination set but keeps it visible. Hit enter and absorb runs with --into for whatever remains.

The first version I wrote had a subtle bug: toggle every candidate off and press enter, and it would silently fall back to jj's default behavior. That's the opposite of what the user is asking for. If you explicitly deselected everything, you probably want to cancel, not run the unconstrained version.

Why Ancestors-Only for the Candidate Set

The maintainer suggested mutable() as the default candidate set, which is what jj's CLI does. I went with mutable() & ::source instead, narrowing to ancestors of the source.

The absorb algorithm explains why. jj absorb only ever distributes hunks into ancestors of the source. Non-ancestors can't receive content because the file annotation walk never visits them. jj's own docstring is explicit: "Only ancestors of the source revision will be considered."

If the picker showed non-ancestors as candidates, users could toggle them on, but absorb would silently ignore them. That's misleading UX. Showing only ancestors keeps the picker honest about what's actually possible.

The Trap of Tracked State

My first iteration tracked a userModified bool. Toggle anything, set the flag. At apply time, if the flag was true, pass --into. Otherwise, run plain absorb.

This sounds reasonable until you toggle a candidate off and then on again. You're back to the original set, but the flag is still true, so absorb runs with --into a --into b, which is functionally identical to no --into, just verbose and ugly in the command history.

Worse: if defaults are empty (small repo, no mutable ancestors), pressing space on something then space again to undo leaves you with empty targets AND a true flag. The empty-targets close logic kicks in and the operation exits without running anything. The user did nothing, but jjui treated it as "I want to cancel."

The fix was to drop the bool and compare sets at apply time. If targets == defaults, no --into. If targets is empty AND defaults wasn't, close. Otherwise, pass --into for each kept target. The state model is now derived, not tracked.

This is the kind of bug unit tests don't catch easily, because the assertion you'd write has the same shape as the bug. I wrote a test for the toggle-on-then-off path asserting "userModified stays true" and it passed because that's exactly what the buggy code did. The test confirmed the bug, not the requirement.

When the Lua API Collides

jjui exposes actions to Lua so users can script workflows. The action that opens the absorb operation was registered as revisions.absorb. The new picker scope was also revisions.absorb.

In jjui's Lua API, scopes are tables and actions are functions. Registering both means the scope table overwrites the action function. Anyone with a Lua script calling jjui.revisions.absorb() would suddenly find that function gone.

The fix was renaming the entry action from revisions.absorb to revisions.open_absorb. This matches the convention every other operation uses: open_abandon, open_squash, open_set_parents. Honestly, it should have been named that way from the start, since the action opens an operation instead of running absorb directly. The Lua collision just made the rename non-optional.

Closing

This is a small feature. But when you live in stacks, being able to constrain absorb to a single ancestor turns "drop to terminal, type carefully" into "space twice, enter." The friction difference compounds.

The PR is open against idursun/jjui. If you use jjui and have thoughts on the picker UX, that's the place.

Resources: