jj / How jj absorb works

How jj absorb works

jj absorb takes the changes in a commit (by default @) and moves each hunk into the mutable ancestor that last touched those lines, using line annotation (blame). Hunks it can't place unambiguously stay where they are.

On this page
  1. 1. Command-line surface
  2. 2. Configuration
  3. 3. End-to-end flow
  4. 4. Core architecture touchpoints
  5. 5. Transaction and operation
  6. 6. Algorithm and invariants
  7. Attribution
  8. Assigning hunks
  9. Applying
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: for every modified file in the source, jj annotates the parent's version of the file within the destination set (default mutable()). That attributes each line to the closest destination commit that last changed it. It then diffs parent against source and assigns each hunk to the commit owning the lines it touches. A hunk is left alone when it spans several owners, when it sits exactly between two of them, or when its lines belong to no destination. The selected hunks are merged into each destination in one transform_descendants walk. The source is reparented, so its absorbed hunks vanish from its diff, and it is abandoned if that leaves it empty and undescribed. The operation is absorb changes into <n> commits.

The command is cmd_absorb. The planning and rewriting live in jj-lib's absorb.rs (split_hunks_to_trees and absorb_hunks), and attribution is FileAnnotator, the engine behind jj file annotate. Compare jj squash, which moves changes into one destination you choose. The help text suggests reviewing the result with jj op show -p (source).

1. Command-line surface

FlagTypeDefaultMeaning
-f, --from REVSETrevset@The source commit, exactly one (source).
-t, --into, --to REVSETSrevsets, repeatablemutable()Destination candidates. Only ancestors of the source that last changed a line can receive hunks (source).
FILESETS (positional)filesetsall filesAbsorb only changes to these paths (source).
-i, --interactiveboolfalseFirst choose which changes are candidates, in a diff editor (source).
--tool NAMEstringnoneDiff editor to use. Implies -i (source).

AbsorbArgs

2. Configuration

No absorb-specific keys. The default destination set mutable() is built from revset-aliases."immutable_heads()", so that setting decides which ancestors are candidates. ui.diff-editor and ui.diff-instructions apply to -i (see the commit page). user.name/user.email become the committer of rewritten commits.

3. End-to-end flow

jj absorb: call flow cmd_absorb loads the workspace through the shared lifecycle, which snapshots the working copy. It resolves the source and the destination revsets, builds the matcher, and warns on unmatched paths. With -i it selects candidate changes in a diff editor and errors if none are selected; either way it builds an AbsorbSource. split_hunks_to_trees annotates each modified file's parent version within the destinations, maps diff hunks to annotated commits, and builds a tree per destination, skipping symlinks, conflicts and other unsupported files with a warning. The destinations are checked rewritable. In a transaction absorb_hunks walks the destinations' descendants: each destination is rebased and merged with its selected tree, the source is reparented and abandoned if discardable, others are rebased. tx.finish records "absorb changes into N commits", then any remaining changes in the source are summarized. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) resolve source (one commit) destinations = union(--into), default mutable() matcher; warn on unmatched paths AbsorbSource -i: diff editor picks candidates ("No changes selected" if none); else whole commit split_hunks_to_trees: for each modified file (parent tree → source tree) • new file → skipped (no owner); symlink, conflict, submodule, access denied → "Skipping <path>: <reason>" • FileAnnotator on the parent's text within the destinations → line ranges owned by commits • line diff parent → source; split_file_hunks assigns each unambiguous hunk to one owner • per owner: parent tree + its hunks → MergedTreeBuilder check_rewritable(owners) start_transaction absorb_hunks: transform_descendants(owners) owner: rebase, then merge(own tree, source parent tree, selected tree) source: reparent; abandon if discardable anything else: rebase "Absorbed changes into N revisions:" …; tx.finish(ui, "absorb changes into N commits") (CLI page); then "Remaining changes:" with a summary diff if the source still has changes

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Source commitread, then reparented or abandonedIts tree stays the same while its parents gain the absorbed hunks, so those hunks leave its diff. It is abandoned when that makes it empty and it has no description (source).commits
Destination commitsrewrittenA 3-way merge adds their hunks to their (rebased) tree. The source is added to their predecessors, so the evolution log shows where the change came from (source).commits
Ancestor history of each filereadLine annotation walks destinations & ::source & files(path) (FileAnnotator::compute).trees
File contentsread, writtenParent and source text are materialized, and a new file blob is written per destination and path.trees
Commits between the destinations and the sourcerebasedSo each picks up the absorbed hunks from below.transactions
View: heads, bookmarks, wc_commit_idswriteFollow the rewrites. An abandoned source @ is replaced by a new empty working-copy commit.view
OperationwriteOne operation. If nothing could be absorbed, the view doesn't change and finish records nothing.operations

