jj / How jj commit works

How jj commit works

jj commit finishes the working-copy commit: it gives @ a description, optionally keeps only some of its changes there, and starts a fresh working-copy commit on top that holds everything else.

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 what to commit
  8. Building the description
  9. Rewriting and moving on
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: with no paths and no -i, jj commit is jj describe followed by jj new, in one operation. It rewrites @ (same change ID) with the chosen description and the selected tree. Then it creates a new, undescribed commit on top whose tree is the full, original working-copy tree, and moves every workspace that was on the old @ there. So the files on disk never change: unselected changes simply show up as the new @'s diff. The operation is commit <old @ id>.

The command is cmd_commit. It shares its description handling with jj describe, its final step with jj new and jj edit (MutableRepo::edit), and its change selection with jj split. Its own help text lists how it differs from split (source): it isn't interactive by default, has no -r, doesn't move bookmarks forward, and can't send the selection elsewhere. The default alias ci comes from misc.toml.

1. Command-line surface

FlagTypeDefaultMeaning
FILESETS (positional)filesets, repeatableall filesKeep only changes to these paths in the committed commit. The rest move to the new @. With none, the matcher is FilesetExpression::all() (source, parse_file_patterns).
-i, --interactiveboolfalseChoose the changes to keep in a diff editor (source).
--tool NAMEstringnoneUse this diff editor. Implies --interactive (source).
-m, --message MESSAGEstring, repeatablenoneUse this description and don't open the editor. Several values are joined as paragraphs, exactly as in describe (source).
--editorboolfalseOpen the editor even with -m (source).

CommitArgs

Unlike describe, there is no --stdin and no revision argument: the target is always this workspace's @ (source). The editor opens when there's no -m, or when --editor is given (line 139).

2. Configuration

KeyDefaultEffect
ui.editornano / NotepadThe description editor. Precedence with $VISUAL, $EDITOR and $JJ_EDITOR is as on the describe page.
templates.draft_commit_descriptionbuiltin_draft_commit_descriptionThe text the description editor starts with, rendered against a hidden temporary commit that already has the selected tree, so its JJ: file list shows only what is being committed (source).
templates.commit_trailersemptyTrailers appended to the description, with the same empty-description rule as describe (source).
ui.diff-editor unset; falls back to :builtin The diff editor for -i when --tool isn't given (diff_editor, diff_selector). When unset, jj uses the built-in editor and prints a hint saying so (source). External tools are configured under [merge-tools.<name>].
ui.diff-instructionstrue (source)Whether the diff editor is given the instruction text "You are splitting the working-copy commit: …" (source).
experimental-advance-branches.enabled-branches / .disabled-branches both empty (source) Name patterns of bookmarks that advance automatically. With no enabled patterns the feature is off (load_advance_bookmarks_matcher). When it is on, matching bookmarks on @'s parents move to the committed commit (get_advanceable_bookmarks).
user.name, user.emailunsetCommitter of the rewritten commit, and author and committer of the new @.

3. End-to-end flow

