jj / How jj describe works

How jj describe works

jj describe sets the description of one or more commits, from -m, from stdin, or in your editor. It rewrites each commit with the same tree and change ID and a new message, and reparents its descendants onto the rewritten versions without touching their contents.

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. Building each description
  8. Parsing the bulk file
  9. Rewriting
  10. 7. Errors and edge cases
  11. 8. Worked example

In short: jj resolves the revisions (default @), refuses if any is immutable, and builds the new descriptions. With one commit and no -m/--stdin, it opens one file in your editor. With several, it opens one file with a JJ: describe <id> section per commit. Commits whose description didn't change are dropped. The rest go through transform_descendants, which writes each described commit and reparents every descendant. Everything is one operation, describe commit <id>. If nothing changed, no operation is recorded at all.

The command is cmd_describe. Building and parsing the editor text lives in description_util.rs, which jj new, commit, squash and others share. The default alias desc comes from misc.toml.

1. Command-line surface

FlagTypeDefaultMeaning
REVSETS (positional)revsets, repeatable@The commits to describe. All positional and -r values are unioned (source).
-r REVSETSrevsets, repeatablenoneHidden alias for the positional argument, kept for symmetry with other commands (source).
-m, --message MESSAGEstring, repeatablenoneUse this description and don't open the editor. Several -m values become paragraphs: each gets a trailing newline, and they are joined with a blank line (join_message_paragraphs). The same text goes on every target. Conflicts with --stdin (source).
--stdinboolfalseRead the description from standard input. A trailing newline is added if missing (source, read).
--editorboolfalseOpen the editor even with -m or --stdin, pre-filled with that text (source).

DescribeArgs

The editor opens when --editor is given or no description was supplied: use_editor = args.editor || shared_description.is_none() (line 151). At this pin, describe has no flags for the author or for trailers. Authorship is handled elsewhere, as described below.

2. Configuration

KeyDefaultEffect
ui.editor nano on Unix (unix.toml), Notepad on Windows (windows.toml) The editor command (TextEditor::from_settings). It may use $path and $line; without $path, the file path is appended. Environment variables fit into the config layers: $VISUAL, then $EDITOR, sit below your config files (source), while $JJ_EDITOR sits above them (source). Layer order: ConfigSource.
templates.draft_commit_description builtin_draft_commit_description (source) Renders the text the editor starts with (description_template). The built-in version (source) shows the description, or default_commit_description (empty, source) if there is none, then JJ: Change ID: … and a JJ:-indented diff.summary(). builtin_draft_commit_description_with_diff adds JJ: ignore-rest and the full Git diff (source).
templates.commit_trailers empty (source) If set, a commit template whose output lines are trailers (for example Signed-off-by: …) to append to each description (parse_trailers_template).
user.name, user.email unset Become the rewritten commit's committer, and fill in an empty author name or email (source).
revset-aliases."immutable_heads()" see the CLI page Decides which targets check_rewritable_expr rejects.

3. End-to-end flow

jj describe: call flow cmd_describe loads the workspace through the shared CLI lifecycle, which snapshots the working copy. It unions the revsets (default @), checks none is immutable, and evaluates them to commits, newest first. If there are none it prints "No revisions to describe." Otherwise it starts a transaction and makes a detached commit builder per commit through rewrite_commit, setting the -m or stdin text if given. If templates.commit_trailers is set, it renders trailers against a hidden temporary commit. If the editor is used, it writes hidden temporary commits, renders the draft template, and opens one editor file, or one bulk file for several commits. It drops commits whose description didn't change, then calls transform_descendants: each target is rewritten with the new description and every other descendant is reparented. tx.finish records "describe commit" and the lifecycle updates the working copy if @ was rewritten. Shared lifecycle: load workspace and repo, snapshot the working copy (CLI page) parse_union_revsets (or @) check_rewritable_expr: no immutable target evaluate → commits, children first no commits? "No revisions to describe." — no operation start_transaction rewrite_commit(c).detach() per target set -m / --stdin text if given templates.commit_trailers set? write_hidden temp commit, render trailers, add_trailers_with_template use_editor? write_hidden temp commits, then: one commit: description_template → edit_description (one file, .jjdescription) several: edit_multiple_descriptions → parse "JJ: describe <id>" sections → validate_commit_descriptions (no missing, duplicate or unexpected IDs) drop unchanged descriptions HashMap old id → builder transform_descendants(targets) target: reparent + set_description, write other descendant: reparent, write then update bookmarks and @ tx.finish(ui, "describe commit <id> [and N more]"): "Nothing changed." if the view didn't change; otherwise write the operation and update the working copy if @ moved (CLI page)

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Target commitsread, then rewrittenEach changed target gets a new commit: same tree, parents (possibly rewritten), change ID and author, a new description, and a new committer signature. The old commit is recorded as its predecessor.commits
Descendant commitsrewrittenReparented onto the rewritten parents with CommitRewriter::reparent, which keeps their tree as is (source).transactions
Temporary commitswritten, hiddenwrite_hidden stores a commit so templates and trailers can be rendered against it, without adding it to the view (source). Nothing references these commits afterwards.commits
View.head_idswriteRewritten commits replace the old ones as heads.view
View.local_bookmarkswriteBookmarks on a rewritten commit move to its new version (update_rewritten_references).view
View.wc_commit_idswriteAny workspace whose @ was rewritten now points at the new commit.view
OperationwriteOne operation, described below, holding the predecessor records.operations
Working copy (TreeState)read, then maybe writeSnapshotted when the workspace loads. If @ was rewritten, the lifecycle checks out the new @. Its tree is unchanged, so no file contents change.working-copy

