jj / How jj gerrit upload works

How jj gerrit upload works

jj gerrit upload pushes revisions to Gerrit for review, one Gerrit change per commit. Gerrit identifies changes by a Change-Id trailer, so jj adds one derived from each commit's change ID, but only in temporary copies it pushes: the local commits are never modified and no operation is recorded.

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. The Change-Id
  8. Heads and pushes
  9. 7. Errors and edge cases
  10. 8. Worked example

In short: jj validates the Gerrit push options, picks the revisions (-r, or @ if it has a description, otherwise @-), and adds all their mutable ancestors. It refuses empty single-parent commits and commits without a description; a merge commit may be empty. In a transaction it never finishes, it rewrites each commit that lacks a Change-Id (or Link) trailer to add one, keeping author and committer, and re-parents descendants onto the rewritten copies. Then, for each head, it pushes the rewritten commit to refs/for/<branch> on the Gerrit remote with git push, passing the options as --push-options.

The command is cmd_gerrit_upload, the only subcommand of jj gerrit. It pushes through the same Git subprocess code as jj git push, but to Gerrit's magic refs/for/ namespace instead of a bookmark.

1. Command-line surface

FlagTypeDefaultMeaning
-r, --revisionrevsets, repeatable@ or @- (§3)What to upload; mutable ancestors come too, so -r foo means mutable()::foo (source).
-b, --remote-branchbranchgerrit.default-remote-branchWhere the changes should land (source).
--remoteremote namesee §2The Gerrit remote (source).
-n, --dry-runboolfalseShow what would be pushed without pushing (source).
--reviewer, --cc, -l/--label, --hashtag, --customstrings, repeatablenoneGerrit push options; labels default to +1 on Gerrit's side, custom values must be key:value (source, custom).
--topic, -m/--message, --deadline, --tracestringnoneSingle-valued options; -m is the patch-set description, percent-encoded (source, source).
--notifynone, owner, owner-reviewers, allGerrit's (all)Who gets email (source, EmailNotification).
--edit, --wip/--ready, --private/--remove-private, --publish-comments/--no-publish-comments, --submit, --skip-validation, --ignore-attention-setboolfalseGerrit flag options (source).
--mergedboolfalse"Create a new change, even if the change has already been merged", but see §7 (source).
-o, --optionstring, repeatablenoneAny other git push -o option, passed through (source).

UploadArgs

2. Configuration

KeyDefaultEffect
gerrit.default-remoteunsetThe remote when --remote isn't given; must exist (schema).
gerrit.default-remote-branchunsetThe target branch when -b isn't given; without either, the command fails (source).
gerrit.review-urlunsetIf set, jj adds Link: <url>/id/<Change-Id> instead of a Change-Id: trailer (schema, use).
templates.commit_trailers—The help suggests format_gerrit_change_id_trailer(self) here so local commits get the trailer and stay identical to what's uploaded (source, template).

The remote is chosen in this order: --remote, then gerrit.default-remote (both must name a configured Git remote), then Git's default push remote, then a remote literally named gerrit; otherwise the command fails (calculate_push_remote).

3. End-to-end flow

The default revision distinguishes the "squash workflow" (describe the parent, work in an empty @) from the "edit workflow" (work directly in a described @). A merge @ with no description is an error, since there is no single parent to choose (source).

4. Core architecture touchpoints

  • Commits: the rewritten copies are written to the store, which for the Git backend means real Git objects that git push can send; they just never become visible in jj because the transaction is dropped (source). See the commits and transactions pages.
  • Trailers: parsed with parse_description_trailers; a Link trailer counts as a Change-Id (source).
  • Revsets: immutable_heads() bounds the upload: immutable commits are assumed to be on Gerrit already, with trailers (source).
  • Git push: git::push_updates runs git push, with each option as --push-option=… (source).

5. Transaction and operation

None is recorded. The transaction exists only to write the temporary copies and is deliberately never finished, so the Change-Id trailer never enters the local description. A source comment gives the reason: otherwise jj split would leave many commits with the same Change-Id, and jj squash one commit with several (source). The local log is unchanged after an upload (test), and there is nothing to jj undo.

