// Copyright 2024 The Jujutsu Authors // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // https://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. use std::collections::HashMap; use std::fmt::Debug; use std::io::Write as _; use std::sync::Arc; use bstr::BStr; use itertools::Itertools as _; use jj_lib::backend::BackendError; use jj_lib::backend::CommitId; use jj_lib::commit::Commit; use jj_lib::git::GitRefUpdate; use jj_lib::git::{self}; use jj_lib::object_id::ObjectId as _; use jj_lib::repo::Repo as _; use jj_lib::revset::RevsetExpression; use jj_lib::settings::UserSettings; use jj_lib::store::Store; use jj_lib::trailer::Trailer; use jj_lib::trailer::parse_description_trailers; use crate::cli_util::CommandHelper; use crate::cli_util::RevisionArg; use crate::cli_util::short_change_hash; use crate::command_error::CommandError; use crate::command_error::internal_error; use crate::command_error::user_error; use crate::command_error::user_error_with_hint; use crate::command_error::user_error_with_message; use crate::git_util::with_remote_git_callbacks; use crate::ui::Ui; /// Upload changes to Gerrit for code review, or update existing changes. /// /// Uploading in a set of revisions to Gerrit creates a single "change" for /// each revision included in the revset. These changes are then available /// for review on your Gerrit instance. /// /// Note: The gerrit commit Id may not match that of your local commit Id, /// since we add a `Change-Id` footer to the commit message if one does not /// already exist. This ID is based off the jj Change-Id, but is not the same. /// /// 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. /// /// 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. #[derive(clap::Args, Clone, Debug)] pub struct UploadArgs { /// The revset, selecting which revisions are sent in to Gerrit /// /// This can be any arbitrary set of commits. Note that when you push a /// commit at the head of a stack, all ancestors are pushed too. This means /// that `jj gerrit upload -r foo` is equivalent to `jj gerrit upload -r /// 'mutable()::foo`. #[arg(long, short = 'r')] revisions: Vec, /// The location where your changes are intended to land /// /// This should be a branch on the remote. Can be configured with the /// `gerrit.default-branch` repository option. #[arg(long = "remote-branch", short = 'b')] remote_branch: Option, /// The Gerrit remote to push to /// /// Can be configured with the `gerrit.default-remote` repository option as /// well. This is typically a full SSH URL for your Gerrit instance. #[arg(long)] remote: Option, /// Do not actually push the changes to Gerrit #[arg(long = "dry-run", short = 'n')] dry_run: bool, } fn calculate_push_remote( store: &Arc, settings: &UserSettings, remote: Option<&str>, ) -> Result { let git_repo = git::get_git_repo(store)?; // will fail if not a git repo let remotes = git_repo.remote_names(); // If --remote was provided, use that if let Some(remote) = remote { if remotes.contains(BStr::new(&remote)) { return Ok(remote.to_string()); } return Err(user_error(format!( "The remote '{remote}' (specified via `--remote`) does not exist", ))); } // If the Gerrit-specific config was set, use that if let Ok(remote) = settings.get_string("gerrit.default-remote") { if remotes.contains(BStr::new(&remote)) { return Ok(remote); } return Err(user_error(format!( "The remote '{remote}' (configured via `gerrit.default-remote`) does not exist", ))); } // If a general push remote was configured, use that if let Some(remote) = git_repo.remote_default_name(gix::remote::Direction::Push) { return Ok(remote.to_string()); } // If there is a Git remote called "gerrit", use that if remotes.iter().any(|r| **r == "gerrit") { return Ok("gerrit".to_owned()); } // Otherwise error out Err(user_error( "No remote specified, and no 'gerrit' remote was found", )) } /// Determine what Gerrit ref and remote to use. The logic is: /// /// 1. If the user specifies `--remote-branch branch`, use that /// 2. If the user has 'gerrit.default-remote-branch' configured, use that /// 3. Otherwise, bail out fn calculate_push_ref( settings: &UserSettings, remote_branch: Option, ) -> Result { // case 1 if let Some(remote_branch) = remote_branch { return Ok(remote_branch); } // case 2 if let Ok(branch) = settings.get_string("gerrit.default-remote-branch") { return Ok(branch); } // case 3 Err(user_error( "No target branch specified via --remote-branch, and no 'gerrit.default-remote-branch' \ was found", )) } pub fn cmd_gerrit_upload( ui: &mut Ui, command: &CommandHelper, args: &UploadArgs, ) -> Result<(), CommandError> { let mut workspace_command = command.workspace_helper(ui)?; let target_expr = workspace_command .parse_union_revsets(ui, &args.revisions)? .resolve()?; workspace_command.check_rewritable_expr(&target_expr)?; let revisions: Vec<_> = target_expr .evaluate(workspace_command.repo().as_ref())? .iter() .try_collect()?; if revisions.is_empty() { writeln!(ui.status(), "No revisions to upload.")?; return Ok(()); } // If you have the changes main -> A -> B, and then run `jj gerrit upload -r B`, // then that uploads both A and B. Thus, we need to ensure that A also // has a Change-ID. // We make an assumption here that all immutable commits already have a // Change-ID. let to_upload: Vec = workspace_command .attach_revset_evaluator( workspace_command .env() .immutable_expression() .range(&RevsetExpression::commits(revisions.clone())), ) .evaluate_to_commits()? .try_collect()?; // Note: This transaction is intentionally never finished. This way, the // Change-Id is never part of the commit description in jj. // This avoids scenarios where you have many commits with the same // Change-Id, or a single commit with many Change-Ids after running // jj split / jj squash respectively. // If a user doesn't like this behavior, they can add the following to // their Cargo.toml. // commit_trailers = 'if(!trailers.contains_key("Change-Id"), // format_gerrit_change_id_trailer(self))' let mut tx = workspace_command.start_transaction(); let base_repo = tx.base_repo(); let store = base_repo.store().clone(); let old_heads = base_repo .index() .heads(&mut revisions.iter()) .map_err(internal_error)?; let git_settings = command.settings().git_settings()?; let remote = calculate_push_remote(&store, command.settings(), args.remote.as_deref())?; let remote_branch = calculate_push_ref(command.settings(), args.remote_branch.clone())?; // Immediately error and reject any commits that shouldn't be uploaded. for commit in &to_upload { if commit.is_empty(tx.repo_mut())? { return Err(user_error_with_hint( format!( "Refusing to upload revision {} because it is empty", short_change_hash(commit.change_id()) ), "Perhaps you squashed then ran upload? Maybe you meant to upload the parent \ commit instead (eg. @-)", )); } if commit.description().is_empty() { return Err(user_error_with_hint( format!( "Refusing to upload revision {} because it is has no description", short_change_hash(commit.change_id()) ), "Maybe you meant to upload the parent commit instead (eg. @-)", )); } } let mut old_to_new: HashMap = HashMap::new(); for original_commit in to_upload.into_iter().rev() { let trailers = parse_description_trailers(original_commit.description()); let change_id_trailers: Vec<&Trailer> = trailers .iter() .filter(|trailer| trailer.key == "Change-Id") .collect(); // There shouldn't be multiple change-ID fields. So just error out if // there is. if change_id_trailers.len() > 1 { return Err(user_error(format!( "multiple Change-Id footers in revision {}", short_change_hash(original_commit.change_id()) ))); } // The user can choose to explicitly set their own change-ID to // override the default change-ID based on the jj change-ID. if let Some(trailer) = change_id_trailers.first() { // Check the change-id format is correct. if trailer.value.len() != 41 || !trailer.value.starts_with('I') { // Intentionally leave the invalid change IDs as-is. writeln!( ui.warning_default(), "warning: invalid Change-Id footer in revision {}", short_change_hash(original_commit.change_id()), )?; } // map the old commit to itself old_to_new.insert(original_commit.id().clone(), original_commit); continue; } // Gerrit change id is 40 chars, jj change id is 32, so we need padding. // To be consistent with `format_gerrit_change_id_trailer``, we pad with // 6a6a6964 (hex of "jjid"). let gerrit_change_id = format!("I{}6a6a6964", original_commit.change_id().hex()); let new_description = format!( "{}{}Change-Id: {}\n", original_commit.description().trim(), if trailers.is_empty() { "\n\n" } else { "\n" }, gerrit_change_id ); let new_parents = original_commit .parents() .map(|parent| -> Result { let p = parent?; Ok(old_to_new.get(p.id()).unwrap_or(&p).id().clone()) }) .try_collect()?; // rewrite the set of parents to point to the commits that were // previously rewritten in toposort order let new_commit = tx .repo_mut() .rewrite_commit(&original_commit) .set_description(new_description) .set_parents(new_parents) // Set the timestamp back to the timestamp of the original commit. // Otherwise, `jj gerrit upload @ && jj gerrit upload @` will upload // two patchsets with the only difference being the timestamp. .set_committer(original_commit.committer().clone()) .set_author(original_commit.author().clone()) .write()?; old_to_new.insert(original_commit.id().clone(), new_commit); } writeln!(ui.stderr())?; let remote_ref = format!("refs/for/{remote_branch}"); writeln!( ui.stderr(), "Found {} heads to push to Gerrit (remote '{}'), target branch '{}'", old_heads.len(), remote, remote_branch, )?; writeln!(ui.stderr())?; // NOTE (aseipp): because we are pushing everything to the same remote ref, // we have to loop and push each commit one at a time, even though // push_updates in theory supports multiple GitRefUpdates at once, because // we obviously can't push multiple heads to the same ref. for head in &old_heads { write!( ui.stderr(), "{}", if args.dry_run { "Dry-run: Would push " } else { "Pushing " } )?; // We have to write the old commit here, because until we finish // the transaction (which we don't), the new commit is labeled as // "hidden". tx.base_workspace_helper().write_commit_summary( ui.stderr_formatter().as_mut(), &store.get_commit(head).unwrap(), )?; writeln!(ui.stderr())?; if args.dry_run { continue; } let new_commit = old_to_new.get(head).unwrap(); // how do we get better errors from the remote? 'git push' tells us // about rejected refs AND ALSO '(nothing changed)' when there are no // changes to push, but we don't get that here. with_remote_git_callbacks(ui, |cb| { git::push_updates( tx.repo_mut(), &git_settings, remote.as_ref(), &[GitRefUpdate { qualified_name: remote_ref.clone().into(), expected_current_target: None, new_target: Some(new_commit.id().clone()), }], cb, ) }) // Despite the fact that a manual git push will error out with 'no new // changes' if you're up to date, this git backend appears to silently // succeed - no idea why. // It'd be nice if we could distinguish this. We should ideally succeed, // but give the user a warning. .map_err(|err| match err { git::GitPushError::NoSuchRemote(_) | git::GitPushError::RemoteName(_) | git::GitPushError::UnexpectedBackend(_) => user_error(err), git::GitPushError::Subprocess(_) => { user_error_with_message("Internal git error while pushing to gerrit", err) } })?; } Ok(()) }