jj / How jj metaedit works

How jj metaedit works

jj metaedit rewrites commits to change their metadata (author, author date, description, change ID) without touching their trees. It only rewrites a commit when something actually changes, unless --force-rewrite is given, and rebases the descendants.

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. 7. Errors and edge cases
  8. 8. Worked example

In short: jj resolves the revisions (default @) and checks they are mutable. If the set is empty it prints No revisions to modify. and stops. In one transaction it runs transform_descendants over the set. For each target it builds a rewrite and applies the requested author, timestamp, description and change-ID changes. It writes a new commit only if one of them differs from the old commit, or --force-rewrite is set. Descendants are reparented. The operation is edit commit metadata for commit <id>, plus and N more when there are several.

The command is cmd_metaedit. To edit a description in an editor, use jj describe. What a commit's metadata fields are, and how a rewrite copies them, is on the commits page.

1. Command-line surface

FlagTypeDefaultMeaning
REVSETS (positional), -r (hidden alias)revsets, repeatable@The commits to modify. Both forms are unioned (source).
--update-change-idbooloffGive each commit a new random change ID (source).
-m, --messagestring, repeatablenoneSet the description. Several values become paragraphs. No editor is opened (source).
--update-author-timestampbooloffSet the author date to now (the new committer timestamp) (source).
--update-authorbooloffSet the author name and email to the configured user. The author date is kept (source).
--author "Name <email>"author stringnoneSet the author name and email explicitly. Conflicts with --update-author (source).
--author-timestamp DATERFC 2822 or RFC 3339 datenoneSet the author date. Conflicts with --update-author-timestamp (source).
--force-rewritebooloffRewrite every target even if nothing else changed. This refreshes the committer (source).

MetaeditArgs

2. Configuration

KeyDefaultEffect
user.name, user.email (or JJ_USER, JJ_EMAIL)unsetThe committer of every rewritten commit, and the author for --update-author (help, for_rewrite_from).
revset-aliases."immutable_heads()"(built-in)Immutable targets are refused by check_rewritable_expr.

3. End-to-end flow

jj metaedit: call flow cmd_metaedit loads the workspace, resolves the revisions (default @), checks they are rewritable, and returns with "No revisions to modify." if the set is empty. It starts a transaction and runs transform_descendants over the targets. For a target it starts from rewriter.reparent(), which already sets the committer to the current user, then applies the author, author timestamp, description and change-ID options, and writes only if something changed or --force-rewrite is set. Non-target descendants are reparented when their parents changed. It prints Modified and Rebased counts, and tx.finish records "edit commit metadata for commit …". load workspace (snapshot); resolve REVSETS ∪ -r (default @); check_rewritable_expr empty → "No revisions to modify." and return start_transaction; transform_descendants(targets) — each commit is visited once, parents first target commit rewrite = --force-rewrite || parents changed builder = reparent() (committer := you, now) --author / --update-author → name, email --update-author-timestamp / --author-timestamp → date -m → description; --update-change-id → new change ID any of these differs → rewrite; write only if rewrite descendant (not a target) parents changed → reparent().write() num_reparented += 1 "Modified N commits:" + summaries; "Rebased M descendant commits." tx.finish(ui, "edit commit metadata for commit <id> [and K more]") — "Nothing changed." if none

4. Core architecture touchpoints

StructureRead / writeHowExplained on
Commit metadata (author, committer, description, change ID)read, writeNew commits copy the old tree and parents (remapped), with changed metadata (for_rewrite_from).commits
TreereadNever changed. Descendants keep their own trees too.trees
Rewrite records and predecessorswriteEach rewritten commit records its old version as predecessor, so jj evolog shows the metadata edit.transactions
Bookmarks, @writeMoved to the rewritten commits when the transaction finishes.view
OperationwriteOne operation, or none if nothing changed.operations

5. Transaction and operation

  • Transaction: one, after the rewritability check (source).
  • Operation description: edit commit metadata for commit <full hex id>, or … for commit <id> and <N> more (source). The ID is the first target's old commit ID. Upstream's evolog shows edit commit metadata for commit 75591b1896b4990e7695701fd7cdbb32dba3ff50 (test).
  • Commits rewritten: targets with a real change, and descendants whose parents moved.
  • No-op runs: when nothing differs, no commit is written and the transaction finishes with Nothing changed. (test).

6. Algorithm and invariants

The source comment explains why transform_descendants is used instead of rewriting each target: if one target descends from another, it is still rewritten only once, so its predecessor chain gets one entry, not two.

The builder comes from rewriter.reparent(), which already applies two defaults from for_rewrite_from. First, the committer becomes the current user at the current time. Second, an empty author name or email is filled in from the committer, and a discardable commit authored by the committer gets its author date reset. So the change check compares against the old commit's author, not the builder's (source). A timestamp counts as changed only if it differs from both the builder's and the old commit's. That is why repeating the same --author-timestamp is a no-op, as upstream's test_metaedit_set_same_timestamp_twice checks.

Precedence (source): --author wins over --update-author (clap also rejects using both). --author-timestamp is applied after --update-author-timestamp, and clap rejects that pair too.

Invariants: trees never change. Immutable commits are never rewritten. Without --force-rewrite, a target is rewritten only if its metadata changes or its parents were rewritten.

7. Errors and edge cases

MessageWhenSource
No revisions to modify.The revsets resolve to nothing. This is a status message, not an error.metaedit.rs, test
Nothing changed.No option changed anything, for example a bare jj metaedit, or --update-author when you are already the author.test
Commit … is immutableA target is in immutable_heads()::.metaedit.rs (CLI page)
error: the argument '--author <AUTHOR>' cannot be used with '--update-author'Both author options were given. Clap exits with status 2.test
clap: invalid value for --author / --author-timestampNot of the form Name <email>, or an unparseable date.metaedit.rs

8. Worked example

From upstream's test_new_change_id. A stack a → b → c, where c is @. Then jj metaedit --update-change-id kkmpptxzrspx gives b a new change ID:

jj metaedit --update-change-id Before: root, qpvuntsm e6086990 (a), kkmpptxz 75591b18 (b), mzvwutvl 22be6c4e (c, @). After jj metaedit --update-change-id on b: b becomes yqosqzyt 01d6741e, with a new change ID and the same tree, and c is rebased onto it as mzvwutvl 0c3fe2d8. a is unchanged. before after metaedit --update-change-id b root qpvuntsm e6086990 a kkmpptxz 75591b18 b @ mzvwutvl 22be6c4e c root qpvuntsm e6086990 a (unchanged) yqosqzyt 01d6741e b — new change ID @ mzvwutvl 0c3fe2d8 c — rebased

Upstream's snapshot:

$ jj metaedit --update-change-id kkmpptxzrspx
Modified 1 commits:
  yqosqzyt 01d6741e b | (no description set)
Rebased 1 descendant commits.
Working copy  (@) now at: mzvwutvl 0c3fe2d8 c | (no description set)
Parent commit (@-)      : yqosqzyt 01d6741e b | (no description set)

The evolog for the new change ID yqosqzyt lists the old kkmpptxz commits as its predecessors, linked by the edit commit metadata for commit 75591b18… operation (test). So the history isn't lost, even though the change ID is new.