In short: jj picks the sources and the destination (@ → @-,
-r X → X's only parent, or --from/--into), and
refuses if any of them is immutable. For each source it selects a diff: all of it, the given paths,
or what you pick in a diff editor. jj-lib's squash_commits then removes that diff from
each source, abandoning a source whose whole diff was taken. It applies all the selected diffs to the
destination as a 3-way merge. The description is your -m, the destination's
(-u), or a combination. If more than one involved commit has a description, an editor
opens. One operation records it all: squash commits into <destination id>.
The command is cmd_squash. The tree arithmetic
lives in jj-lib's rewrite::squash_commits.
jj absorb also moves changes into ancestors, but picks the destination per hunk from
blame; its page will cover that entry point. Change selection is the same DiffSelector
that jj commit uses.
1. Command-line surface
| Flag | Type | Default | Meaning |
|---|---|---|---|
-r, --revision REVSET | revset | @ | Squash this one commit into its parent. It must resolve to exactly one commit with exactly one parent. Conflicts with --from and --into (source). |
-f, --from REVSETS | revsets, repeatable | @ | Squash from these commits. They may be any number of commits (source). |
-t, --into, --to REVSET | revset | @ | Squash into this one commit (source). |
-o, --onto, -d, --destination REVSETS | revsets, repeatable | none | Experimental: squash into a new commit with these parents. Conflicts with --into and -r (source). |
-A, --insert-after, --after REVSETS | revsets, repeatable | none | Experimental: the new commit goes after these, and their children are rebased onto it (source). |
-B, --insert-before, --before REVSETS | revsets, repeatable | none | Experimental: the new commit goes before these, taking their parents (source). -A and -B can be combined. |
-m, --message MESSAGE | string, repeatable | none | Use this description for the result, joined as paragraphs (source). |
-u, --use-destination-message | bool | false | Keep the destination's description and discard the sources'. Conflicts with -m (source). |
--editor | bool | false | Open the editor even when the description is already decided (source). |
-i, --interactive | bool | false | Choose the changes to move in a diff editor, per source (source). |
--tool NAME | string | none | Diff editor to use. Implies -i (source). |
FILESETS (positional) | filesets | all files | Move only changes to these paths (source). |
-k, --keep-emptied | bool | false | Never abandon a source, even if it ends up empty (source). |
2. Configuration
jj squash has no settings of its own. It reads the same keys as
jj commit:
ui.editor for descriptions, ui.diff-editor (falling back to
:builtin) and ui.diff-instructions for -i,
templates.draft_commit_description and templates.commit_trailers, and
user.name/user.email for the committer. revset-aliases."immutable_heads()"
decides what check_rewritable refuses.
3. End-to-end flow
4. Core architecture touchpoints
| Structure | Read / write | How | Explained on |
|---|---|---|---|
| Source commits | read, then abandoned or rewritten | A source whose whole diff was selected is abandoned (unless -k). Otherwise it's rewritten with the selected diff reversed out of its tree. A source with nothing selected is left alone. | commits |
| Destination commit | rewritten | Same change ID, a tree with every selected diff merged in, and predecessors set to the old destination plus every contributing source (source). | commits |
| Trees | read, merged | Each source's parent tree and tree, and the destination tree, combined by MergedTree::merge 3-way merges, so a squash can produce conflicts. | trees |
| Descendants | rebased | Children of the rewritten or abandoned commits are rebased. Abandoned commits' children move to the abandoned commit's parents. | transactions |
View.local_bookmarks | write | Bookmarks on a rewritten commit follow it. Bookmarks on an abandoned source move to its parent's new version. | view |
View.wc_commit_ids | write | If @ was abandoned, a new empty commit is created on its new parents and becomes @ (update_wc_commits). If @ was rewritten, @ follows it. | view |
View.head_ids | write | Updated by the rewrites, abandons and the new @. | view |
| Operation | write | One operation, described below. | operations |
Working copy (TreeState) | read, then maybe write | Snapshotted first. Updated if @'s tree changed, for example when changes were squashed out of it partially or into it. | working-copy |
5. Transaction and operation
- Transaction: one, started after the commits are resolved and checked (source).
-
Operation description (source):
squash commits into <destination id>when the destination already exists. In the experimental mode, it issquash commit <id>orsquash commit <id> and <n> more, naming the first source by full hex ID. The text is fixed before anything is rewritten. - Commits created or rewritten: the destination is rewritten, and partially squashed sources are rewritten. Fully squashed sources are abandoned, and descendants are rebased. In the experimental mode, a new destination commit is created first. Its predecessor record leaves out that intermediate empty commit (source).
- Working copy: snapshotted at the start. If
@is abandoned, you get a fresh empty@on its parent, and the files on disk stay the same, since the changes are now in the parent. jj undorestores the previous view: the sources come back unchanged and the destination gets its old tree and description.
6. Algorithm and invariants
Choosing sources and destination
There are two modes (source). With -r, or no
revision flags at all, the source is one commit and the destination is its single parent. A merge
commit is refused, because it's unclear which parent should receive the changes. With
--from/--into, the sources are the union of the --from revsets,
minus the destination if it's among them. The order is reversed so that older commits are applied
first, which "avoids creating transient conflicts" according to the source comment. The destination
can be anywhere: an ancestor, a descendant, or unrelated.
What squash_commits does
Each source arrives as a CommitWithSelection:
the commit, its parent tree, and the selected tree, meaning the parent tree plus the chosen changes.
Two predicates decide its fate. The selection is full when it equals the commit's tree, and
empty when it equals the parent tree.
-
Classify (source). A source is abandoned when
the selection is full and
-kwasn't given. A source with an empty selection that isn't being abandoned is skipped. An already-empty source is both full and empty, so it is abandoned, which letsjj squashclean an empty commit out of the middle of a stack. If every source is skipped, nothing happens. -
Rewrite sources (source). Abandoned sources
are recorded as abandoned. Partial sources are rewritten to
merge(source tree, inverted selected diff), which takes the moved changes back out. - Moving into a descendant (source). If any source is an ancestor of the destination, jj rebases descendants first and uses the rebased destination. Otherwise the destination would still contain the changes being moved, applying them again would be a no-op, and they would vanish from history.
-
Apply to the destination (source). The new
tree is
MergedTree::merge(Merge::from_diffs(destination tree, [each selected diff])). Every diff is labelled for conflict markers, for example(squash destination)and(squashed revision). If the same lines were changed differently, the destination ends up conflicted rather than the command failing. The builder records the old destination and every source as predecessors.
Choosing the description
SquashedDescription is Exact for
-m, UseDestination for -u, and Combine
otherwise. For Combine,
try_combine_messages looks only at the
abandoned sources and the destination. If none has a description, the result is empty. If
exactly one does, its description is used. If two or more do, the editor opens with
combine_messages_for_editing: the destination's
description under JJ: Description from the destination commit:, each source's under
JJ: Description from source commit: (empty ones too), and any configured trailers not
already present. The editor intro is "Enter a description for the combined commit." A partially
squashed source keeps its own description and doesn't contribute
(source).
Invariants: the total content is preserved, because every change removed from a
source is added to the destination, modulo conflicts. The destination keeps its change ID. A source
is only abandoned when nothing of it is left. The result is one operation, so jj undo
reverses it all.
7. Errors and edge cases
| Message | When | Source |
|---|---|---|
Cannot squash merge commits without a specified destination, hint Use `--into` to specify which parent to squash into | -r (or @) has more or fewer than one parent. | squash.rs |
Revset `…` resolved to more than one revision, hint --revision must resolve to a single revision. Use --from and --into instead if you want to squash multiple revisions. | -r matches several commits. | squash.rs |
Commit … is immutable | Any source or the destination is immutable. | cli_util.rs |
No changes selected | -i/--tool and nothing was chosen from any source. | squash.rs |
Warning: No matching entries for paths: … | An explicit path exists in none of the selected trees. | cli_util.rs |
Warning: The argument "…" is being interpreted as a fileset expression. To specify a revset, pass -r "…" instead. | Nothing was squashed, exactly one positional argument was given with no revision flags, and it also parses as a revset. A common mistake is jj squash X meaning -r X. | squash.rs |
Refusing to create a loop: commit … would be both an ancestor and a descendant of the squashed commit | Experimental mode: the requested parents and children would form a cycle. | cli_util.rs |
No revisions found to use as parent | Experimental mode: the location resolves to no parents. | cli_util.rs |
| clap conflicts | -r with --from/--into/-o/-A/-B; -o with -A/-B/--into; -m with -u. | squash.rs |
Edge cases: if nothing at all is selected without -i, nothing is rewritten. The
operation is still finished, so finish prints Nothing changed. unless the
experimental mode created a commit. With --into equal to one of the
--from commits, that commit is silently dropped from the sources.
8. Worked example
From upstream's test_squash:
three commits a, b and c, each with a bookmark of the same
name, and each setting file1 to its own letter, with @ at c.
Running jj squash -r b:
Upstream's snapshot of the output:
Rebased 1 descendant commits.
Working copy (@) now at: mzvwutvl 441a7a3a c | (no description set)
Parent commit (@-) : qpvuntsm 105931bf a b | (no description set)
b's whole diff was selected, so it was abandoned, and its bookmark followed it to the
rewritten a. The operation is squash commits into e6086990958c…, naming
the old a in full. No description was set anywhere, so the editor didn't open. The same
test's first case, a plain jj squash from c, instead abandons
@. The parent becomes 9422c8d6 with bookmarks b c, and jj
creates a fresh, empty @ on top of it.