From 1fb8e168921ca15244aa6bb9dc7fb02d0ecdec0c Mon Sep 17 00:00:00 2001 From: Austin Seipp Date: Thu, 18 Jan 2024 00:35:09 -0600 Subject: [PATCH] cli: basic `jj gerrit mail` implementation This implements the most basic workflow for submitting changes to Gerrit, through a verb called 'mail'. The idea is to 'mail in' changes to be read by another human. This name is taken from the dictionary used by Piper/CitC at Google, and intended to be distinct from the word 'submit', which for Gerrit means 'merge a change into the repository.' Given a list of revsets (specified by multiple `-r` options), this will parse the footers of every commit, collect them, insert a `Change-Id` (if one doesn't already exist), and then push them into the given remote. Because the argument is a revset, you may submit entire trees of changes at once, including multiple trees of independent changes, e.g. jj gerrit mail -r foo:: -r baz:: There are many other improvements that can be applied on top of this, including a ton of consistency and "does this make sense?" checks. However, it is flexible and a good starting point, and you can in fact both submit and cycle reviews with this interface. Signed-off-by: Austin Seipp Change-Id: I12f08dfb0fd3c57f3da34ed44ca1b1000e6ffe74 --- cli/src/commands/gerrit.rs | 220 +++++++++++++++++++++++++++++++++++-- 1 file changed, 212 insertions(+), 8 deletions(-) diff --git a/cli/src/commands/gerrit.rs b/cli/src/commands/gerrit.rs index cc536128d7..1eb060c35a 100644 --- a/cli/src/commands/gerrit.rs +++ b/cli/src/commands/gerrit.rs @@ -13,15 +13,22 @@ // limitations under the License. use std::fmt::Debug; +use std::io::Write; use clap::Subcommand; -use indexmap::IndexSet; +use hex::ToHex; +use indexmap::{IndexMap, IndexSet}; use jj_lib::commit::Commit; +use jj_lib::content_hash::blake2b_hash; +use jj_lib::footer::get_footer_lines; +use jj_lib::git::{self, GitRefUpdate}; +use jj_lib::hex_util::to_reverse_hex; +use jj_lib::object_id::ObjectId; use jj_lib::repo::Repo; use crate::cli_util::{ - get_git_repo, resolve_multiple_nonempty_revsets, user_error, CommandError, CommandHelper, - RevisionArg, + get_git_repo, resolve_multiple_nonempty_revsets, short_commit_hash, user_error, + with_remote_git_callbacks, CommandError, CommandHelper, RevisionArg, }; use crate::ui::Ui; @@ -108,10 +115,11 @@ pub fn cmd_mail( let for_branch = for_parts[0]; let for_remote = for_parts[1]; - let tx = workspace_command.start_transaction(); + let mut tx = workspace_command.start_transaction(); let base_repo = tx.base_repo().clone(); + let mut_repo = tx.mut_repo(); let store = base_repo.store(); - let git_repo = get_git_repo(&store)?; // will fail if not a git repo + let git_repo = get_git_repo(store)?; // will fail if not a git repo // make sure the remote exists let mut remote_exists = false; @@ -123,14 +131,210 @@ pub fn cmd_mail( } if !remote_exists { - return Err(user_error(&format!( + return Err(user_error(format!( "The (Gerrit) remote '{}' does not exist", for_remote ))); } - for head in base_repo.index().heads(&mut to_mail.iter().map(|c| c.id())) { - println!("Pushing to {}@{}: {:?}", for_branch, for_remote, head); + // immediately error and reject any discardable commits, i.e. the + // the empty wcc + for commit in to_mail.iter() { + if commit.is_discardable() { + return Err(user_error(format!( + "Refusing to mail in commit {} because it is an empty commit with no \ + description\n(use 'jj amend' to add a description, or 'jj abandon' to discard it)", + short_commit_hash(commit.id()) + ))); + } + } + + let mut old_to_new: IndexMap = IndexMap::new(); + for commit_id in base_repo + .index() + .topo_order(&mut to_mail.iter().map(|c| c.id())) + .into_iter() + { + let original_commit = store.get_commit(&commit_id).unwrap(); + let description = original_commit.description().to_owned(); + let footer = get_footer_lines(&description); + + if let Some(footer) = footer.clone() { + // look up the existing change id footer + let change_id = footer.iter().find(|(key, _)| key == &"Change-Id"); + if let Some((_, values)) = change_id { + // map the old commit to itself + old_to_new.insert(original_commit.clone(), original_commit.clone()); + + // multiple change-ids are not allowed + if values.len() > 1 { + writeln!( + ui.warning(), + "warning: multiple Change-Id footers in commit {}", + short_commit_hash(original_commit.clone().id()), + )?; + continue; + } + + // check the change-id format is correct in any case + if values[0].len() != 41 || !values[0].starts_with('I') { + writeln!( + ui.warning(), + "warning: invalid Change-Id footer in commit {}", + short_commit_hash(original_commit.id()), + )?; + continue; + } + + continue; // fallthrough + } + } + + // NOTE: Gerrit's change ID is not compatible with the alphabet used by + // jj, and the needed length of the change-id is different as well. + // + // for us, we convert to gerrit's format is the character 'I', followed + // by 40 characters of the truncated blake2 hash of the change id + let change_id = to_reverse_hex(&original_commit.change_id().hex()).unwrap(); + let hashed_id: String = blake2b_hash(&change_id).encode_hex(); + let gerrit_change_id = format!("I{}", hashed_id.chars().take(40).collect::()); + + let spacing = if let Some(footer) = footer { + if footer.is_empty() { + "\n\n" + } else { + "\n" + } + } else { + "\n\n" + }; + + let new_description = format!( + "{}{}Change-Id: {}\n", + description.trim(), + spacing, + gerrit_change_id + ); + + // rewrite the set of parents to point to the commits that were + // previously rewritten in toposort order + let new_parents = original_commit + .parents() + .iter() + .map(|parent| { + if let Some(rewritten_parent) = old_to_new.get(parent) { + rewritten_parent + } else { + parent + } + .id() + .clone() + }) + .collect(); + + let new_commit = mut_repo + .rewrite_commit(command.settings(), &original_commit) + .set_description(new_description) + .set_parents(new_parents) + .write()?; + old_to_new.insert(original_commit.clone(), new_commit.clone()); + } + + tx.finish( + ui, + format!( + "describing {} commit(s) for mail-in to gerrit", + old_to_new.len() + ), + )?; + + let tx = workspace_command.start_transaction(); + let base_repo = tx.base_repo().clone(); + + // NOTE(aseipp): write the status report *after* finishing the first + // transaction. until we call 'tx.finish', the outstanding tx write set + // contains a commit with a duplicated jj change-id, i.e. while the + // transaction is open, it is ambiguous whether the change-id refers to the + // newly written commit or the old one that already existed. + // + // this causes an awkward UX interaction, where write_commit_summary will + // output a line with a red change-id indicating it's duplicated/conflicted, + // AKA "??" status. but then the user will immediately run 'jj log' and not + // see any conflicting change-ids, because the transaction was committed by + // then and the new commits replaced the old ones! just printing this after + // the transaction finishes avoids this weird case. + for (old, new) in old_to_new.iter() { + if old != new { + write!(ui.stderr(), "Added Change-Id footer to ")?; + } else { + write!(ui.stderr(), "Skipped Change-Id (it already exists) for ")?; + } + tx.write_commit_summary(ui.stderr_formatter().as_mut(), new)?; + writeln!(ui.stderr())?; + } + writeln!(ui.stderr())?; + + let new_commits = old_to_new.values().collect::>(); + let new_heads = base_repo + .index() + .heads(&mut new_commits.iter().map(|c| c.id())); + let remote_ref = format!("refs/for/{}", for_branch); + + writeln!( + ui.stderr(), + "Found {} heads to push to Gerrit (remote '{}'), target is branch '{}' ({})", + new_heads.len(), + for_remote, + for_branch, + remote_ref.clone(), + )?; + + // split these two loops to keep the output a little nicer; display first, + // then push + for head in &new_heads { + let head_commit = store.get_commit(head).unwrap(); + + write!(ui.stderr(), " ")?; + tx.write_commit_summary(ui.stderr_formatter().as_mut(), &head_commit)?; + writeln!(ui.stderr())?; + } + writeln!(ui.stderr())?; + + for head in &new_heads { + let head_commit = store.get_commit(head).unwrap(); + let head_id = head_commit.id().clone(); + + let ref_update = GitRefUpdate { + qualified_name: remote_ref.clone(), + force: false, + new_target: Some(head_id), + }; + + write!(ui.stderr(), "Pushing ")?; + tx.write_commit_summary(ui.stderr_formatter().as_mut(), &head_commit)?; + writeln!(ui.stderr())?; + + with_remote_git_callbacks(ui, |cb| { + git::push_updates(&git_repo, for_remote, &[ref_update], cb) + }) + .map_or_else( + |err| match err { + git::GitPushError::RefUpdateRejected(_) => { + // gerrit rejects ref updates when there are no changes, i.e. + // you submit a change that is already up to date. just give + // the user a light warning and carry on + writeln!( + ui.warning(), + "warning: ref update rejected by gerrit; no changes to push (did you \ + forget to update, amend, or add new changes?)" + )?; + + Ok(()) + } + _ => Err(user_error(err.to_string())), + }, + Ok, + )?; } Ok(())