Skip to content

Commit

Permalink
cli: basic jj gerrit mail implementation
Browse files Browse the repository at this point in the history
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 <[email protected]>
Change-Id: I12f08dfb0fd3c57f3da34ed44ca1b1000e6ffe74
  • Loading branch information
thoughtpolice committed Jan 18, 2024
1 parent 0d29735 commit 1fb8e16
Showing 1 changed file with 212 additions and 8 deletions.
220 changes: 212 additions & 8 deletions cli/src/commands/gerrit.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand All @@ -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<Commit, Commit> = 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::<String>());

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::<Vec<&Commit>>();
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(())
Expand Down

0 comments on commit 1fb8e16

Please sign in to comment.