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
| Flag | Type | Default | Meaning |
|---|---|---|---|
-f, --from REVSET | revset | @ | The source commit, exactly one (source). |
-t, --into, --to REVSETS | revsets, repeatable | mutable() | Destination candidates. Only ancestors of the source that last changed a line can receive hunks (source). |
FILESETS (positional) | filesets | all files | Absorb only changes to these paths (source). |
-i, --interactive | bool | false | First choose which changes are candidates, in a diff editor (source). |
--tool NAME | string | none | Diff editor to use. Implies -i (source). |
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
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Source commit | read, then reparented or abandoned | Its 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 commits | rewritten | A 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 file | read | Line annotation walks destinations & ::source & files(path) (FileAnnotator::compute). | trees |
| File contents | read, written | Parent and source text are materialized, and a new file blob is written per destination and path. | trees |
| Commits between the destinations and the source | rebased | So each picks up the absorbed hunks from below. | transactions |
View: heads, bookmarks, wc_commit_ids | write | Follow the rewrites. An abandoned source @ is replaced by a new empty working-copy commit. | view |
| Operation | write | One 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, wherencounts 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 undorestores 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
| Message | When | Source |
|---|---|---|
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 immutable | A 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:
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 @.