use anyhow::anyhow;
#[derive(Debug, Clone)]
struct Change {
pub author: String,
pub id: Option<String>,
pub series: Vec<String>,
pub commit: git2::Oid,
}
impl Change {
fn new(commit: git2::Oid) -> Self {
Self {
author: Default::default(),
id: Default::default(),
series: Default::default(),
commit,
}
}
}
fn is_trailer_line(line: &str) -> bool {
let key_len = line
.bytes()
.take_while(|&b| b.is_ascii_alphanumeric() || b == b'-')
.count();
key_len > 0 && line[key_len..].starts_with(": ")
}
pub fn commit_change_meta(commit: &git2::Commit) -> (Option<String>, Vec<String>) {
let (mut id, series) = parse_change_meta(commit.message().unwrap_or(""));
if let Ok(buf) = commit.header_field_bytes("change-id") {
if let Ok(s) = std::str::from_utf8(&buf) {
let s = s.trim();
if !s.is_empty() {
id = Some(s.to_string());
}
}
}
(id, series)
}
fn parse_change_meta(message: &str) -> (Option<String>, Vec<String>) {
let lines: Vec<&str> = message.lines().collect();
let mut footer_start = lines.len();
for (i, line) in lines.iter().enumerate().rev() {
if line.is_empty() || is_trailer_line(line) {
footer_start = i;
} else {
break;
}
}
let mut id: Option<String> = None;
let mut series: Vec<String> = Vec::new();
for line in &lines[footer_start..] {
if let Some(v) = line.strip_prefix("Change: ") {
id = Some(v.to_string());
}
if let Some(v) = line.strip_prefix("Change-Id: ") {
id = Some(v.to_string());
}
if let Some(v) = line.strip_prefix("Change-Series: ") {
series.push(v.to_string());
}
}
(id, series)
}
fn get_change_id(commit: &git2::Commit) -> Change {
let mut change = Change::new(commit.id());
change.author = commit.author().email().unwrap_or("").to_string();
let (id, series) = commit_change_meta(commit);
change.id = id;
change.series = series;
change
}
#[derive(PartialEq, Clone, Debug)]
pub enum PushMode {
Normal,
Publish(String),
}
#[derive(Debug, Clone)]
pub struct PushRef {
pub ref_name: String,
pub oid: git2::Oid,
pub change_id: String,
}
pub fn baseref_and_options(
refname: &str,
author: &str,
) -> anyhow::Result<(String, String, Vec<String>, PushMode)> {
let mut split = refname.splitn(2, '%');
let push_to = split.next().ok_or(anyhow!("no next"))?.to_owned();
let options = if let Some(options) = split.next() {
options.split(',').map(|x| x.to_string()).collect()
} else {
vec![]
};
let mut baseref = push_to.to_owned();
let mut push_mode = PushMode::Normal;
if baseref.starts_with("refs/for") {
baseref = baseref.replacen("refs/for", "refs/heads", 1)
}
if baseref.starts_with("refs/drafts") {
baseref = baseref.replacen("refs/drafts", "refs/heads", 1)
}
if baseref.starts_with("refs/publish/for") {
push_mode = PushMode::Publish(author.to_string());
baseref = baseref.replacen("refs/publish/for", "refs/heads", 1)
}
Ok((baseref, push_to, options, push_mode))
}
fn split_changes(
repo: &git2::Repository,
changes: std::collections::HashMap<git2::Oid, Change>,
base: git2::Oid,
) -> anyhow::Result<Vec<Change>> {
if base == git2::Oid::zero() {
return Ok(changes.into_values().collect());
}
changes
.iter()
.map(|(_, c)| downstack(repo, base, c))
.collect()
}
fn changed_paths(
repo: &git2::Repository,
commit: &git2::Commit,
) -> anyhow::Result<std::collections::HashSet<String>> {
let parent_tree = if commit.parent_count() > 0 {
Some(commit.parent(0)?.tree()?)
} else {
None
};
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&commit.tree()?), None)?;
let mut paths = std::collections::HashSet::new();
for delta in diff.deltas() {
if let Some(p) = delta.old_file().path().and_then(|p| p.to_str()) {
paths.insert(p.to_string());
}
if let Some(p) = delta.new_file().path().and_then(|p| p.to_str()) {
paths.insert(p.to_string());
}
}
Ok(paths)
}
fn downstack(repo: &git2::Repository, base: git2::Oid, change: &Change) -> anyhow::Result<Change> {
let change_oid = change.commit;
if !repo.graph_descendant_of(change_oid, base)? {
return Err(anyhow!(
"change {} is not a descendant of base {}",
change_oid,
base
));
}
let mut walk = repo.revwalk()?;
walk.simplify_first_parent()?;
walk.set_sorting(git2::Sort::REVERSE | git2::Sort::TOPOLOGICAL)?;
walk.push(change_oid)?;
walk.hide(base)?;
let oids: Vec<git2::Oid> = walk.collect::<Result<Vec<_>, _>>()?;
if oids.is_empty() {
return Ok(change.clone());
}
let mut commits: Vec<git2::Commit> = oids
.into_iter()
.map(|oid| repo.find_commit(oid))
.collect::<Result<Vec<_>, _>>()?;
let change_commit = commits.pop().unwrap();
let change_parent = change_commit.parent(0)?;
let change_meta = get_change_id(&change_commit);
let mut affected_paths = changed_paths(repo, &change_commit)?;
for s in &change_meta.series {
affected_paths.insert(format!("\x00series:{}", s));
}
let mut needed: Vec<bool> = vec![false; commits.len()];
for (i, intermediate) in commits.iter().enumerate().rev() {
let meta = get_change_id(intermediate);
let mut paths = changed_paths(repo, intermediate)?;
for s in &meta.series {
paths.insert(format!("\x00series:{}", s));
}
if !paths.is_disjoint(&affected_paths) {
needed[i] = true;
affected_paths.extend(paths);
}
}
let mut current_base = repo.find_commit(base)?;
for (intermediate, is_needed) in commits.iter().zip(needed.iter()) {
if !is_needed {
continue;
}
let inter_parent = intermediate.parent(0)?;
let mut index = repo.merge_trees(
&inter_parent.tree()?,
¤t_base.tree()?,
&intermediate.tree()?,
None,
)?;
let new_tree = repo.find_tree(index.write_tree_to(repo)?)?;
let new_oid = josh_core::history::rewrite_commit(
repo,
intermediate,
&[¤t_base],
josh_core::filter::Rewrite::from_tree(new_tree),
josh_core::history::GpgsigMode::Preserve,
)?;
current_base = repo.find_commit(new_oid)?;
}
let mut index = repo.merge_trees(
&change_parent.tree()?,
¤t_base.tree()?,
&change_commit.tree()?,
None,
)?;
let new_tree = repo.find_tree(index.write_tree_to(repo)?)?;
let new_oid = josh_core::history::rewrite_commit(
repo,
&change_commit,
&[¤t_base],
josh_core::filter::Rewrite::from_tree(new_tree),
josh_core::history::GpgsigMode::Preserve,
)?;
let mut result = change.clone();
result.commit = new_oid;
Ok(result)
}
fn changes_to_refs(
repo: &git2::Repository,
baseref: &str,
change_author: &str,
changes: Vec<Change>,
) -> anyhow::Result<Vec<PushRef>> {
if !change_author.contains('@') {
return Err(anyhow!(
"Push option 'author' needs to be set to a valid email address",
));
};
let changes: Vec<Change> = changes
.into_iter()
.filter(|change| change.author == change_author)
.collect();
let mut seen = std::collections::HashSet::new();
for change in changes.iter() {
if let Some(id) = &change.id {
if id.contains('@') {
return Err(anyhow!("Change id must not contain '@'"));
}
if !seen.insert(id) {
return Err(anyhow!(
"rejecting to push {:?} with duplicate label",
change.commit
));
}
seen.insert(id);
}
}
let mut refs = vec![];
for change in changes {
if let Some(change_id) = change.id {
let ref_name = format!(
"refs/heads/@changes/{}/{}/{}",
baseref.replacen("refs/heads/", "", 1),
change.author,
change_id,
);
let base_ref_name = ref_name.replacen("refs/heads/@changes", "refs/heads/@base", 1);
refs.push(PushRef {
ref_name,
oid: change.commit,
change_id: change_id.clone(),
});
if let Some(parent_sha) = repo.find_commit(change.commit)?.parent_ids().next() {
refs.push(PushRef {
ref_name: base_ref_name,
oid: parent_sha,
change_id,
});
}
}
}
Ok(refs)
}
fn get_changes(
repo: &git2::Repository,
tip: git2::Oid,
base: git2::Oid,
) -> anyhow::Result<std::collections::HashMap<git2::Oid, Change>> {
let mut walk = repo.revwalk()?;
walk.set_sorting(git2::Sort::REVERSE | git2::Sort::TOPOLOGICAL)?;
walk.simplify_first_parent()?;
walk.push(tip)?;
if base != git2::Oid::zero() {
walk.hide(base)?;
}
let mut changes = std::collections::HashMap::new();
for rev in walk {
let commit = repo.find_commit(rev?)?;
let change = get_change_id(&commit);
changes.insert(change.commit, change);
}
Ok(changes)
}
pub fn build_to_push(
repo: &git2::Repository,
push_mode: &PushMode,
baseref: &str,
ref_with_options: &str,
oid_to_push: git2::Oid,
base_oid: git2::Oid,
) -> anyhow::Result<Vec<PushRef>> {
match push_mode {
PushMode::Publish(author) => {
let changes = get_changes(repo, oid_to_push, base_oid)?;
let changes = split_changes(repo, changes, base_oid)?;
let mut push_refs = changes_to_refs(repo, baseref, author, changes)?;
push_refs.push(PushRef {
ref_name: format!(
"refs/heads/@heads/{}/{}",
baseref.replacen("refs/heads/", "", 1),
author,
),
oid: oid_to_push,
change_id: baseref.replacen("refs/heads/", "", 1),
});
push_refs.sort_by(|a, b| a.ref_name.cmp(&b.ref_name));
Ok(push_refs)
}
PushMode::Normal => Ok(vec![PushRef {
ref_name: if ref_with_options.starts_with("refs/") {
ref_with_options.to_string()
} else {
format!("refs/heads/{}", ref_with_options)
},
oid: oid_to_push,
change_id: "JOSH_PUSH".to_string(),
}]),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn footer_in_body_is_ignored() {
let (id, series) =
parse_change_meta("Subject\n\nbody mentions Change: not-a-trailer\nmore body\n");
assert_eq!(id, None);
assert!(series.is_empty());
}
#[test]
fn real_trailing_footer_is_parsed() {
let (id, _) = parse_change_meta("Subject\n\nBody.\n\nChange: real-id\n");
assert_eq!(id.as_deref(), Some("real-id"));
}
#[test]
fn single_line_message_is_its_own_footer() {
let (id, _) = parse_change_meta("Change: only-line");
assert_eq!(id.as_deref(), Some("only-line"));
}
#[test]
fn footer_followed_by_body_is_ignored() {
let (id, _) = parse_change_meta("Subject\n\nChange: middle\n\nBody after.\n");
assert_eq!(id, None);
}
#[test]
fn other_trailers_in_block_do_not_break_change() {
let msg = "Subject\n\nBody.\n\nSigned-off-by: x <x@y>\nChange: real\n\
Reviewed-by: z <z@w>\n";
let (id, _) = parse_change_meta(msg);
assert_eq!(id.as_deref(), Some("real"));
}
#[test]
fn series_in_footer_block_is_collected() {
let msg = "Subject\n\nBody.\n\nChange-Series: s1\nChange-Series: s2\nChange: c\n";
let (id, series) = parse_change_meta(msg);
assert_eq!(id.as_deref(), Some("c"));
assert_eq!(series, vec!["s1".to_string(), "s2".to_string()]);
}
#[test]
fn series_in_body_is_ignored() {
let msg = "Subject\n\nWe discussed Change-Series: bogus here.\nmore body\n";
let (_id, series) = parse_change_meta(msg);
assert!(series.is_empty());
}
#[test]
fn is_trailer_line_basics() {
assert!(is_trailer_line("Change: foo"));
assert!(is_trailer_line("Change-Id: foo"));
assert!(is_trailer_line("Signed-off-by: a <a@b>"));
assert!(!is_trailer_line("not a trailer"));
assert!(!is_trailer_line("Change:no-space"));
assert!(!is_trailer_line(": leading colon"));
assert!(!is_trailer_line(""));
}
}