jj / How jj squash works

How jj squash works

jj squash moves changes out of one or more source commits and into a destination commit. By default that's from @ into its parent. A source left empty is abandoned, and their descriptions are combined.

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. Choosing sources and destination
  8. What squash_commits does
  9. Choosing the description
  10. 7. Errors and edge cases
  11. 8. Worked example

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

FlagTypeDefaultMeaning
-r, --revision REVSETrevset@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 REVSETSrevsets, repeatable@Squash from these commits. They may be any number of commits (source).
-t, --into, --to REVSETrevset@Squash into this one commit (source).
-o, --onto, -d, --destination REVSETSrevsets, repeatablenoneExperimental: squash into a new commit with these parents. Conflicts with --into and -r (source).
-A, --insert-after, --after REVSETSrevsets, repeatablenoneExperimental: the new commit goes after these, and their children are rebased onto it (source).
-B, --insert-before, --before REVSETSrevsets, repeatablenoneExperimental: the new commit goes before these, taking their parents (source). -A and -B can be combined.
-m, --message MESSAGEstring, repeatablenoneUse this description for the result, joined as paragraphs (source).
-u, --use-destination-messageboolfalseKeep the destination's description and discard the sources'. Conflicts with -m (source).
--editorboolfalseOpen the editor even when the description is already decided (source).
-i, --interactiveboolfalseChoose the changes to move in a diff editor, per source (source).
--tool NAMEstringnoneDiff editor to use. Implies -i (source).
FILESETS (positional)filesetsall filesMove only changes to these paths (source).
-k, --keep-emptiedboolfalseNever abandon a source, even if it ends up empty (source).

SquashArgs and its help text

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

jj squash: call flow cmd_squash loads the workspace through the shared lifecycle, which snapshots the working copy. It chooses sources and destination: with --from, --into, -o, -A or -B it unions the --from revsets (default @) and resolves the single --into (default @), dropping it from the sources; otherwise it takes -r (default @), which must be one commit with one parent, the parent being the destination. It checks every source and the destination are rewritable and builds the operation description. In the experimental mode it creates a new empty destination commit and rebases its new children. select_diff runs the diff selector per source. rewrite::squash_commits abandons or rewrites each source, rebases first if a source is an ancestor of the destination, and merges the selected diffs into the destination. The description is chosen and maybe edited, the destination is written, descendants are rebased, and tx.finish records the operation. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) --from / --into / -o / -A / -B sources = union(--from) or @ destination = --into or @ (not with -o/-A/-B) drop destination from sources; oldest first otherwise: -r (default @) exactly one commit exactly one parent → the destination else "Cannot squash merge commits …" check_rewritable(sources + destination) operation description; start_transaction -o / -A / -B only: compute_commit_location, new empty commit, rebase its new children onto it select_diff, per source DiffSelector::select(parent tree → source tree) → CommitWithSelection rewrite::squash_commits full selection → abandon source partial → source minus the selected diff source an ancestor? rebase first destination tree + every selected diff description -m / -u / combine; editor if ambiguous or --editor write destination rebase_descendants → "Rebased N …" (nothing selected: no write, or error with -i) tx.finish(ui, "squash commits into <id>"): write the operation; if @ was abandoned, a new empty @ was created on its parent; update the working copy (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Source commitsread, then abandoned or rewrittenA 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 commitrewrittenSame change ID, a tree with every selected diff merged in, and predecessors set to the old destination plus every contributing source (source).commits
Treesread, mergedEach source's parent tree and tree, and the destination tree, combined by MergedTree::merge 3-way merges, so a squash can produce conflicts.trees
DescendantsrebasedChildren of the rewritten or abandoned commits are rebased. Abandoned commits' children move to the abandoned commit's parents.transactions
View.local_bookmarkswriteBookmarks on a rewritten commit follow it. Bookmarks on an abandoned source move to its parent's new version.view
View.wc_commit_idswriteIf @ 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_idswriteUpdated by the rewrites, abandons and the new @.view
OperationwriteOne operation, described below.operations
Working copy (TreeState)read, then maybe writeSnapshotted 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 is squash commit <id> or squash 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 undo restores 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.

  1. Classify (source). A source is abandoned when the selection is full and -k wasn'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 lets jj squash clean an empty commit out of the middle of a stack. If every source is skipped, nothing happens.
  2. 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.
  3. 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.
  4. 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

MessageWhenSource
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 immutableAny 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 commitExperimental mode: the requested parents and children would form a cycle.cli_util.rs
No revisions found to use as parentExperimental 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:

jj squash -r b: before and after Before: root, then e6086990958c with bookmark a, 75591b1896b4 with bookmark b, and 22be6c4e01da with bookmark c at @. After jj squash -r b: b's changes moved into a, giving 105931bfedad with bookmarks a and b, where file1 now reads b. b was abandoned, so its bookmark moved to the rewritten a. c was rebased onto it as 441a7a3a17b0, still @ and still with file1 reading c. before after 000000000000 (root) e6086990958c a file1 = a 75591b1896b4 b file1 = b 22be6c4e01da c @ file1 = c 000000000000 (root) 105931bfedad a b rewritten a: file1 = b 441a7a3a17b0 c @ rebased; file1 = c 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.