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
| Flag | Type | Default | Meaning |
|---|---|---|---|
-r, --revision | revsets, repeatable | @ or @- (§3) | What to upload; mutable ancestors come too, so -r foo means mutable()::foo (source). |
-b, --remote-branch | branch | gerrit.default-remote-branch | Where the changes should land (source). |
--remote | remote name | see §2 | The Gerrit remote (source). |
-n, --dry-run | bool | false | Show what would be pushed without pushing (source). |
--reviewer, --cc, -l/--label, --hashtag, --custom | strings, repeatable | none | Gerrit push options; labels default to +1 on Gerrit's side, custom values must be key:value (source, custom). |
--topic, -m/--message, --deadline, --trace | string | none | Single-valued options; -m is the patch-set description, percent-encoded (source, source). |
--notify | none, owner, owner-reviewers, all | Gerrit's (all) | Who gets email (source, EmailNotification). |
--edit, --wip/--ready, --private/--remove-private, --publish-comments/--no-publish-comments, --submit, --skip-validation, --ignore-attention-set | bool | false | Gerrit flag options (source). |
--merged | bool | false | "Create a new change, even if the change has already been merged", but see §7 (source). |
-o, --option | string, repeatable | none | Any other git push -o option, passed through (source). |
2. Configuration
| Key | Default | Effect |
|---|---|---|
gerrit.default-remote | unset | The remote when --remote isn't given; must exist (schema). |
gerrit.default-remote-branch | unset | The target branch when -b isn't given; without either, the command fails (source). |
gerrit.review-url | unset | If 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 pushcan 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; aLinktrailer counts as aChange-Id(source). - Revsets:
immutable_heads()bounds the upload: immutable commits are assumed to be on Gerrit already, with trailers (source). - Git push:
git::push_updatesrunsgit 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
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
| Message | When | Source |
|---|---|---|
No remote specified, and no 'gerrit' remote was found | No 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 found | No 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 --submit | Invalid option combinations, checked before anything else. | upload.rs |
No revision provided, or … @ is a merge commit with no description …, hint to use -r | No working-copy commit, or an undescribed merge @. | upload.rs, test |
Refusing to upload revision <id> because it is empty / … has no description | A 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 gerrit | The 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_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
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.