5. Transaction and operation

  • Transaction: one, started after the revisions are resolved and checked (source).
  • Operation description (source): describe commit <id> for one commit, or describe commit <id> and <n> more for several. <id> is the full hex ID of the first commit in evaluation order, which is the newest, since revsets evaluate children first. It is computed from all resolved targets, including any whose description ends up unchanged.
  • Commits created or rewritten: one new commit per target whose description changed, plus one per descendant of those targets. Descendants are reparented, not rebased: their trees are kept, so describing can never introduce conflicts.
  • Nothing changed: if every new description equals the old one, no commit is rewritten. finish sees no view change, prints Nothing changed. and records no operation (source). The hidden temporary commits don't count, because they never enter the view.
  • Working copy: snapshotted at the start, so the commits you describe include your latest edits. If @ was rewritten, the new @ is checked out after the operation commits.
  • jj undo restores the previous view: the old commits become visible again with their old descriptions, and the rewritten ones are hidden.

6. Algorithm and invariants

Building each description

  1. Start from the existing description. rewrite_commit copies the stored commit (for_rewrite_from). It clears predecessors, sets the committer to you, now, and fills in an empty author name or email from the committer. If the old commit was discardable (empty, with no description) and you are its author, the author timestamp is reset to now as well. .detach() turns it into a DetachedCommitBuilder that holds the draft without touching the repo.
  2. Apply -m or --stdin to every builder, if given (source).
  3. Add trailers if templates.commit_trailers is non-empty. They are skipped for a builder whose description is still empty and no editor will open. The source comment explains why: the first trailer would become the subject line, and an empty description is what lets an untouched working-copy commit be discarded (source). Trailers already present aren't repeated, and a new trailer paragraph is separated by a blank line (add_trailers_with_template).
  4. Edit, if use_editor. For one commit, edit_description appends a blank line and JJ: Lines starting with "JJ:" (like this one) will be removed., then writes a temporary .jjdescription file. The cursor goes to the first line that isn't a JJ: comment. For several, edit_multiple_descriptions writes one file. It has a header, then a JJ: describe <12-hex commit id> ------- section per commit in topological order (parents first), each filled from the draft template.
  5. Clean up what the editor returns (cleanup_description_lines). Lines starting with JJ: are dropped, everything from JJ: ignore-rest onwards is dropped, and leading and trailing blank lines are trimmed. A non-empty result ends in exactly one newline.

Parsing the bulk file

parse_bulk_edit_message starts a new section at each line beginning JJ: describe . It strips trailing whitespace and dashes from the rest of that line to get the commit ID prefix. Before the first section, only blank lines and JJ: lines are allowed. Each section is matched to a commit by its exact 12-character short ID, and the IDs are then validated: any commit with no section, any section repeated, or any unknown ID is an error, and nothing is written (source). The order of sections in the file doesn't matter.

Rewriting

