use std::collections::HashMap;
use std::fmt::Debug;
use std::io::Write as _;
use std::sync::Arc;
use bstr::BStr;
use futures::TryStreamExt as _;
use jj_lib::backend::CommitId;
use jj_lib::commit::Commit;
use jj_lib::git;
use jj_lib::git::GitPushOptions;
use jj_lib::git::GitRefUpdate;
use jj_lib::git::GitSubprocessOptions;
use jj_lib::merge::Diff;
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 percent_encoding::NON_ALPHANUMERIC;
use percent_encoding::utf8_percent_encode;
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_message;
use crate::git_util::GitSubprocessUi;
use crate::git_util::print_push_stats;
use crate::ui::Ui;
#[derive(clap::Args, Clone, Debug, Default)]
pub struct UploadArgs {
#[arg(long = "revision", short, value_name = "REVSETS", alias = "revisions")]
revisions: Vec<RevisionArg>,
#[arg(long = "remote-branch", short = 'b')]
remote_branch: Option<String>,
#[arg(long)]
remote: Option<String>,
#[arg(long = "dry-run", short = 'n')]
dry_run: bool,
#[arg(long)]
reviewer: Vec<String>,
#[arg(long)]
cc: Vec<String>,
#[arg(long, short = 'l')]
label: Vec<String>,
#[arg(long)]
topic: Option<String>,
#[arg(long)]
hashtag: Vec<String>,
#[arg(long, short = 'm')]
message: Option<String>,
#[arg(long)]
edit: bool,
#[arg(long)]
wip: bool,
#[arg(long)]
ready: bool,
#[arg(long)]
private: bool,
#[arg(long)]
remove_private: bool,
#[arg(long)]
publish_comments: bool,
#[arg(long)]
no_publish_comments: bool,
#[arg(long, value_enum)]
notify: Option<EmailNotification>,
#[arg(long)]
submit: bool,
#[arg(long)]
skip_validation: bool,
#[arg(long)]
merged: bool,
#[arg(long)]
ignore_attention_set: bool,
#[arg(long)]
deadline: Option<String>,
#[arg(long)]
custom: Vec<String>,
#[arg(long)]
trace: Option<String>,
}
#[derive(clap::ValueEnum, Clone, Debug)]
#[value(rename_all = "kebab_case")]
pub enum EmailNotification {
None,
Owner,
OwnerReviewers,
All,
}
fn calculate_push_remote(
store: &Arc<Store>,
settings: &UserSettings,
remote: Option<&str>,
) -> Result<String, CommandError> {
let git_repo = git::get_git_repo(store)?; let remotes = git_repo.remote_names();
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 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 let Some(remote) = git_repo.remote_default_name(gix::remote::Direction::Push) {
return Ok(remote.to_string());
}
if remotes.iter().any(|r| **r == "gerrit") {
return Ok("gerrit".to_owned());
}
Err(user_error(
"No remote specified, and no 'gerrit' remote was found",
))
}
fn calculate_push_ref(
settings: &UserSettings,
remote_branch: Option<String>,
) -> Result<String, CommandError> {
if let Some(remote_branch) = remote_branch {
return Ok(remote_branch);
}
if let Ok(branch) = settings.get_string("gerrit.default-remote-branch") {
return Ok(branch);
}
Err(user_error(
"No target branch specified via --remote-branch, and no 'gerrit.default-remote-branch' \
was found",
))
}
fn encode_message(message: &str) -> String {
utf8_percent_encode(message, NON_ALPHANUMERIC).to_string()
}
fn push_options(args: &UploadArgs) -> Result<Vec<String>, CommandError> {
for c in &args.custom {
if !c.contains(':') {
return Err(user_error(format!(
"Custom values must be of the form 'key:value'. Got {c}"
)));
}
}
if args.wip && args.ready {
return Err(user_error("--wip and --ready are mutually exclusive"));
}
if args.private && args.remove_private {
return Err(user_error(
"--private and --remove-private are mutually exclusive",
));
}
if args.publish_comments && args.no_publish_comments {
return Err(user_error(
"--publish-comments and --no-publish-comments are mutually exclusive",
));
}
if args.skip_validation && !args.submit {
return Err(user_error(
"--skip-validation is only supported for --submit",
));
}
Ok([
args.notify.clone().map(|arg| {
(
"notify",
match arg {
EmailNotification::None => "NONE",
EmailNotification::All => "ALL",
EmailNotification::Owner => "OWNER",
EmailNotification::OwnerReviewers => "OWNER_REVIEWERS",
}
.to_string(),
)
}),
args.topic.clone().map(|arg| ("topic", arg)),
args.trace.clone().map(|arg| ("trace", arg)),
args.deadline.clone().map(|arg| ("deadline", arg)),
args.message
.as_ref()
.map(|arg| ("message", encode_message(arg))),
]
.into_iter()
.chain(args.label.iter().map(|arg| Some(("label", arg.clone()))))
.chain(
args.hashtag
.iter()
.map(|arg| Some(("hashtag", arg.clone()))),
)
.chain(
args.custom
.iter()
.map(|arg| Some(("custom-keyed-value", arg.clone()))),
)
.chain(
args.reviewer
.iter()
.map(|arg| Some(("reviewer", arg.clone()))),
)
.chain(args.cc.iter().map(|arg| Some(("cc", arg.clone()))))
.flatten()
.map(|(k, v)| format!("{k}={v}"))
.chain(
[
args.edit.then_some("edit"),
args.wip.then_some("wip"),
args.ready.then_some("ready"),
args.private.then_some("private"),
args.remove_private.then_some("remove-private"),
args.publish_comments.then_some("publish-comments"),
args.no_publish_comments.then_some("no-publish-comments"),
args.skip_validation.then_some("skip-validation"),
args.ignore_attention_set.then_some("ignore-attention-set"),
args.submit.then_some("submit"),
]
.into_iter()
.flatten()
.map(str::to_string),
)
.collect())
}
pub async fn cmd_gerrit_upload(
ui: &mut Ui,
command: &CommandHelper,
args: &UploadArgs,
) -> Result<(), CommandError> {
let push_options = GitPushOptions {
remote_push_options: push_options(args)?,
};
let mut workspace_command = command.workspace_helper(ui)?;
let revisions: Vec<_> = if args.revisions.is_empty() {
match workspace_command
.get_wc_commit_id()
.map(|id| workspace_command.repo().store().get_commit(id))
.transpose()?
{
None => {
return Err(user_error("No revision provided")
.hinted("Explicitly specify a revision to upload with `-r`"));
}
Some(commit) => {
let revisions = if commit.description().is_empty() {
let parents = commit.parent_ids();
if parents.len() != 1 {
return Err(user_error(
"No revision provided, and @ is a merge commit with no description. \
Unable to determine a suitable default commit to upload.",
)
.hinted("Explicitly specify a revision to upload with `-r`"));
}
writeln!(
ui.status(),
"No revision provided and @ has no description. Defaulting to @-"
)?;
parents.to_vec()
} else {
writeln!(ui.status(), "No revision provided. Defaulting to @")?;
vec![commit.id().clone()]
};
workspace_command.check_rewritable(&revisions).await?;
revisions
}
}
} else {
let target_expr = workspace_command
.parse_union_revsets(ui, &args.revisions)?
.resolve()?;
workspace_command
.check_rewritable_expr(&target_expr)
.await?;
target_expr
.evaluate(workspace_command.repo().as_ref())?
.stream()
.try_collect()
.await?
};
if revisions.is_empty() {
writeln!(ui.status(), "No revisions to upload.")?;
return Ok(());
}
let to_upload: Vec<Commit> = workspace_command
.attach_revset_evaluator(
workspace_command
.env()
.immutable_expression()
.range(&RevsetExpression::commits(revisions.clone())),
)
.evaluate_to_commits()?
.try_collect()
.await?;
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 subprocess_options = GitSubprocessOptions::from_settings(command.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())?;
for commit in &to_upload {
if commit.is_empty(tx.repo_mut()).await? {
return Err(user_error(format!(
"Refusing to upload revision {} because it is empty",
short_change_hash(commit.change_id())
))
.hinted(
"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(format!(
"Refusing to upload revision {} because it is has no description",
short_change_hash(commit.change_id())
))
.hinted("Maybe you meant to upload the parent commit instead (eg. @-)"));
}
}
let mut old_to_new: HashMap<CommitId, Commit> = 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" || trailer.key == "Link")
.collect();
if change_id_trailers.len() > 1 {
return Err(user_error(format!(
"Multiple Change-Id footers in revision {}",
short_change_hash(original_commit.change_id())
)));
}
let new_description = if let Some(trailer) = change_id_trailers.first() {
if trailer.key == "Change-Id"
&& (trailer.value.len() != 41 || !trailer.value.starts_with('I'))
{
writeln!(
ui.warning_default(),
"Invalid Change-Id footer in revision {}",
short_change_hash(original_commit.change_id()),
)?;
}
if trailer.key == "Link"
&& !matches!(trailer.value.split_once("/id/I"), Some((_url, id)) if id.len() == 40)
{
writeln!(
ui.warning_default(),
"Invalid Link footer in revision {}",
short_change_hash(original_commit.change_id()),
)?;
}
original_commit.description().to_owned()
} else {
let gerrit_change_id = format!("I{}6a6a6964", original_commit.change_id().hex());
let change_id_trailer =
if let Ok(review_url) = command.settings().get_string("gerrit.review-url") {
format!(
"Link: {}/id/{gerrit_change_id}",
review_url.trim_end_matches('/'),
)
} else {
format!("Change-Id: {gerrit_change_id}")
};
format!(
"{}{}{}\n",
original_commit.description().trim(),
if trailers.is_empty() { "\n\n" } else { "\n" },
change_id_trailer,
)
};
let new_parents = original_commit
.parent_ids()
.iter()
.map(|id| old_to_new.get(id).map_or(id, |p| p.id()).clone())
.collect();
if new_description == original_commit.description()
&& new_parents == original_commit.parent_ids()
{
old_to_new.insert(original_commit.id().clone(), original_commit);
continue;
}
let new_commit = tx
.repo_mut()
.rewrite_commit(&original_commit)
.set_description(new_description)
.set_parents(new_parents)
.set_committer(original_commit.committer().clone())
.set_author(original_commit.author().clone())
.write()
.await?;
old_to_new.insert(original_commit.id().clone(), new_commit);
}
let remote_ref = format!("refs/for/{remote_branch}");
writeln!(
ui.status(),
"Found {} heads to push to Gerrit (remote '{}'), target branch '{}'",
old_heads.len(),
remote,
remote_branch,
)?;
for head in &old_heads {
if let Some(mut formatter) = ui.status_formatter() {
if args.dry_run {
write!(formatter, "Dry-run: Would push ")?;
} else {
write!(formatter, "Pushing ")?;
}
tx.base_workspace_helper().write_commit_summary(
formatter.as_mut(),
&store.get_commit_async(head).await.unwrap(),
)?;
writeln!(formatter)?;
}
if args.dry_run {
continue;
}
let new_commit = old_to_new.get(head).unwrap();
let push_stats = git::push_updates(
tx.repo_mut(),
subprocess_options.clone(),
remote.as_ref(),
&[GitRefUpdate {
qualified_name: remote_ref.clone().into(),
targets: Diff::new(
None,
Some(gix::ObjectId::from_bytes_or_panic(
new_commit.id().as_bytes(),
)),
),
}],
&mut GitSubprocessUi::new(ui),
&push_options,
)
.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)
}
})?;
print_push_stats(ui, &push_stats)?;
if !push_stats.all_ok() {
return Err(user_error("Failed to push all changes to gerrit"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_gerrit_push_options() {
assert_eq!(
push_options(&Default::default()).unwrap(),
Vec::<String>::new(),
);
assert_eq!(
push_options(&UploadArgs {
message: Some("Uploaded with jj!".to_string()),
notify: Some(EmailNotification::None),
topic: Some("my-topic".to_string()),
reviewer: vec!["foo@example.com".to_string()],
cc: vec!["bar@example.com".to_string(), "baz@example.com".to_string()],
edit: true,
wip: true,
private: true,
publish_comments: true,
..Default::default()
})
.unwrap(),
[
"notify=NONE",
"topic=my-topic",
"message=Uploaded%20with%20jj%21",
"reviewer=foo@example.com",
"cc=bar@example.com",
"cc=baz@example.com",
"edit",
"wip",
"private",
"publish-comments",
]
.into_iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
);
assert_eq!(
push_options(&UploadArgs {
notify: Some(EmailNotification::All),
trace: Some("my-trace".to_string()),
hashtag: vec!["my-hashtag".to_string(), "my-second-hashtag".to_string()],
deadline: Some("yesterday".to_string()),
label: vec!["Auto-Submit".to_string(), "Commit-Queue+2".to_string()],
custom: vec!["foo:bar".to_string(), "baz:quux".to_string()],
ready: true,
remove_private: true,
no_publish_comments: true,
skip_validation: true,
ignore_attention_set: true,
submit: true,
..Default::default()
})
.unwrap(),
[
"notify=ALL",
"trace=my-trace",
"deadline=yesterday",
"label=Auto-Submit",
"label=Commit-Queue+2",
"hashtag=my-hashtag",
"hashtag=my-second-hashtag",
"custom-keyed-value=foo:bar",
"custom-keyed-value=baz:quux",
"ready",
"remove-private",
"no-publish-comments",
"skip-validation",
"ignore-attention-set",
"submit",
]
.into_iter()
.map(|s| s.to_string())
.collect::<Vec<_>>(),
);
}
}