···11-From a85628df2f2bc1b46374de546052f624b09f172b Mon Sep 17 00:00:00 2001
22-From: Austin Seipp <aseipp@pobox.com>
33-Date: Thu, 18 Jan 2024 00:35:09 -0600
44-Subject: [PATCH] cli: basic `jj gerrit upload` implementation
55-66-This implements the most basic workflow for submitting changes to Gerrit,
77-through a verb called 'upload'. This verb is intended to be distinct from the word
88-'submit', which for Gerrit means 'merge a change into the repository.'
99-1010-Given a list of revsets (specified by multiple `-r` options), this will parse
1111-the footers of every commit, collect them, insert a `Change-Id` (if one doesn't
1212-already exist), and then push them into the given remote.
1313-1414-Because the argument is a revset, you may submit entire trees of changes at
1515-once, including multiple trees of independent changes, e.g.
1616-1717- jj gerrit upload -r foo:: -r baz::
1818-1919-There are many other improvements that can be applied on top of this, including
2020-a ton of consistency and "does this make sense?" checks. However, it is flexible
2121-and a good starting point, and you can in fact both submit and cycle reviews
2222-with this interface.
2323-2424-Signed-off-by: Austin Seipp <aseipp@pobox.com>
2525----
2626- CHANGELOG.md | 2 +
2727- cli/src/commands/gerrit/mod.rs | 57 +++++
2828- cli/src/commands/gerrit/upload.rs | 384 +++++++++++++++++++++++++++++++
2929- cli/src/commands/mod.rs | 7 +
3030- cli/src/config-schema.json | 14 ++
3131- cli/tests/cli-reference@.md.snap | 38 +++
3232- cli/tests/runner.rs | 1 +
3333- cli/tests/test_gerrit_upload.rs | 89 +++++++
3434- 8 files changed, 592 insertions(+)
3535- create mode 100644 cli/src/commands/gerrit/mod.rs
3636- create mode 100644 cli/src/commands/gerrit/upload.rs
3737- create mode 100644 cli/tests/test_gerrit_upload.rs
3838-3939-diff --git a/CHANGELOG.md b/CHANGELOG.md
4040-index 267b5ed303..9bc1029fcf 100644
4141---- a/CHANGELOG.md
4242-+++ b/CHANGELOG.md
4343-@@ -107,6 +107,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
4444- * The new command `jj redo` can progressively redo operations that were
4545- previously undone by multiple calls to `jj undo`.
4646-4747-+* Gerrit support implemented with the new command `jj gerrit upload`
4848-+
4949- ### Fixed bugs
5050-5151- * `jj git clone` now correctly fetches all tags, unless `--fetch-tags` is
5252-diff --git a/cli/src/commands/gerrit/mod.rs b/cli/src/commands/gerrit/mod.rs
5353-new file mode 100644
5454-index 0000000000..60abdb6702
5555---- /dev/null
5656-+++ b/cli/src/commands/gerrit/mod.rs
5757-@@ -0,0 +1,57 @@
5858-+// Copyright 2024 The Jujutsu Authors
5959-+//
6060-+// Licensed under the Apache License, Version 2.0 (the "License");
6161-+// you may not use this file except in compliance with the License.
6262-+// You may obtain a copy of the License at
6363-+//
6464-+// https://www.apache.org/licenses/LICENSE-2.0
6565-+//
6666-+// Unless required by applicable law or agreed to in writing, software
6767-+// distributed under the License is distributed on an "AS IS" BASIS,
6868-+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
6969-+// See the License for the specific language governing permissions and
7070-+// limitations under the License.
7171-+
7272-+use std::fmt::Debug;
7373-+
7474-+use clap::Subcommand;
7575-+
7676-+use crate::cli_util::CommandHelper;
7777-+use crate::command_error::CommandError;
7878-+use crate::commands::gerrit;
7979-+use crate::ui::Ui;
8080-+
8181-+/// Interact with Gerrit Code Review.
8282-+#[derive(Subcommand, Clone, Debug)]
8383-+pub enum GerritCommand {
8484-+ /// Upload changes to Gerrit for code review, or update existing changes.
8585-+ ///
8686-+ /// Uploading in a set of revisions to Gerrit creates a single "change" for
8787-+ /// each revision included in the revset. This change is then available for
8888-+ /// review on your Gerrit instance.
8989-+ ///
9090-+ /// This command modifies each commit in the revset to include a `Change-Id`
9191-+ /// footer in its commit message if one does not already exist. Note that
9292-+ /// this ID is NOT compatible with jj IDs, and is Gerrit-specific.
9393-+ ///
9494-+ /// If a change already exists for a given revision (i.e. it contains the
9595-+ /// same `Change-Id`), this command will update the contents of the existing
9696-+ /// change to match.
9797-+ ///
9898-+ /// Note: this command takes 1-or-more revsets arguments, each of which can
9999-+ /// resolve to multiple revisions; so you may post trees or ranges of
100100-+ /// commits to Gerrit for review all at once.
101101-+ Upload(gerrit::upload::UploadArgs),
102102-+}
103103-+
104104-+pub fn cmd_gerrit(
105105-+ ui: &mut Ui,
106106-+ command: &CommandHelper,
107107-+ subcommand: &GerritCommand,
108108-+) -> Result<(), CommandError> {
109109-+ match subcommand {
110110-+ GerritCommand::Upload(review) => gerrit::upload::cmd_upload(ui, command, review),
111111-+ }
112112-+}
113113-+
114114-+mod upload;
115115-diff --git a/cli/src/commands/gerrit/upload.rs b/cli/src/commands/gerrit/upload.rs
116116-new file mode 100644
117117-index 0000000000..88c3ca5e97
118118---- /dev/null
119119-+++ b/cli/src/commands/gerrit/upload.rs
120120-@@ -0,0 +1,384 @@
121121-+// Copyright 2024 The Jujutsu Authors
122122-+//
123123-+// Licensed under the Apache License, Version 2.0 (the "License");
124124-+// you may not use this file except in compliance with the License.
125125-+// You may obtain a copy of the License at
126126-+//
127127-+// https://www.apache.org/licenses/LICENSE-2.0
128128-+//
129129-+// Unless required by applicable law or agreed to in writing, software
130130-+// distributed under the License is distributed on an "AS IS" BASIS,
131131-+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
132132-+// See the License for the specific language governing permissions and
133133-+// limitations under the License.
134134-+
135135-+use std::fmt::Debug;
136136-+use std::io::Write as _;
137137-+use std::rc::Rc;
138138-+use std::sync::Arc;
139139-+
140140-+use bstr::BStr;
141141-+use indexmap::IndexMap;
142142-+use itertools::Itertools as _;
143143-+use jj_lib::backend::CommitId;
144144-+use jj_lib::commit::Commit;
145145-+use jj_lib::commit::CommitIteratorExt as _;
146146-+use jj_lib::git::GitRefUpdate;
147147-+use jj_lib::git::{self};
148148-+use jj_lib::object_id::ObjectId as _;
149149-+use jj_lib::repo::Repo as _;
150150-+use jj_lib::revset::RevsetExpression;
151151-+use jj_lib::settings::UserSettings;
152152-+use jj_lib::store::Store;
153153-+use jj_lib::trailer::Trailer;
154154-+use jj_lib::trailer::parse_description_trailers;
155155-+
156156-+use crate::cli_util::CommandHelper;
157157-+use crate::cli_util::RevisionArg;
158158-+use crate::cli_util::short_commit_hash;
159159-+use crate::command_error::CommandError;
160160-+use crate::command_error::internal_error;
161161-+use crate::command_error::user_error;
162162-+use crate::command_error::user_error_with_hint;
163163-+use crate::command_error::user_error_with_message;
164164-+use crate::git_util::with_remote_git_callbacks;
165165-+use crate::ui::Ui;
166166-+
167167-+#[derive(clap::Args, Clone, Debug)]
168168-+pub struct UploadArgs {
169169-+ /// The revset, selecting which commits are sent in to Gerrit. This can be
170170-+ /// any arbitrary set of commits; they will be modified to include a
171171-+ /// `Change-Id` footer if one does not already exist, and then sent off to
172172-+ /// Gerrit for review.
173173-+ #[arg(long, short = 'r')]
174174-+ revisions: Vec<RevisionArg>,
175175-+
176176-+ /// The location where your changes are intended to land. This should be
177177-+ /// an upstream branch.
178178-+ #[arg(long = "remote-branch", short = 'b')]
179179-+ remote_branch: Option<String>,
180180-+
181181-+ /// The Gerrit remote to push to. Can be configured with the `gerrit.remote`
182182-+ /// repository option as well. This is typically a full SSH URL for your
183183-+ /// Gerrit instance.
184184-+ #[arg(long)]
185185-+ remote: Option<String>,
186186-+
187187-+ /// If true, do not actually add `Change-Id`s to commits, and do not push
188188-+ /// the changes to Gerrit.
189189-+ #[arg(long = "dry-run", short = 'n')]
190190-+ dry_run: bool,
191191-+}
192192-+
193193-+/// calculate push remote. The logic is:
194194-+/// 1. If the user specifies `--remote`, use that
195195-+/// 2. If the user has 'gerrit.remote' configured, use that
196196-+/// 3. If there is a default push remote, use that
197197-+/// 4. If the user has a remote named 'gerrit', use that
198198-+/// 5. otherwise, bail out
199199-+fn calculate_push_remote(
200200-+ store: &Arc<Store>,
201201-+ config: &UserSettings,
202202-+ remote: Option<String>,
203203-+) -> Result<String, CommandError> {
204204-+ let git_repo = git::get_git_repo(store)?; // will fail if not a git repo
205205-+ let remotes = git_repo.remote_names();
206206-+
207207-+ // case 1
208208-+ if let Some(remote) = remote {
209209-+ if remotes.contains(BStr::new(&remote)) {
210210-+ return Ok(remote);
211211-+ }
212212-+ return Err(user_error(format!(
213213-+ "The remote '{remote}' (specified via `--remote`) does not exist",
214214-+ )));
215215-+ }
216216-+
217217-+ // case 2
218218-+ if let Ok(remote) = config.get_string("gerrit.default-remote") {
219219-+ if remotes.contains(BStr::new(&remote)) {
220220-+ return Ok(remote);
221221-+ }
222222-+ return Err(user_error(format!(
223223-+ "The remote '{remote}' (configured via `gerrit.default-remote`) does not exist",
224224-+ )));
225225-+ }
226226-+
227227-+ // case 3
228228-+ if let Some(remote) = git_repo.remote_default_name(gix::remote::Direction::Push) {
229229-+ return Ok(remote.to_string());
230230-+ }
231231-+
232232-+ // case 4
233233-+ if remotes.iter().any(|r| **r == "gerrit") {
234234-+ return Ok("gerrit".to_owned());
235235-+ }
236236-+
237237-+ // case 5
238238-+ Err(user_error(
239239-+ "No remote specified, and no 'gerrit' remote was found",
240240-+ ))
241241-+}
242242-+
243243-+/// Determine what Gerrit ref and remote to use. The logic is:
244244-+///
245245-+/// 1. If the user specifies `--remote-branch branch`, use that
246246-+/// 2. If the user has 'gerrit.default-remote-branch' configured, use that
247247-+/// 3. Otherwise, bail out
248248-+fn calculate_push_ref(
249249-+ config: &UserSettings,
250250-+ remote_branch: Option<String>,
251251-+) -> Result<String, CommandError> {
252252-+ // case 1
253253-+ if let Some(remote_branch) = remote_branch {
254254-+ return Ok(remote_branch);
255255-+ }
256256-+
257257-+ // case 2
258258-+ if let Ok(branch) = config.get_string("gerrit.default-remote-branch") {
259259-+ return Ok(branch);
260260-+ }
261261-+
262262-+ // case 3
263263-+ Err(user_error(
264264-+ "No target branch specified via --remote-branch, and no 'gerrit.default-remote-branch' \
265265-+ was found",
266266-+ ))
267267-+}
268268-+
269269-+pub fn cmd_upload(ui: &mut Ui, command: &CommandHelper, upload: &UploadArgs) -> Result<(), CommandError> {
270270-+ let mut workspace_command = command.workspace_helper(ui)?;
271271-+
272272-+ let revisions: Vec<_> = workspace_command
273273-+ .parse_union_revsets(ui, &upload.revisions)?
274274-+ .evaluate_to_commits()?
275275-+ .try_collect()?;
276276-+ if revisions.is_empty() {
277277-+ writeln!(ui.status(), "No revisions to upload.")?;
278278-+ return Ok(());
279279-+ }
280280-+
281281-+ if revisions
282282-+ .iter()
283283-+ .any(|commit| commit.id() == workspace_command.repo().store().root_commit_id())
284284-+ {
285285-+ return Err(user_error("Cannot upload the virtual 'root()' commit"));
286286-+ }
287287-+
288288-+ workspace_command.check_rewritable(revisions.iter().ids())?;
289289-+
290290-+ // If you have the changes main -> A -> B, and then run `jj gerrit upload B`,
291291-+ // then that uploads both A and B. Thus, we need to ensure that A also
292292-+ // has a Change-ID.
293293-+ // We make an assumption here that all immutable commits already have a
294294-+ // Change-ID.
295295-+ let to_upload: Vec<Commit> = workspace_command
296296-+ .attach_revset_evaluator(
297297-+ // I'm unsure, but this *might* have significant performance
298298-+ // implications. If so, we can change it to a maximum depth.
299299-+ Rc::new(RevsetExpression::Difference(
300300-+ // Unfortunately, DagRange{root: immutable_heads, heads: commits}
301301-+ // doesn't work if you're, for example, working on top of an
302302-+ // immutable commit that isn't in immutable_heads().
303303-+ Rc::new(RevsetExpression::Ancestors {
304304-+ heads: RevsetExpression::commits(
305305-+ revisions.iter().ids().cloned().collect::<Vec<_>>(),
306306-+ ),
307307-+ generation: jj_lib::revset::GENERATION_RANGE_FULL,
308308-+ parents_range: jj_lib::revset::PARENTS_RANGE_FULL,
309309-+ }),
310310-+ workspace_command.env().immutable_expression().clone(),
311311-+ )),
312312-+ )
313313-+ .evaluate_to_commits()?
314314-+ .try_collect()?;
315315-+
316316-+ let mut tx = workspace_command.start_transaction();
317317-+ let base_repo = tx.base_repo().clone();
318318-+ let store = base_repo.store();
319319-+
320320-+ let old_heads = base_repo
321321-+ .index()
322322-+ .heads(&mut revisions.iter().ids())
323323-+ .map_err(internal_error)?;
324324-+
325325-+ let git_settings = command.settings().git_settings()?;
326326-+ let remote = calculate_push_remote(store, command.settings(), upload.remote.clone())?;
327327-+ let remote_branch = calculate_push_ref(command.settings(), upload.remote_branch.clone())?;
328328-+
329329-+ // immediately error and reject any discardable commits, i.e. the
330330-+ // the empty wcc
331331-+ for commit in &to_upload {
332332-+ if commit.is_discardable(tx.repo_mut())? {
333333-+ return Err(user_error_with_hint(
334334-+ format!(
335335-+ "Refusing to upload commit {} because it is an empty commit with no description",
336336-+ short_commit_hash(commit.id())
337337-+ ),
338338-+ "Perhaps you squashed then ran upload? Maybe you meant to upload the parent commit \
339339-+ instead (eg. @-)",
340340-+ ));
341341-+ }
342342-+ }
343343-+
344344-+ let mut old_to_new: IndexMap<CommitId, Commit> = IndexMap::new();
345345-+ for commit_id in to_upload.iter().map(|c| c.id()).rev() {
346346-+ let original_commit = store.get_commit(commit_id).unwrap();
347347-+ let description = original_commit.description().to_owned();
348348-+ let trailers = parse_description_trailers(&description);
349349-+
350350-+ let change_id_trailers: Vec<&Trailer> = trailers
351351-+ .iter()
352352-+ .filter(|trailer| trailer.key == "Change-Id")
353353-+ .collect();
354354-+
355355-+ // There shouldn't be multiple change-ID fields. So just error out if
356356-+ // there is.
357357-+ if change_id_trailers.len() > 1 {
358358-+ return Err(user_error(format!(
359359-+ "multiple Change-Id footers in commit {}",
360360-+ short_commit_hash(commit_id)
361361-+ )));
362362-+ }
363363-+
364364-+ // The user can choose to explicitly set their own change-ID to
365365-+ // override the default change-ID based on the jj change-ID.
366366-+ if let Some(trailer) = change_id_trailers.first() {
367367-+ // Check the change-id format is correct.
368368-+ if trailer.value.len() != 41 || !trailer.value.starts_with('I') {
369369-+ // Intentionally leave the invalid change IDs as-is.
370370-+ writeln!(
371371-+ ui.warning_default(),
372372-+ "warning: invalid Change-Id footer in commit {}",
373373-+ short_commit_hash(original_commit.id()),
374374-+ )?;
375375-+ }
376376-+
377377-+ // map the old commit to itself
378378-+ old_to_new.insert(original_commit.id().clone(), original_commit.clone());
379379-+ continue;
380380-+ }
381381-+
382382-+ // Gerrit change id is 40 chars, jj change id is 32, so we need padding.
383383-+ // To be consistent with `format_gerrit_change_id_trailer``, we pad with
384384-+ // 6a6a6964 (hex of "jjid").
385385-+ let gerrit_change_id = format!("I6a6a6964{}", original_commit.change_id().hex());
386386-+
387387-+ let new_description = format!(
388388-+ "{}{}Change-Id: {}\n",
389389-+ description.trim(),
390390-+ if trailers.is_empty() { "\n\n" } else { "\n" },
391391-+ gerrit_change_id
392392-+ );
393393-+
394394-+ let new_parents = original_commit
395395-+ .parents()
396396-+ .map(|parent| {
397397-+ let p = parent.unwrap();
398398-+ if let Some(rewritten_parent) = old_to_new.get(p.id()) {
399399-+ rewritten_parent
400400-+ } else {
401401-+ &p
402402-+ }
403403-+ .id()
404404-+ .clone()
405405-+ })
406406-+ .collect();
407407-+
408408-+ // rewrite the set of parents to point to the commits that were
409409-+ // previously rewritten in toposort order
410410-+ //
411411-+ // TODO FIXME (aseipp): this whole dance with toposorting, calculating
412412-+ // new_parents, and then doing rewrite_commit is roughly equivalent to
413413-+ // what we do in duplicate.rs as well. we should probably refactor this?
414414-+ let new_commit = tx
415415-+ .repo_mut()
416416-+ .rewrite_commit(&original_commit)
417417-+ .set_description(new_description)
418418-+ .set_parents(new_parents)
419419-+ // Set the timestamp back to the timestamp of the original commit.
420420-+ // Otherwise, `jj gerrit upload @ && jj gerrit upload @` will upload
421421-+ // two patchsets with the only difference being the timestamp.
422422-+ .set_committer(original_commit.committer().clone())
423423-+ .set_author(original_commit.author().clone())
424424-+ .write()?;
425425-+
426426-+ old_to_new.insert(original_commit.id().clone(), new_commit.clone());
427427-+ }
428428-+ writeln!(ui.stderr())?;
429429-+
430430-+ let remote_ref = format!("refs/for/{remote_branch}");
431431-+ writeln!(
432432-+ ui.stderr(),
433433-+ "Found {} heads to push to Gerrit (remote '{}'), target branch '{}'",
434434-+ old_heads.len(),
435435-+ remote,
436436-+ remote_branch,
437437-+ )?;
438438-+
439439-+ writeln!(ui.stderr())?;
440440-+
441441-+ // NOTE (aseipp): because we are pushing everything to the same remote ref,
442442-+ // we have to loop and push each commit one at a time, even though
443443-+ // push_updates in theory supports multiple GitRefUpdates at once, because
444444-+ // we obviously can't push multiple heads to the same ref.
445445-+ for head in &old_heads {
446446-+ write!(
447447-+ ui.stderr(),
448448-+ "{}",
449449-+ if upload.dry_run {
450450-+ "Dry-run: Would push "
451451-+ } else {
452452-+ "Pushing "
453453-+ }
454454-+ )?;
455455-+ // We have to write the old commit here, because the until we finish
456456-+ // the transaction (which we don't), the new commit is labelled as
457457-+ // "hidden".
458458-+ tx.base_workspace_helper().write_commit_summary(
459459-+ ui.stderr_formatter().as_mut(),
460460-+ &store.get_commit(head).unwrap(),
461461-+ )?;
462462-+ writeln!(ui.stderr())?;
463463-+
464464-+ if upload.dry_run {
465465-+ continue;
466466-+ }
467467-+
468468-+ let new_commit = store
469469-+ .get_commit(old_to_new.get(head).unwrap().id())
470470-+ .unwrap();
471471-+
472472-+ // how do we get better errors from the remote? 'git push' tells us
473473-+ // about rejected refs AND ALSO '(nothing changed)' when there are no
474474-+ // changes to push, but we don't get that here.
475475-+ with_remote_git_callbacks(ui, |cb| {
476476-+ git::push_updates(
477477-+ tx.repo_mut(),
478478-+ &git_settings,
479479-+ remote.as_ref(),
480480-+ &[GitRefUpdate {
481481-+ qualified_name: remote_ref.clone().into(),
482482-+ expected_current_target: None,
483483-+ new_target: Some(new_commit.id().clone()),
484484-+ }],
485485-+ cb,
486486-+ )
487487-+ })
488488-+ // Despite the fact that a manual git push will error out with 'no new
489489-+ // changes' if you're up to date, this git backend appears to silently
490490-+ // succeed - no idea why.
491491-+ // It'd be nice if we could distinguish this. We should ideally succeed,
492492-+ // but give the user a warning.
493493-+ .map_err(|err| match err {
494494-+ git::GitPushError::NoSuchRemote(_)
495495-+ | git::GitPushError::RemoteName(_)
496496-+ | git::GitPushError::UnexpectedBackend(_) => user_error(err),
497497-+ git::GitPushError::Subprocess(_) => {
498498-+ user_error_with_message("Internal git error while pushing to gerrit", err)
499499-+ }
500500-+ })?;
501501-+ }
502502-+
503503-+ Ok(())
504504-+}
505505-diff --git a/cli/src/commands/mod.rs b/cli/src/commands/mod.rs
506506-index cdf3c9c3ad..cb7b4ca185 100644
507507---- a/cli/src/commands/mod.rs
508508-+++ b/cli/src/commands/mod.rs
509509-@@ -30,6 +30,8 @@ mod evolog;
510510- mod file;
511511- mod fix;
512512- #[cfg(feature = "git")]
513513-+mod gerrit;
514514-+#[cfg(feature = "git")]
515515- mod git;
516516- mod help;
517517- mod interdiff;
518518-@@ -115,6 +117,9 @@ enum Command {
519519- Fix(fix::FixArgs),
520520- #[cfg(feature = "git")]
521521- #[command(subcommand)]
522522-+ Gerrit(gerrit::GerritCommand),
523523-+ #[cfg(feature = "git")]
524524-+ #[command(subcommand)]
525525- Git(git::GitCommand),
526526- Help(help::HelpArgs),
527527- Interdiff(interdiff::InterdiffArgs),
528528-@@ -180,6 +185,8 @@ pub fn run_command(ui: &mut Ui, command_helper: &CommandHelper) -> Result<(), Co
529529- Command::File(args) => file::cmd_file(ui, command_helper, args),
530530- Command::Fix(args) => fix::cmd_fix(ui, command_helper, args),
531531- #[cfg(feature = "git")]
532532-+ Command::Gerrit(sub_args) => gerrit::cmd_gerrit(ui, command_helper, sub_args),
533533-+ #[cfg(feature = "git")]
534534- Command::Git(args) => git::cmd_git(ui, command_helper, args),
535535- Command::Help(args) => help::cmd_help(ui, command_helper, args),
536536- Command::Interdiff(args) => interdiff::cmd_interdiff(ui, command_helper, args),
537537-diff --git a/cli/src/config-schema.json b/cli/src/config-schema.json
538538-index 887c34e2ba..d15b334ecf 100644
539539---- a/cli/src/config-schema.json
540540-+++ b/cli/src/config-schema.json
541541-@@ -490,6 +490,20 @@
542542- }
543543- }
544544- },
545545-+ "gerrit": {
546546-+ "type": "object",
547547-+ "description": "Settings for interacting with Gerrit",
548548-+ "properties": {
549549-+ "default-remote": {
550550-+ "type": "string",
551551-+ "description": "The Gerrit remote to interact with"
552552-+ },
553553-+ "default-remote-branch": {
554554-+ "type": "string",
555555-+ "description": "The default branch to propose changes for"
556556-+ }
557557-+ }
558558-+ },
559559- "merge-tools": {
560560- "type": "object",
561561- "description": "Tables of custom options to pass to the given merge tool (selected in ui.merge-editor)",
562562-diff --git a/cli/tests/cli-reference@.md.snap b/cli/tests/cli-reference@.md.snap
563563-index a97a0ffc55..dff8bcbe37 100644
564564---- a/cli/tests/cli-reference@.md.snap
565565-+++ b/cli/tests/cli-reference@.md.snap
566566-@@ -45,6 +45,8 @@ This document contains the help content for the `jj` command-line program.
567567- * [`jj file track`↴](#jj-file-track)
568568- * [`jj file untrack`↴](#jj-file-untrack)
569569- * [`jj fix`↴](#jj-fix)
570570-+* [`jj gerrit`↴](#jj-gerrit)
571571-+* [`jj gerrit upload`↴](#jj-gerrit-upload)
572572- * [`jj git`↴](#jj-git)
573573- * [`jj git clone`↴](#jj-git-clone)
574574- * [`jj git export`↴](#jj-git-export)
575575-@@ -139,6 +141,7 @@ To get started, see the tutorial [`jj help -k tutorial`].
576576- * `evolog` — Show how a change has evolved over time
577577- * `file` — File operations
578578- * `fix` — Update files with formatting fixes or other changes
579579-+* `gerrit` — Interact with Gerrit Code Review
580580- * `git` — Commands for working with Git remotes and the underlying Git repo
581581- * `help` — Print this message or the help of the given subcommand(s)
582582- * `interdiff` — Compare the changes of two commits
583583-@@ -1177,6 +1180,41 @@ output of the first tool.
584584-585585-586586-587587-+## `jj gerrit`
588588-+
589589-+Interact with Gerrit Code Review
590590-+
591591-+**Usage:** `jj gerrit <COMMAND>`
592592-+
593593-+###### **Subcommands:**
594594-+
595595-+* `upload` — Upload changes to Gerrit for code review, or update existing changes
596596-+
597597-+
598598-+
599599-+## `jj gerrit upload`
600600-+
601601-+Upload changes to Gerrit for code review, or update existing changes.
602602-+
603603-+Uploading in a set of revisions to Gerrit creates a single "change" for each revision included in the revset. This change is then available for review on your Gerrit instance.
604604-+
605605-+This command modifies each commit in the revset to include a `Change-Id` footer in its commit message if one does not already exist. Note that this ID is NOT compatible with jj IDs, and is Gerrit-specific.
606606-+
607607-+If a change already exists for a given revision (i.e. it contains the same `Change-Id`), this command will update the contents of the existing change to match.
608608-+
609609-+Note: this command takes 1-or-more revsets arguments, each of which can resolve to multiple revisions; so you may post trees or ranges of commits to Gerrit for review all at once.
610610-+
611611-+**Usage:** `jj gerrit upload [OPTIONS]`
612612-+
613613-+###### **Options:**
614614-+
615615-+* `-r`, `--revisions <REVISIONS>` — The revset, selecting which commits are sent in to Gerrit. This can be any arbitrary set of commits; they will be modified to include a `Change-Id` footer if one does not already exist, and then sent off to Gerrit for review
616616-+* `-b`, `--remote-branch <REMOTE_BRANCH>` — The location where your changes are intended to land. This should be an upstream branch
617617-+* `--remote <REMOTE>` — The Gerrit remote to push to. Can be configured with the `gerrit.remote` repository option as well. This is typically a full SSH URL for your Gerrit instance
618618-+* `-n`, `--dry-run` — If true, do not actually add `Change-Id`s to commits, and do not push the changes to Gerrit
619619-+
620620-+
621621-+
622622- ## `jj git`
623623-624624- Commands for working with Git remotes and the underlying Git repo
625625-diff --git a/cli/tests/runner.rs b/cli/tests/runner.rs
626626-index 88c1ca2319..f228da5e70 100644
627627---- a/cli/tests/runner.rs
628628-+++ b/cli/tests/runner.rs
629629-@@ -37,6 +37,7 @@ mod test_file_show_command;
630630- mod test_file_track_untrack_commands;
631631- mod test_fix_command;
632632- mod test_generate_md_cli_help;
633633-+mod test_gerrit_upload;
634634- mod test_git_clone;
635635- mod test_git_colocated;
636636- mod test_git_fetch;
637637-diff --git a/cli/tests/test_gerrit_upload.rs b/cli/tests/test_gerrit_upload.rs
638638-new file mode 100644
639639-index 0000000000..71543cedd8
640640---- /dev/null
641641-+++ b/cli/tests/test_gerrit_upload.rs
642642-@@ -0,0 +1,89 @@
643643-+// Copyright 2025 The Jujutsu Authors
644644-+//
645645-+// Licensed under the Apache License, Version 2.0 (the "License");
646646-+// you may not use this file except in compliance with the License.
647647-+// You may obtain a copy of the License at
648648-+//
649649-+// https://www.apache.org/licenses/LICENSE-2.0
650650-+//
651651-+// Unless required by applicable law or agreed to in writing, software
652652-+// distributed under the License is distributed on an "AS IS" BASIS,
653653-+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
654654-+// See the License for the specific language governing permissions and
655655-+// limitations under the License.
656656-+
657657-+use crate::common::TestEnvironment;
658658-+use crate::common::create_commit;
659659-+
660660-+#[test]
661661-+fn test_gerrit_upload_dryrun() {
662662-+ let test_env = TestEnvironment::default();
663663-+ test_env.run_jj_in(".", ["git", "init", "repo"]).success();
664664-+ let work_dir = test_env.work_dir("repo");
665665-+
666666-+ create_commit(&work_dir, "a", &[]);
667667-+ create_commit(&work_dir, "b", &["a"]);
668668-+ create_commit(&work_dir, "c", &["a"]);
669669-+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b"]);
670670-+ insta::assert_snapshot!(output, @r###"
671671-+ ------- stderr -------
672672-+ Error: No remote specified, and no 'gerrit' remote was found
673673-+ [EOF]
674674-+ [exit status: 1]
675675-+ "###);
676676-+
677677-+ // With remote specified but.
678678-+ test_env.add_config(r#"gerrit.default-remote="origin""#);
679679-+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b"]);
680680-+ insta::assert_snapshot!(output, @r###"
681681-+ ------- stderr -------
682682-+ Error: The remote 'origin' (configured via `gerrit.default-remote`) does not exist
683683-+ [EOF]
684684-+ [exit status: 1]
685685-+ "###);
686686-+
687687-+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b", "--remote=origin"]);
688688-+ insta::assert_snapshot!(output, @r###"
689689-+ ------- stderr -------
690690-+ Error: The remote 'origin' (specified via `--remote`) does not exist
691691-+ [EOF]
692692-+ [exit status: 1]
693693-+ "###);
694694-+
695695-+ let output = work_dir.run_jj([
696696-+ "git",
697697-+ "remote",
698698-+ "add",
699699-+ "origin",
700700-+ "http://example.com/repo/foo",
701701-+ ]);
702702-+ insta::assert_snapshot!(output, @"");
703703-+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b", "--remote=origin"]);
704704-+ insta::assert_snapshot!(output, @r###"
705705-+ ------- stderr -------
706706-+ Error: No target branch specified via --remote-branch, and no 'gerrit.default-remote-branch' was found
707707-+ [EOF]
708708-+ [exit status: 1]
709709-+ "###);
710710-+
711711-+ test_env.add_config(r#"gerrit.default-remote-branch="main""#);
712712-+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b", "--dry-run"]);
713713-+ insta::assert_snapshot!(output, @r###"
714714-+ ------- stderr -------
715715-+
716716-+ Found 1 heads to push to Gerrit (remote 'origin'), target branch 'main'
717717-+
718718-+ Dry-run: Would push zsuskuln 123b4d91 b | b
719719-+ [EOF]
720720-+ "###);
721721-+
722722-+ let output = work_dir.run_jj(["gerrit", "upload", "-r", "b", "--dry-run", "-b", "other"]);
723723-+ insta::assert_snapshot!(output, @r###"
724724-+ ------- stderr -------
725725-+
726726-+ Found 1 heads to push to Gerrit (remote 'origin'), target branch 'other'
727727-+
728728-+ Dry-run: Would push zsuskuln 123b4d91 b | b
729729-+ [EOF]
730730-+ "###);
731731-+}
732732-