Unchanged descriptions are filtered out first, so descendants aren't rewritten for nothing (source). The source comment explains why it then uses transform_descendants rather than rewriting each target and calling rebase_descendants. When one target descends from another, the later approach would rewrite it twice and add an extra predecessor link. transform_descendants visits each commit in the descendants of the targets exactly once, parents first (transform_commits). It hands each one a CommitRewriter whose new parents already account for rewrites made earlier in the walk (source):

  • a target: reparent(), set the edited description, write(), and count it as described;
  • any other descendant: reparent() and write(), and count it as rebased.

Note that the final commits come from a fresh rewrite_commit inside reparent(). Only the description text is taken from the draft builders. Then bookmarks and working-copy pointers move to the new commits. Updated N commits. is printed only when N > 1, and Rebased N descendant commits. whenever a non-target was rewritten.

Invariants: trees never change, so no conflicts can arise; change IDs are kept, so every rewritten commit is the same change; and each commit is rewritten at most once per invocation.

7. Errors and edge cases

MessageWhenSource
No revisions to describe.Status message (not an error) when the revsets match nothing. No transaction is started.describe.rs
Commit … is immutable / The root commit … is immutableAny target is immutable. Hints name the commit and say how many immutable commits would be rewritten.cli_util.rs
Nothing changed.Every description came out the same as before, including a -m that differs only by the trailing newline. No operation is recorded.cli_util.rs
clap: --message cannot be used with --stdinBoth are given.describe.rs
Failed to edit description, caused by Failed to run editor '…' or Editor '…' exited with …The editor can't be started or exits non-zero. A hint says Edited description is left in <path>.description_util.rs, command_error.rs
Found the following line without a commit header: "…"Bulk file: text before the first JJ: describe line.description_util.rs
The description for the following commits were not found in the edited message: …Bulk file: a commit's section was deleted.description_util.rs
The following commits were found in the edited message multiple times: …Bulk file: a section appears twice.description_util.rs
The following commits were not being edited, but were found in the edited message: …Bulk file: a section names an ID that isn't a target.description_util.rs
Trailers should be valid utf-8The trailer template rendered non-UTF-8 text. Output that doesn't parse as trailers fails with the trailer parser's error.description_util.rs

Edge cases: an empty -m "" clears the description, and makes an empty commit discardable again. The bulk-file header warns that a syntax error loses your text. That's because the temporary file is deleted as soon as the editor exits successfully, before the text is parsed (edit_str). Describing the same commit through two revsets is fine, since the targets are a set.

8. Worked example

This is upstream's own test (test_describe_multiple_commits). It starts from a chain of three empty, undescribed commits on the root, with @ at the top, and describes the top and the bottom in one go: jj describe -r@ -r@-- -m "description from CLI".

jj describe -r@ -r@--: before and after Before: root, then e8849ae12c70, 43444d88b009 and 3cd3b246e098 at @, none described. After describing @ and @--: 0ff65c91377a replaces e8849ae12c70 with the new description, 650ac8f249be replaces 43444d88b009 with no description because it was only reparented, and 4c3ccb9d4fb2 replaces 3cd3b246e098 as @ with the new description. All three keep their change IDs and trees. before after 000000000000 (root) e8849ae12c70 (no description set) 43444d88b009 (no description set) 3cd3b246e098 @ (no description set) 000000000000 (root) 0ff65c91377a description from CLI · described 650ac8f249be (no description set) · reparented 4c3ccb9d4fb2 @ description from CLI · described jj describe -r@ -r@--

The command prints, on stderr:

Updated 2 commits.
Rebased 1 descendant commits.
Working copy  (@) now at: kkmpptxz 4c3ccb9d (empty) description from CLI
Parent commit (@-)      : rlvkpnrz 650ac8f2 (empty) (no description set)

All three commits get new IDs, because each one's parent changed or its own description did. The middle commit only got a new parent, so its description and tree are untouched. The operation is describe commit 3cd3b246e098… and 1 more, naming the old @ in full. Running the same command again prints Nothing changed. and records nothing. Running plain jj describe -r@ @- instead opens a bulk file like this (from the same test), with the parent first:

JJ: Enter or edit commit descriptions after the `JJ: describe` lines.
JJ: Warning:
JJ: - The text you enter will be lost on a syntax error.
JJ: - The syntax of the separator lines may change in the future.
JJ:
JJ: describe 650ac8f249be -------


JJ: Change ID: rlvkpnrz
JJ:
JJ: describe 4c3ccb9d4fb2 -------
description from CLI

JJ: Change ID: kkmpptxz
JJ:
JJ: Lines starting with "JJ:" (like this one) will be removed.