jj commit: call flow cmd_commit loads the workspace through the shared CLI lifecycle, which snapshots the working copy, and gets the working-copy commit or fails with "This command requires a working copy". It builds the path matcher, finds advanceable bookmarks on @'s parents, picks a diff selector, and starts a transaction. DiffSelector::select restores the matched paths from @'s tree onto the parent tree and, if interactive, opens the diff editor; the result is the selected tree. It warns if paths were given but matched nothing. It rewrites @ with the selected tree, adds trailers, and opens the description editor unless -m was given. It writes the rewritten commit, creates a new commit on top with @'s full original tree, advances bookmarks, and runs edit for every workspace that was on @. tx.finish records "commit" with the old @'s ID, rebases any descendants, and updates the working copy. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) get_wc_commit (or error) parse_file_patterns → matcher get_advanceable_bookmarks(@'s parents) diff_selector, text_editor, start_transaction DiffSelector::select restore_tree: matched paths from @'s tree onto the parent tree -i / --tool: then the diff editor selected tree warn if paths matched nothing rewrite_commit(@).detach(), set_tree description -m paragraphs, else @'s current text add_trailers (unless empty and no editor) editor: write_hidden → description_template → edit_description; hint if left empty commit_builder.write rewritten @: same change ID, selected tree for the workspaces on old @: new_commit([rewritten], @'s full tree) advance_bookmarks → rewritten MutableRepo::edit(name, new @) per workspace tx.finish(ui, "commit <old @ id>"): rebase descendants of the old @ onto the rewritten commit, move its bookmarks there, write the operation, and check out the new @, whose tree equals what was snapshotted, so no files change (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Old @read, then rewrittenRewritten into the committed commit: same change ID, parents and author, the selected tree, the new description, and you as committer now.commits
Parent tree of @readThe base the selection is built on (commit.parent_tree, source). For a merge, this is the merged parents' tree.trees
New @createdA new change on top of the committed commit, with the old @'s full tree, an empty description, and fresh author and committer signatures (for_new_commit).commits
Temporary commitwritten, hiddenOnly when the editor opens: it carries the draft so the description template can render its diff summary.describe
View.wc_commit_idswriteEvery workspace whose @ was the old commit now points at the same new @ (workspaces_for_wc_commit_id).view
View.head_idswriteThe new @ becomes a head, and the old commit stops being visible.view
View.local_bookmarkswriteBookmarks on the old @ follow the rewrite to the committed commit, not the new @. With advance-bookmarks enabled, matching bookmarks on @'s parents move to the committed commit too.view
OperationwriteOne operation, commit <old @ id>.operations
Working copy (TreeState)read, then rewrittenSnapshotted at the start. The state is then reset to the new @, which has the same tree, so file contents don't change.working-copy

5. Transaction and operation

  • Transaction: one, always. Even jj commit -m "" on an empty @ records an operation, because a new commit is created (source).
  • Operation description: commit <id>, the full hex ID of the working-copy commit before the command (source).
  • Commits created or rewritten: the old @ is rewritten, and one new commit is created. If the old @ had descendants, which is possible but unusual, finish rebases them onto the committed commit. With a partial selection, that commit lacks the unselected changes, so the rebase may conflict.
  • Working copy: snapshotted first, so everything you've edited is included. After the operation, the new @ is checked out. Its tree equals the snapshot, so nothing on disk changes.
  • jj undo restores the previous view: the old @ comes back as @ with its old description and all its changes, and the two new commits are hidden.

6. Algorithm and invariants

Choosing what to commit

DiffSelector::select works on two trees: before is @'s parent tree, and after is @'s tree. It first runs restore_tree, which starts from before and copies in every path the matcher accepts from after. With no paths, the result is simply @'s tree. Non-interactive selection stops there. Interactive selection then opens the diff editor on before → that result, with the instruction text. The tree you leave on the right side is what gets committed. If there's nothing to choose from, it prints Empty diff - won't run diff editor. and skips the editor.

Building the description

  1. Start from the -m paragraphs, or from @'s current description (source).
  2. Add trailers through add_trailers, unless the description is empty and no editor will open. That would give the commit a description made only of trailers (source).
  3. If the editor is used, write a hidden temporary commit with the selected tree and the draft text, render templates.draft_commit_description for it, and edit it with edit_description. If the result is empty, jj hints that you can jj undo, or describe the parent with jj desc @- (source). The commit still goes ahead.

Rewriting and moving on

rewrite_commit(@).detach() takes the commit's fields and sets the selected tree and the description. It is then written with write, which records the old @ as rewritten (source, write). Then, for the workspaces whose @ was the old commit, jj:

  1. creates one new commit whose parent is the committed commit and whose tree is the old @'s full tree (commit.tree(), not the selection);
  2. advances eligible bookmarks to the committed commit (a no-op unless the experimental setting is on);
  3. calls MutableRepo::edit for each of those workspaces, pointing them all at that new commit (source).

Because the new @'s tree is the snapshotted tree, the diff between the committed commit and the new @ is exactly the unselected changes.

Invariants: the working-copy files are never modified; the committed commit keeps @'s change ID, so it is the "same change" you were working on; the new @ is always a new change; and every workspace that shared the old @ ends up on the same new @.

7. Errors and edge cases

MessageWhenSource
This command requires a working copyThis workspace has no working-copy commit, for example after jj workspace forget.commit.rs
Warning: The given paths do not match any file: …Paths were given but the selected tree equals the parent tree. The command still runs, committing an empty change.commit.rs
Empty diff - won't run diff editor.Interactive mode with nothing to select.cli_util.rs
Hint: The commit message was left empty. …The editor returned an empty description. The commit still goes ahead.commit.rs
Hint: Using default editor ':builtin'; run `jj config set --user ui.diff-editor :builtin` to disable this message.Interactive mode with no ui.diff-editor configured.merge_tools/mod.rs
Failed to edit description, Trailers should be valid utf-8As on the describe page, minus the bulk-file errors (commit only ever edits one description).description_util.rs

cmd_commit makes no check_rewritable call of its own, unlike describe. A @ that is a merge stays a merge: the committed commit keeps all its parents, and the new @ has the committed commit as its only parent.

8. Worked example

Upstream's test_commit_paths: a fresh repository whose @ (change qpvuntsm) has two new files, file1 and file2. Running jj commit -m=first file1:

jj commit -m=first file1: before and after Before: the root, then @ (change qpvuntsm) with no description, adding file1 and file2. After: the root, then qpvuntsm rewritten with description "first" and only file1, then a new working-copy commit (change rlvkpnrz) with no description, adding file2. Its tree contains both files, so nothing on disk changes. before after root qpvuntsm @ (no description set) adds file1, file2 root qpvuntsm first rewritten: adds file1 only same change ID, new commit ID rlvkpnrz @ new change, (no description set) adds file2; tree = old @'s tree jj commit -m=first file1

Upstream checks the result: jj diff -r @- shows only Added regular file file1, and jj diff shows only file2. The change IDs are the deterministic ones upstream's test environment assigns. In the companion test_commit_paths_warning, the path file3 matches nothing. That produces the warning, an empty first commit as @-, and both files left in the new @:

Warning: The given paths do not match any file: file3
Working copy  (@) now at: rlvkpnrz 4c6f0146 (no description set)
Parent commit (@-)      : qpvuntsm 68a50538 (empty) first