use std::path::Path;
use anyhow::{Context, Result};
use git2::Repository;
pub const TRAILER: &str = "Change-Id";
pub const MIN_LEN: usize = 3;
const HEX_LEN: usize = 40;
const EMPTY_TREE: &str = "4b825dc642cb6eb9a060e54bf8d69288fbee4904";
pub fn normalize(value: &str) -> Option<String> {
let value = value.trim();
let hex = value.strip_prefix(['I', 'i'])?;
if hex.len() != HEX_LEN || !hex.bytes().all(|b| b.is_ascii_hexdigit()) {
return None;
}
Some(format!("I{}", hex.to_ascii_lowercase()))
}
pub fn from_message(message: &str) -> Option<String> {
let trailers = git2::message_trailers_strs(message).ok()?;
let mut found = None;
for (key, value) in trailers.iter() {
if key.eq_ignore_ascii_case(TRAILER) {
if let Some(id) = normalize(value) {
found = Some(id);
}
} else if key.eq_ignore_ascii_case("Link")
&& let Some((_, tail)) = value.trim().rsplit_once("/id/")
&& let Some(id) = normalize(tail)
{
found = Some(id);
}
}
found
}
pub fn to_letters(change_id: &str) -> String {
change_id
.strip_prefix('I')
.unwrap_or(change_id)
.chars()
.map(|c| match c.to_digit(16) {
Some(d) => (b'z' - d as u8) as char,
None => c,
})
.collect()
}
pub fn is_letters(s: &str) -> bool {
!s.is_empty() && s.chars().all(|c| ('k'..='z').contains(&c))
}
pub fn enabled(repo: &Repository) -> bool {
let Ok(config) = repo.config() else {
return true;
};
if config.get_bool("loom.changeId") == Ok(false) {
return false;
}
!config
.get_string("gerrit.createChangeId")
.map(|v| v.eq_ignore_ascii_case("false"))
.unwrap_or(false)
}
pub fn link_base(repo: &Repository) -> Option<String> {
let url = repo.config().ok()?.get_string("gerrit.reviewUrl").ok()?;
let url = url.trim().trim_end_matches('/');
(!url.is_empty()).then(|| url.to_string())
}
pub fn generate(repo: &Repository, workdir: &Path, message: &str) -> Result<String> {
let refhash = repo
.head()
.ok()
.and_then(|h| h.target())
.map(|oid| oid.to_string())
.unwrap_or_else(|| EMPTY_TREE.to_string());
generate_with(workdir, &refhash, message)
}
pub fn generate_unlike(workdir: &Path, message: &str, other: &str) -> Result<String> {
generate_with(workdir, other, message)
}
fn generate_with(workdir: &Path, refhash: &str, message: &str) -> Result<String> {
let ident = crate::git::run_git_stdout(workdir, &["var", "GIT_COMMITTER_IDENT"])
.context("Could not read the committer identity")?;
Ok(generate_from(ident.trim_end(), refhash, message))
}
pub(crate) fn generate_from(ident: &str, refhash: &str, message: &str) -> String {
let mut input = format!("{ident}\n{refhash}\n{message}");
if !input.ends_with('\n') {
input.push('\n');
}
let oid = git2::Oid::hash_object(git2::ObjectType::Blob, input.as_bytes())
.expect("hashing a blob cannot fail");
format!("I{oid}")
}
pub fn trailer_line(change_id: &str, link_base: Option<&str>) -> String {
match link_base {
Some(base) => format!("Link: {base}/id/{change_id}"),
None => format!("{TRAILER}: {change_id}"),
}
}
pub fn stamp(message: &str, change_id: &str, link_base: Option<&str>) -> String {
let body = message.trim_end();
let has_block = git2::message_trailers_strs(&format!("{body}\n"))
.map(|t| t.iter().next().is_some())
.unwrap_or(false);
let separator = if has_block { "\n" } else { "\n\n" };
format!("{body}{separator}{}\n", trailer_line(change_id, link_base))
}
fn is_autosquash(message: &str) -> bool {
let subject = message.lines().next().unwrap_or("");
subject
.split_once("! ")
.is_some_and(|(kind, _)| !kind.is_empty() && kind.bytes().all(|b| b.is_ascii_lowercase()))
}
pub fn for_message(
repo: &Repository,
workdir: &Path,
message: &str,
keep: Option<&str>,
) -> Result<String> {
if message.trim().is_empty() || from_message(message).is_some() || is_autosquash(message) {
return Ok(message.to_string());
}
let id = match keep {
Some(id) => id.to_string(),
None if enabled(repo) => generate(repo, workdir, message)?,
None => return Ok(message.to_string()),
};
Ok(stamp(message, &id, link_base(repo).as_deref()))
}
pub fn ensure_on_head(repo: &Repository, workdir: &Path, keep: Option<&str>) -> Result<()> {
let head = repo.head()?.peel_to_commit()?;
let message = head.message().unwrap_or("").to_string();
let stamped = for_message(repo, workdir, &message, keep)?;
if stamped == message {
return Ok(());
}
crate::git::commit_amend_message_unverified(workdir, &stamped)
}
pub fn ensure_on_head_or_warn(repo: &Repository, workdir: &Path, keep: Option<&str>) {
if let Err(e) = ensure_on_head(repo, workdir, keep) {
crate::core::msg::warn(&format!("Commit created without a Change-Id: {e}"));
}
}
#[cfg(test)]
#[path = "changeid_test.rs"]
mod tests;