> ## Content Index
> Fetch the complete content index at: https://til.codes/llms.txt
> Use this file to discover other available public pages before exploring further.

# Picking Where jj absorb Lands: Adding --into to jjui
- URL: https://til.codes/picking-where-jj-absorb-lands-adding-into-to-jjui/
- Published: 2026-04-18T23:14:13.000Z
- Updated: 2026-04-18T23:14:13.000Z
- Description: jj absorb is the command I miss most in git. Adding --into support to jjui so I can target specific ancestors from the TUI
- Author: Manu Ajith
- Tags: jj, Jujutsu

I [wrote about jj absorb recently](https://til.codes/the-magic-of-jj-absorb-rewriting-history-without-the-pain), 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](https://github.com/idursun/jjui?ref=til.codes) 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](https://github.com/idursun/jjui/issues/634?ref=til.codes). 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:

![](https://til.codes/content/images/2026/04/image.png)

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](https://github.com/jj-vcs/jj/blob/main/cli/src/commands/absorb.rs?ref=til.codes): *"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](https://github.com/idursun/jjui?ref=til.codes). If you use `jjui` and have thoughts on the picker UX, that's the place.

**Resources:**

- [jjui](https://github.com/idursun/jjui?ref=til.codes)
- [GitHub issue #634](https://github.com/idursun/jjui/issues/634?ref=til.codes)
- [jj absorb docs](https://docs.jj-vcs.dev/cli/?ref=til.codes#jj-absorb?ref=til.codes)