jj / How jj split works

How jj split works

jj split turns one commit into two. You choose which changes go in the first. The rest form the second, either as its child (the default), as a sibling (--parallel), or with the selection extracted elsewhere in the graph (-o/-A/-B).

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. Selecting
  8. Building the two commits
  9. Placing them
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: jj resolves one mutable commit (default @) and selects changes from it, in a diff editor unless you gave paths. It then builds two commits from the original. The "selected" commit gets the chosen tree. The "remaining" commit gets the full original tree (on top of the selected one) or the original minus the selection (in parallel mode). Which of the two keeps the original change ID depends on the mode. Both descriptions are edited in one file. Descendants and working copies are moved onto the remaining commit, and so are bookmarks, by default. One operation records it all: split commit <id>.

The command is cmd_split. Its help text draws the three layouts (source). Selection is the shared DiffSelector (see jj commit, whose help text compares itself with split). The bulk description editor is the one jj describe uses for several commits. The -o/-A/-B mode hands the selected commit to the rebase engine.

1. Command-line surface

FlagTypeDefaultMeaning
FILESETS (positional)filesetsall filesFiles matching these go in the selected changes. Giving any turns interactive mode off unless -i is also given (source).
-i, --interactiveboolon when no filesetsChoose changes in a diff editor (source, default).
--tool NAMEstringnoneDiff editor to use. Implies -i (source).
-r, --revision REVSETrevset@The commit to split, which must be exactly one (source).
-o, --onto, -d, --destination REVSETSrevsets, repeatablenoneExtract the selection into a new commit on these parents. Conflicts with --parallel (source).
-A, --insert-after, --after REVSETSrevsets, repeatablenoneExtract the selection into a new commit inserted after these (source).
-B, --insert-before, --before REVSETSrevsets, repeatablenoneExtract the selection into a new commit inserted before these (source).
-m, --message MESSAGEstring, repeatablenoneDescription for the selected commit, without opening the editor. The remaining commit keeps the original description (source).
--editorboolfalseOpen the description editor even with -m (source).
-p, --parallelboolfalseMake the two commits siblings instead of parent and child (source).

SplitArgs

2. Configuration

KeyDefaultEffect
split.legacy-bookmark-behavior true (misc.toml) When true, bookmarks on the split commit move to the remaining commit. When false, an experimental setting according to its comment, they stay with the selected commit (source). Upstream tests all three cases (test_split_with_bookmarks).
ui.diff-editor, ui.diff-instructions:builtin with a hint; trueThe diff editor and its instruction text, as on the commit page.
ui.editor, templates.draft_commit_description, templates.commit_trailerssee describeThe description editor, its draft template, and trailers added to both descriptions.

3. End-to-end flow