5. Transaction and operation

  • Transaction: one, started after planning (source). With no hunks to move it changes nothing, and jj prints Nothing changed.
  • Operation description: absorb changes into <n> commits, where n counts the rewritten destinations (source). It names no commit IDs.
  • Commits created or rewritten: every destination that received hunks, every commit between them and the source, and the source itself, which is reparented or abandoned.
  • Working copy: snapshotted first, so your latest edits are what gets absorbed. If @ was the source and is abandoned, you get a new empty @. If it was only reparented, its tree is the same and no files change.
  • jj undo restores every destination, the intermediate commits and the source.

6. Algorithm and invariants

Attribution

For each file whose content differs between the source's parent tree and its tree (source), the parent's text is annotated starting at the source commit (with_file_content) and walking ancestors within the destination set (source). Each line ends up owned by the nearest destination commit that last changed it. Lines last changed outside the set, such as immutable history, stay unresolved and are dropped from the owner list. Consecutive lines with the same owner are merged into ranges (compact_line_ranges).

Assigning hunks

split_file_hunks walks the line diff's changed hunks alongside the owner ranges:

  • Pure deletion: may span several owners, as long as the owned ranges cover the deleted lines contiguously. Each owner then gets the part of the deletion inside its range (source).
  • Anything else (modification or insertion) must lie inside a single owner's range. A pure insertion exactly at the boundary between two owners is ambiguous and skipped. An insertion at the start or end of a file goes to the owner of the adjacent lines (source).

For each owner, jj rebuilds the file from the parent text, splicing in only that owner's hunks (combine_texts). It writes the result into a tree that starts as the source's parent tree. A file deletion is propagated only if a single owner receives the whole deletion. New files have no owner, and are never absorbed. Symlinks, conflicts, submodules and unreadable files are skipped with a warning (to_file_value).

Applying

absorb_hunks runs one transform_descendants over the owners, so each commit is rewritten once and parents come first. An owner is first rebased, picking up absorbed changes from owners below it, then merged: merge(rebased tree, source parent tree, selected tree), which adds exactly its hunks. Any other commit in between is rebased. The source is reparented, not rebased. Its content stays the same, so the hunks that moved below it simply stop being part of its diff.

Invariants: the source's final tree, and the files on disk, are unchanged. Every hunk lands either in exactly one owner or stays in the source. Nothing is absorbed into a commit outside the destination set, or one that isn't an ancestor. Immutable commits are never rewritten: they're excluded by default, and if you name them explicitly the command refuses.

7. Errors and edge cases

MessageWhenSource
No changes selected-i and the selection matches the parent.absorb.rs
Warning: Skipping <path>: Is a symlink / Is a conflict / Is a Git submodule / Access is denied: …The file's before or after value isn't a plain file.absorb.rs, lib absorb.rs
Warning: No matching entries for paths: …An explicit path isn't in the source tree.absorb.rs
Commit … is immutableA hunk's owner is immutable, which is only possible with an explicit --into such as all() (upstream test).absorb.rs
Nothing changed.No hunk could be placed. This also covers an empty source.cli_util.rs
Absorbed changes into <n> revisions:, Rebased <n> descendant commits., Remaining changes:Status output. The last is followed by a summary diff of what stayed in the source.absorb.rs

8. Worked example

From upstream's test_absorb_simple. Commit 1 wrote 1a, 1b into file1, and commit 2 appended 2a, 2b. In a new @, the test inserts 1X at the top and 2Z at the bottom, then runs jj absorb:

jj absorb: assigning two inserted lines Annotation of the parent's file1 gives lines 1a and 1b to commit 1 and lines 2a and 2b to commit 2. The source inserts 1X before 1a, which is inside commit 1's range at the file start, and 2Z after 2b, at the end of commit 2's range. 1X is absorbed into commit 1 and 2Z into commit 2. The source becomes empty and undescribed, so it is abandoned and a new empty working-copy commit takes its place. A later insertion Y between 1b and 2a sits on the boundary of two owners and is left in place. @'s diff to file1 owner (annotation of the parent) result + 1X 1a 1b 2a 2b + 2Z commit 1 (1a, 1b) commit 2 (2a, 2b) 1 ← +1X bd7d4016 2 ← +2Z 95568809 @ abandoned new empty @ yqosqzyt + Y (between 1b and 2a) later step: on the boundary of two owners → ambiguous, stays in @ ("Nothing changed.")

Upstream's snapshot of the output:

Absorbed changes into 2 revisions:
  zsuskuln 95568809 2
  kkmpptxz bd7d4016 1
Working copy  (@) now at: yqosqzyt 977269ac (empty) (no description set)
Parent commit (@-)      : zsuskuln 95568809 2

1X is an insertion at the start of the file, adjacent only to commit 1's lines. 2Z is at the end, adjacent only to commit 2's lines. Each has exactly one owner. The source was then empty with no description, so it was abandoned and a fresh @ created, as the new change ID shows. Commit 2 was rewritten once, after commit 1, because the walk goes parents first. The operation is absorb changes into 2 commits. Later in the same test, an inserted Y between 1b and 2a touches two owners' boundary, so jj absorb prints Nothing changed. and leaves it in @.