The price is the divergence the help text warns about: the commit Gerrit merges has a different ID from the local one, so after fetching it, abandon the local change or rebase with --skip-emptied, or add the trailer locally via templates.commit_trailers (source).

6. Algorithm and invariants

The Change-Id

How jj derives a Gerrit Change-Id from a jj change ID A Gerrit Change-Id is I followed by 40 hex characters. jj's change ID is 32 hex characters, so jj writes I, the change ID in hex, and the padding 6a6a6964, which is "jjid" in hex. The trailer is Change-Id: followed by that, or a Link trailer to review-url/id/ followed by that if gerrit.review-url is set. I change ID, hex (32 chars) 19b790168e73f7a73a98deae21e807c0 6a6a6964 "jjid" in hex (padding) default Change-Id: I…6a6a6964 with gerrit.review-url Link: URL/id/I…6a6a6964 appended after a blank line, or directly under existing trailers

An existing Change-Id or Link trailer is kept as is, even if malformed; jj only warns when a Change-Id isn't I plus 40 characters, or a Link doesn't end in /id/I plus 40 characters. Two such trailers in one commit is an error (source). The padding matches the format_gerrit_change_id_trailer template, so a trailer added locally by that template and one added here are identical (source).

Heads and pushes

Only the heads of the requested revisions are pushed; per the help text, Gerrit then has one change per uploaded commit, and an existing change with the same Change-Id gets a new patch set (source). Each head is pushed separately, because all of them go to the same refs/for/<branch> ref (source). A source comment notes that jj can't tell "no new changes" from success here (source).

Invariants: the local repo and its operation log are unchanged; every uploaded commit has a description and content; a commit already carrying a trailer is uploaded byte-for-byte unless one of its ancestors was rewritten.

7. Errors and edge cases

MessageWhenSource
No remote specified, and no 'gerrit' remote was foundNo remote could be chosen.upload.rs, test
The remote '<r>' (specified via `--remote`) does not exist / (configured via `gerrit.default-remote`)The named remote isn't configured.upload.rs, test
No target branch specified via --remote-branch, and no 'gerrit.default-remote-branch' was foundNo target branch.upload.rs, test
Custom values must be of the form 'key:value'; --wip and --ready are mutually exclusive (likewise private, publish-comments); --skip-validation is only supported for --submitInvalid option combinations, checked before anything else.upload.rs
No revision provided, or … @ is a merge commit with no description …, hint to use -rNo working-copy commit, or an undescribed merge @.upload.rs, test
Refusing to upload revision <id> because it is empty / … has no descriptionA commit in the upload set is undescribed, or empty with a single parent (empty merge commits are allowed).upload.rs
Multiple Change-Id trailers in revision <id>More than one Change-Id/Link trailer.upload.rs
Failed to push all changes to gerritThe remote rejected a ref.upload.rs, test

Two help texts don't match the code, so this page follows the code: --remote says it "can be a full SSH URL", but only configured remote names are accepted (source); and --merged is parsed but never turned into a push option (push_options).

8. Worked example

From upstream's test_gerrit_upload_local_implicit_change_ids. A clone has b on a@origin (immutable) and c on b, where c already has a Signed-off-by trailer. jj gerrit upload -r c --remote-branch=main uploads b and c as one head:

Found 1 heads to push to Gerrit (remote 'origin'), target branch 'main'.
Pushing yqosqzyt f6e97ced c | c
Local commits versus what reaches refs/for/main Locally, a 7d980be7, b 3bcb28c4 and c f6e97ced are unchanged. On the remote, refs/for/main points at 68b986d2, a copy of c with a Change-Id trailer added below Signed-off-by, whose parent 81b72352 is a copy of b with its own Change-Id; both sit on the unchanged a. local repo (unchanged) remote: refs/for/main 7d980be7 a a@origin, immutable 3bcb28c4 b f6e97ced c Signed-off-by: … 7d980be7 a 81b72352 b Change-Id: Id043564e…6a6a6964 68b986d2 c Signed-off-by: … Change-Id: I19b79016…6a6a6964

The remote's git log refs/for/main shows the trailers; the local jj log before and after is identical (source). The test pushes to a plain Git repo, where refs/for/main is just a ref; a real Gerrit server turns that push into changes.