jj split: call flow cmd_split loads the workspace through the shared lifecycle, which snapshots the working copy. SplitArgs::resolve finds the one target commit, checks it is rewritable, builds the matcher and a diff selector that is interactive when no paths are given, computes the -o/-A/-B location, and warns on unmatched paths. In a transaction, select_diff picks the selected tree. A first builder rewrites the target with the selected tree; a second builder rewrites it with the full tree, or with the selection removed in parallel mode. One of them gets a new change ID depending on the mode. If the editor is used, both are written as hidden temporary commits and their descriptions edited in one bulk file. Both are written; then either move_first_commit relocates the selected commit with the rebase engine, or rewrite_descendants moves descendants, working copies and, with the legacy setting, bookmarks to the remaining commit. tx.finish records "split commit". Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) SplitArgs::resolve: resolve_single_rev, check_rewritable, matcher, diff_selector(-i or no paths), compute_commit_location for -o/-A/-B, warn on unmatched paths (checked against the parent tree and the commit's tree) start_transaction; select_diff DiffSelector::select(parent tree → tree) warn if all or nothing was selected two builders from rewrite_commit(target) selected: selected tree (+ -m, trailers) remaining: full tree, or tree − selection (-p) new change ID: remaining, or selected with -o/-A/-B editor (no -m, or --editor): write_hidden both, edit_multiple_descriptions with "Enter a description for the selected changes." / "… for the remaining changes." → validate_commit_descriptions write selected; set remaining's parents (selected, or the target's parents with -p); write remaining default / -p: rewrite_descendants legacy setting: target → remaining (bookmarks) rebase children onto remaining (or both); edit @ -o/-A/-B: move_first_commit rebase target's descendants onto remaining, then move_commits(selected → location) print summaries; tx.finish(ui, "split commit <id>") (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Target commitread, then replaced by twoBoth new commits are built with rewrite_commit(target), so both copy its author (and author date), description and parents.commits
Change IDsone kept, one newBy default the selected commit keeps the change ID and is recorded as the rewrite of the target; the remaining commit gets a fresh change ID and no rewrite source. With -o/-A/-B it's the other way round (source, source).commits
Treesread, mergedThe selected tree comes from the diff selector. In parallel mode, the remaining tree is merge(tree, inverted selected diff) (source).trees
DescendantsrebasedOnto the remaining commit, or onto both with --parallel. With -o/-A/-B, onto the remaining commit, and the selected commit's new children onto it.rebase
View.local_bookmarkswriteFollow the target's recorded rewrite. In the default and parallel layouts that is the remaining commit with the legacy setting, and otherwise the selected one. With -o/-A/-B it is always the remaining commit, which continues the change.view
View.wc_commit_idswriteEvery workspace whose @ was the target is moved to the remaining commit with MutableRepo::edit (source).view
Temporary commitswritten, hiddenWhen the editor is used, so both drafts can render their JJ: file lists.describe
OperationwriteOne operation, split commit <id>.operations
Working copy (TreeState)read, then maybe writeSnapshotted first. In the default layout the new @ has the full original tree, so files don't change. With --parallel or a move, they may.working-copy

5. Transaction and operation

  • Transaction: one, always. Even an empty split writes two commits (source).
  • Operation description: split commit <full hex id of the target> (source).
  • Commits created or rewritten: two replace the target, and every descendant is rebased. With -A/-B, the new children of the selected commit are rebased too.
  • Working copy: snapshotted at the start. If @ was split, it becomes the remaining commit.
  • jj undo restores the single original commit and the old descendants.

6. Algorithm and invariants

Selecting

select_diff runs the diff selector on parent tree → commit tree, with instructions explaining that the right side becomes the new first commit. Paths without -i select whole files. The result is a CommitWithSelection. If the selection is everything, jj warns that the original revision becomes empty. If it is nothing, jj warns that the new revision will be empty. Neither is an error.

Building the two commits

  • Selected (source): the selected tree, the -m text or the original description, with trailers added only if the description isn't empty. In move mode it drops the rewrite source and draws a new change ID, so the original commit doesn't become divergent.
  • Remaining (source): the full original tree. On top of the selected commit, its diff is exactly the unselected changes. In parallel mode, the tree is the original with the selected diff reverse-applied, so its diff against the shared parents is the unselected changes. Outside move mode it is the one with the new change ID.
  • Descriptions (source): unless -m was given without --editor, both drafts are written as hidden commits, with parents set as they will be. They are edited in one bulk file, under the intros "Enter a description for the selected changes." and "Enter a description for the remaining changes.". The result is validated like describe's bulk edit.

Placing them

rewrite_descendants (default and parallel) first, with the legacy setting, re-records the target as rewritten to the remaining commit, which is what carries bookmarks there. It then walks the target's descendants, replacing whichever parent now stands for the target with the remaining commit, or with both commits in parallel mode, and rebases each one. Finally it points every workspace that was on the target at the remaining commit.

move_first_commit (-o/-A/-B) first rebases the target's descendants onto the remaining commit, which kept the change ID. It maps the requested parents and children through those rewrites, then runs move_commits on the selected commit alone. It counts rebased commits carefully so that a commit rewritten twice is reported once.

Invariants: the two commits' changes add up to the original's. Exactly one of them continues the original change; the other is a new change. Descendants keep their content relative to the combined pair. Author and author date carry over to both, as upstream's test_split_by_paths checks.

7. Errors and edge cases

MessageWhenSource
Warning: All changes have been selected, so the original revision will become empty.Full selection, which is also the case for an empty commit.split.rs
Warning: No changes have been selected, so the new revision will be empty.Empty selection.split.rs
Warning: No matching entries for paths: …A path exists in neither the parent tree nor the commit's tree. Common when a revision is passed positionally, as in jj split @.split.rs
Commit … is immutableThe target is immutable.split.rs
Refusing to create a loop: … of the split-out commit, No revisions found to use as parent-o/-A/-B location errors.cli_util.rs
Bulk description errors (missing, duplicate or unexpected sections)The edited description file is malformed.description_util.rs
clap: --parallel with -o/-A/-B; -o with -A/-BIncompatible layouts.split.rs

The help text says "Splitting an empty commit is not supported because the same effect can be achieved with jj new" (source). The code has no such check, though: upstream's test_split_empty splits an empty commit successfully, with the "Empty diff" and "All changes have been selected" warnings, and gets two empty commits.

8. Worked example

From upstream's test_split_by_paths: a fresh @ (change qpvuntsm) adds file1, file2 and file3. Running jj split file2 (paths given, so no diff editor):

jj split file2: before and after Before: the root, then @ (change qpvuntsm) adding file1, file2 and file3. After: the root, then qpvuntsm, which keeps its change ID and now adds only file2 (the selected changes), then a new change zsuskuln on top adding file1 and file3 (the remaining changes), which is the new @. before after root qpvuntsm @ A file1, A file2, A file3 root qpvuntsm 6dbc7747 selected changes: A file2 same change ID zsuskuln 42cbbc02 @ remaining changes: A file1, A file3 new change ID; tree = original tree jj split file2

Upstream's snapshot of the output:

Selected changes : qpvuntsm 6dbc7747 (no description set)
Remaining changes: zsuskuln 42cbbc02 (no description set)
Working copy  (@) now at: zsuskuln 42cbbc02 (no description set)
Parent commit (@-)      : qpvuntsm 6dbc7747 (no description set)

The bulk editor showed two sections, JJ: Enter a description for the selected changes. and JJ: Enter a description for the remaining changes., each listing its own files. The test also sets a commit-trailers template, so Trailer: value was pre-filled in both. Both new commits keep the original author date, while their committer dates are newer. The operation is split commit <qpvuntsm's old id>.