use anyhow::{Context as _, Result, bail};
use std::path::{Path, PathBuf};
use std::process::Command;
use super::git_output_in;
pub const RELEASE_COMMIT_PREFIX: &str = "chore(release): ";
pub fn release_bump_subject_prefix() -> String {
format!("{RELEASE_COMMIT_PREFIX}bump ")
}
pub fn release_bump_subject(summary: &str, suffix: &str) -> String {
format!("{}{summary}{suffix}", release_bump_subject_prefix())
}
pub const CHANGELOG_PROVENANCE_PREFIX: &str = "changelog regenerated for ";
pub fn changelog_regenerated_marker(crate_name: &str, version: &str) -> String {
format!("{CHANGELOG_PROVENANCE_PREFIX}{crate_name}@{version}")
}
pub fn changelog_regenerated_recorded_in(
workspace_root: &Path,
crate_name: &str,
version: &str,
changelog_rel_path: &str,
) -> Result<bool> {
let marker = changelog_regenerated_marker(crate_name, version);
let out = Command::new("git")
.arg("-C")
.arg(workspace_root)
.args([
"-c",
"log.showSignature=false",
"log",
"-1",
"--pretty=format:%B",
"--",
changelog_rel_path,
])
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()
.context("failed to invoke git log for the changelog provenance marker")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("does not have any commits yet") {
return Ok(false);
}
let raw = format!("git log failed: {}", stderr.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
let body = String::from_utf8_lossy(&out.stdout);
Ok(body.lines().map(str::trim).any(|line| line == marker))
}
#[derive(Debug, Clone)]
pub struct Commit {
pub hash: String,
pub short_hash: String,
pub message: String,
pub author_name: String,
pub author_email: String,
pub body: String,
}
pub fn parse_commit_output(output: &str) -> Vec<Commit> {
if output.is_empty() {
return vec![];
}
output
.split('\x1e')
.filter(|record| !record.trim().is_empty())
.filter_map(|record| {
let fields: Vec<&str> = record.split('\x1f').collect();
if fields.len() >= 5 {
Some(Commit {
hash: fields[0].trim().to_string(),
short_hash: fields[1].to_string(),
message: fields[2].to_string(),
author_name: fields[3].to_string(),
author_email: fields[4].to_string(),
body: fields.get(5).unwrap_or(&"").trim().to_string(),
})
} else {
None
}
})
.collect()
}
fn cwd_or_dot() -> PathBuf {
std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
}
pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
get_commits_between_in(&cwd_or_dot(), from, to, path_filter)
}
pub fn get_commits_between_in(
cwd: &Path,
from: &str,
to: &str,
path_filter: Option<&str>,
) -> Result<Vec<Commit>> {
get_commits_between_paths_in(
cwd,
from,
to,
&path_filter
.into_iter()
.map(String::from)
.collect::<Vec<_>>(),
)
}
pub fn get_commits_between_paths(from: &str, to: &str, paths: &[String]) -> Result<Vec<Commit>> {
get_commits_between_paths_in(&cwd_or_dot(), from, to, paths)
}
pub fn get_commits_between_paths_in(
cwd: &Path,
from: &str,
to: &str,
paths: &[String],
) -> Result<Vec<Commit>> {
let range = format!("{}..{}", from, to);
let mut args = vec![
"-c".to_string(),
"log.showSignature=false".to_string(),
"log".to_string(),
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
range,
];
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = git_output_in(cwd, &arg_refs)?;
Ok(parse_commit_output(&output))
}
pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
get_all_commits_in(&cwd_or_dot(), path_filter)
}
pub fn get_all_commits_in(cwd: &Path, path_filter: Option<&str>) -> Result<Vec<Commit>> {
get_all_commits_paths_in(
cwd,
&path_filter
.into_iter()
.map(String::from)
.collect::<Vec<_>>(),
)
}
pub fn get_all_commits_paths(paths: &[String]) -> Result<Vec<Commit>> {
get_all_commits_paths_in(&cwd_or_dot(), paths)
}
pub fn get_all_commits_paths_in(cwd: &Path, paths: &[String]) -> Result<Vec<Commit>> {
let mut args = vec![
"-c".to_string(),
"log.showSignature=false".to_string(),
"log".to_string(),
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
"HEAD".to_string(),
];
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = git_output_in(cwd, &arg_refs)?;
Ok(parse_commit_output(&output))
}
#[derive(Debug, Clone)]
pub struct CommitWithFiles {
pub commit: Commit,
pub files: Vec<String>,
}
pub fn parse_commit_output_with_files(output: &str) -> Vec<CommitWithFiles> {
if output.is_empty() {
return vec![];
}
let segments: Vec<&str> = output.split('\x1e').collect();
let mut out: Vec<CommitWithFiles> = Vec::new();
for (idx, seg) in segments.iter().enumerate() {
let metadata = if idx == 0 {
seg.trim_start_matches(['\n', '\r']).to_string()
} else {
let lines: Vec<&str> = seg.split('\n').collect();
match lines.iter().position(|line| line.contains('\x1f')) {
Some(start) => lines[start..].join("\n"),
None => String::new(),
}
};
if metadata.trim().is_empty() {
continue;
}
let commits = parse_commit_output(&metadata);
let Some(commit) = commits.into_iter().next() else {
continue;
};
let files = match segments.get(idx + 1) {
Some(next) => next
.split('\n')
.map(str::trim)
.take_while(|line| !line.contains('\x1f'))
.filter(|line| !line.is_empty())
.map(str::to_string)
.collect(),
None => Vec::new(),
};
out.push(CommitWithFiles { commit, files });
}
out
}
pub fn get_commits_between_paths_with_files_in(
cwd: &Path,
from: &str,
to: &str,
paths: &[String],
) -> Result<Vec<CommitWithFiles>> {
let range = format!("{}..{}", from, to);
let mut args = vec![
"-c".to_string(),
"log.showSignature=false".to_string(),
"log".to_string(),
"--name-only".to_string(),
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
range,
];
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = git_output_in(cwd, &arg_refs)?;
Ok(parse_commit_output_with_files(&output))
}
pub fn get_all_commits_paths_with_files_in(
cwd: &Path,
paths: &[String],
) -> Result<Vec<CommitWithFiles>> {
let mut args = vec![
"-c".to_string(),
"log.showSignature=false".to_string(),
"log".to_string(),
"--name-only".to_string(),
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
"HEAD".to_string(),
];
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = git_output_in(cwd, &arg_refs)?;
Ok(parse_commit_output_with_files(&output))
}
pub fn get_commits_reachable_paths_in(
cwd: &Path,
rev: &str,
paths: &[String],
) -> Result<Vec<Commit>> {
let mut args = vec![
"-c".to_string(),
"log.showSignature=false".to_string(),
"log".to_string(),
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
rev.to_string(),
];
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = git_output_in(cwd, &arg_refs)?;
Ok(parse_commit_output(&output))
}
pub fn get_commits_reachable_paths_with_files_in(
cwd: &Path,
rev: &str,
paths: &[String],
) -> Result<Vec<CommitWithFiles>> {
let mut args = vec![
"-c".to_string(),
"log.showSignature=false".to_string(),
"log".to_string(),
"--name-only".to_string(),
"--pretty=format:%H%x1f%h%x1f%s%x1f%an%x1f%ae%x1f%b%x1e".to_string(),
rev.to_string(),
];
if !paths.is_empty() {
args.push("--".to_string());
for p in paths {
args.push(p.clone());
}
}
let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
let output = git_output_in(cwd, &arg_refs)?;
Ok(parse_commit_output_with_files(&output))
}
pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
get_last_commit_messages_in(&cwd_or_dot(), count)
}
pub fn get_last_commit_messages_in(cwd: &Path, count: usize) -> Result<Vec<String>> {
let output = git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
&format!("-{count}"),
"--pretty=format:%s",
],
)?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn get_commit_messages_between(from: &str, to: &str) -> Result<Vec<String>> {
get_commit_messages_between_in(&cwd_or_dot(), from, to)
}
pub fn get_commit_messages_between_in(cwd: &Path, from: &str, to: &str) -> Result<Vec<String>> {
let output = git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%s",
&format!("{from}..{to}"),
],
)?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn get_current_branch() -> Result<String> {
get_current_branch_in(&cwd_or_dot())
}
pub fn is_branchlike(name: &str) -> bool {
use regex::Regex;
use std::sync::OnceLock;
static LOCKSTEP: OnceLock<Regex> = OnceLock::new();
static PER_CRATE: OnceLock<Regex> = OnceLock::new();
let lockstep = LOCKSTEP.get_or_init(|| Regex::new(r"^v\d+\.\d+\.\d+").expect("static regex"));
let per_crate =
PER_CRATE.get_or_init(|| Regex::new(r"^[^/]+-v\d+\.\d+\.\d+").expect("static regex"));
!(lockstep.is_match(name) || per_crate.is_match(name))
}
pub fn get_current_branch_in(cwd: &Path) -> Result<String> {
get_current_branch_in_with_env(cwd, &crate::ProcessEnvSource)
}
pub fn get_current_branch_in_with_env<E: crate::EnvSource + ?Sized>(
cwd: &Path,
env: &E,
) -> Result<String> {
if let Ok(name) = git_output_in(cwd, &["symbolic-ref", "--short", "HEAD"]) {
return Ok(name);
}
if let Ok(out) = git_output_in(
cwd,
&[
"for-each-ref",
"--points-at",
"HEAD",
"--format=%(refname:short)",
"refs/heads/",
],
) && !out.is_empty()
{
let branches: Vec<&str> = out.lines().collect();
for preferred in ["master", "main"] {
if branches.contains(&preferred) {
return Ok(preferred.to_string());
}
}
if let Some(first) = branches.first() {
return Ok((*first).to_string());
}
}
if let Ok(out) = git_output_in(
cwd,
&["symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
) && let Some(name) = out.strip_prefix("origin/")
{
return Ok(name.to_string());
}
if let Some(name) = env.var("GITHUB_REF_NAME")
&& !name.is_empty()
&& is_branchlike(&name)
{
return Ok(name);
}
anyhow::bail!(
"could not resolve current branch: HEAD is detached and no fallback (points-at-HEAD branches, origin/HEAD, GITHUB_REF_NAME) succeeded"
)
}
pub fn branches_containing_sha_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
let out = git_output_in(
cwd,
&[
"branch",
"-r",
"--contains",
sha,
"--format=%(refname:short)",
],
)?;
Ok(out
.lines()
.filter_map(|line| line.trim().strip_prefix("origin/").map(str::to_string))
.filter(|name| !name.is_empty() && name != "HEAD")
.collect())
}
pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
has_commits_since_tag_in(&cwd_or_dot(), tag)
}
pub fn has_commits_since_tag_in(cwd: &Path, tag: &str) -> Result<bool> {
let range = format!("{}..HEAD", tag);
let output = git_output_in(
cwd,
&["-c", "log.showSignature=false", "log", "--oneline", &range],
)?;
Ok(!output.is_empty())
}
pub fn count_commits_since_last_tag_in(cwd: &Path, monorepo_prefix: Option<&str>) -> Result<u64> {
let match_arg;
let mut describe_args: Vec<&str> = vec!["describe", "--tags", "--abbrev=0"];
if let Some(prefix) = monorepo_prefix {
match_arg = format!("--match={}*", prefix);
describe_args.push(&match_arg);
}
describe_args.push("HEAD");
let range = match git_output_in(cwd, &describe_args) {
Ok(tag) if !tag.is_empty() => format!("{tag}..HEAD"),
_ => "HEAD".to_string(),
};
let count = match git_output_in(cwd, &["rev-list", "--count", &range]) {
Ok(s) => s.trim().parse::<u64>().unwrap_or(0),
Err(_) => 0,
};
Ok(count)
}
pub fn get_short_commit() -> Result<String> {
get_short_commit_in(&cwd_or_dot())
}
pub fn get_short_commit_in(cwd: &Path) -> Result<String> {
git_output_in(cwd, &["rev-parse", "--short", "HEAD"])
}
pub const SHORT_COMMIT_LEN: usize = 7;
pub fn short_commit_str(commit: &str) -> String {
if commit.len() > SHORT_COMMIT_LEN {
commit[..SHORT_COMMIT_LEN].to_string()
} else {
commit.to_string()
}
}
pub fn get_head_commit() -> Result<String> {
get_head_commit_in(&cwd_or_dot())
}
pub fn get_head_commit_in(cwd: &Path) -> Result<String> {
git_output_in(cwd, &["rev-parse", "HEAD"])
}
pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
has_changes_since_in(&cwd_or_dot(), tag, path)
}
pub fn has_changes_since_in(cwd: &Path, tag: &str, path: &str) -> Result<bool> {
let output = git_output_in(
cwd,
&["diff", "--name-only", &format!("{}..HEAD", tag), "--", path],
)?;
Ok(!output.is_empty())
}
pub fn get_last_commit_messages_path(count: usize, path: &str) -> Result<Vec<String>> {
get_last_commit_messages_path_in(&cwd_or_dot(), count, path)
}
pub fn get_last_commit_messages_path_in(
cwd: &Path,
count: usize,
path: &str,
) -> Result<Vec<String>> {
let output = git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
&format!("-{count}"),
"--pretty=format:%s",
"--",
path,
],
)?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn get_commit_messages_between_path(from: &str, to: &str, path: &str) -> Result<Vec<String>> {
get_commit_messages_between_path_in(&cwd_or_dot(), from, to, path)
}
pub fn get_commit_messages_between_path_in(
cwd: &Path,
from: &str,
to: &str,
path: &str,
) -> Result<Vec<String>> {
let output = git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%s",
&format!("{from}..{to}"),
"--",
path,
],
)?;
Ok(output.lines().map(str::to_string).collect())
}
pub fn stage_and_commit(files: &[&str], message: &str) -> Result<bool> {
stage_and_commit_in(&cwd_or_dot(), files, message)
}
pub fn stage_and_commit_in(cwd: &Path, files: &[&str], message: &str) -> Result<bool> {
let mut args = vec!["add", "--"];
args.extend(files.iter().copied());
git_output_in(cwd, &args)?;
let diff = Command::new("git")
.current_dir(cwd)
.args(["diff", "--cached", "--quiet", "--"])
.args(files)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.status()?;
if diff.success() {
return Ok(false);
}
git_output_in(cwd, &["commit", "-m", message])?;
Ok(true)
}
pub fn log_subjects_for_range(
workspace_root: &std::path::Path,
range: &str,
rel_path: &str,
) -> Result<Vec<String>> {
let out = Command::new("git")
.arg("-C")
.arg(workspace_root)
.args([
"-c",
"log.showSignature=false",
"log",
"--pretty=format:%B%x1e",
range,
"--",
rel_path,
])
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
let missing_range = stderr.contains("unknown revision")
|| stderr.contains("bad revision")
|| stderr.contains("does not have any commits yet");
if missing_range {
return Ok(Vec::new());
}
let raw = format!("git log {} failed: {}", range, stderr.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
let text = String::from_utf8_lossy(&out.stdout);
Ok(text
.split('\x1e')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect())
}
pub fn add_path_in(workspace_root: &std::path::Path, rel: &std::path::Path) -> Result<()> {
let out = Command::new("git")
.arg("-C")
.arg(workspace_root)
.arg("add")
.arg(rel)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()
.context("failed to invoke git add")?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let raw = format!("git add {} failed: {}", rel.display(), stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
Ok(())
}
pub fn commit_in(workspace_root: &std::path::Path, message: &str, sign: bool) -> Result<()> {
let mut cmd = Command::new("git");
cmd.arg("-C").arg(workspace_root).arg("commit");
if sign {
cmd.arg("-S");
}
cmd.arg("-m")
.arg(message)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C");
let out = cmd.output().context("failed to invoke git commit")?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let raw = format!("git commit failed: {}", stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
Ok(())
}
pub fn paths_changed_since_tag(tag: &str, paths: &[&str]) -> Result<bool> {
paths_changed_since_tag_in(&cwd_or_dot(), tag, paths)
}
pub fn paths_changed_since_tag_in(cwd: &Path, tag: &str, paths: &[&str]) -> Result<bool> {
let mut args: Vec<String> = vec![
"diff".to_string(),
"--name-only".to_string(),
format!("{tag}..HEAD"),
"--".to_string(),
];
for p in paths {
args.push((*p).to_string());
}
let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
let output = Command::new("git")
.current_dir(cwd)
.args(&arg_refs)
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()?;
if output.status.success() {
Ok(!String::from_utf8_lossy(&output.stdout).trim().is_empty())
} else {
Ok(false)
}
}
pub fn head_commit_hash_in(repo: &std::path::Path) -> Result<String> {
get_head_commit_in(repo)
}
pub fn rev_parse_in(cwd: &Path, rev: &str) -> Result<String> {
git_output_in(cwd, &["rev-parse", rev])
}
pub fn rev_verify_commit_in(cwd: &Path, rev: &str) -> Result<String> {
git_output_in(
cwd,
&["rev-parse", "--verify", &format!("{}^{{commit}}", rev)],
)
}
pub fn commits_between_in(cwd: &Path, sha: &str) -> Result<Vec<String>> {
let range = format!("{}..HEAD", sha);
let out = git_output_in(cwd, &["rev-list", &range])?;
if out.is_empty() {
return Ok(Vec::new());
}
Ok(out.lines().map(|s| s.trim().to_string()).collect())
}
pub fn commit_subject_in(cwd: &Path, sha: &str) -> Result<String> {
git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
"-1",
"--format=%s",
sha,
],
)
}
pub fn commits_with_subjects_in(cwd: &Path, sha: &str) -> Result<Vec<(String, String)>> {
let range = format!("{}..HEAD", sha);
let out = git_output_in(
cwd,
&[
"-c",
"log.showSignature=false",
"log",
"--format=%H%x1f%s",
&range,
],
)?;
if out.is_empty() {
return Ok(Vec::new());
}
Ok(out
.lines()
.filter_map(|line| {
let mut parts = line.splitn(2, '\x1f');
let sha = parts.next()?.trim().to_string();
let subj = parts.next().unwrap_or("").to_string();
if sha.is_empty() {
None
} else {
Some((sha, subj))
}
})
.collect())
}
#[derive(Debug, Clone, Default)]
pub struct CommitterIdentity {
pub name: Option<String>,
pub email: Option<String>,
}
impl CommitterIdentity {
pub fn default_for_rollback() -> Self {
let host = std::env::var("HOSTNAME")
.ok()
.or_else(|| std::env::var("COMPUTERNAME").ok())
.and_then(|h| h.split('.').next().map(str::to_string))
.filter(|h| !h.is_empty())
.unwrap_or_else(|| "localhost".to_string());
Self {
name: Some("anodize-rollback".to_string()),
email: Some(format!("anodize-rollback@{host}")),
}
}
fn apply_to(&self, cmd: &mut Command) {
if let Some(n) = &self.name {
cmd.env("GIT_AUTHOR_NAME", n).env("GIT_COMMITTER_NAME", n);
}
if let Some(e) = &self.email {
cmd.env("GIT_AUTHOR_EMAIL", e).env("GIT_COMMITTER_EMAIL", e);
}
}
}
fn read_git_identity(cwd: &Path) -> (Option<String>, Option<String>) {
let one = |key: &str| -> Option<String> {
let out = Command::new("git")
.current_dir(cwd)
.args(["config", "--get", key])
.env("LC_ALL", "C")
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.ok()?;
if !out.status.success() {
return None;
}
let value = String::from_utf8_lossy(&out.stdout).trim().to_string();
if value.is_empty() { None } else { Some(value) }
};
(one("user.name"), one("user.email"))
}
pub fn resolve_rollback_identity(cwd: &Path) -> CommitterIdentity {
let env_author_set =
std::env::var("GIT_AUTHOR_EMAIL").is_ok() && std::env::var("GIT_AUTHOR_NAME").is_ok();
let env_committer_set =
std::env::var("GIT_COMMITTER_EMAIL").is_ok() && std::env::var("GIT_COMMITTER_NAME").is_ok();
if env_author_set && env_committer_set {
return CommitterIdentity::default();
}
let (name, email) = read_git_identity(cwd);
if name.is_some() && email.is_some() {
return CommitterIdentity::default();
}
CommitterIdentity::default_for_rollback()
}
pub fn revert_commit_in(
cwd: &Path,
sha: &str,
message: Option<&str>,
identity: &CommitterIdentity,
) -> Result<()> {
let status = Command::new("git")
.args(["status", "--porcelain", "--untracked-files=no"])
.current_dir(cwd)
.env("LC_ALL", "C")
.env("GIT_TERMINAL_PROMPT", "0")
.output()
.with_context(|| format!("revert_commit_in: git status in {}", cwd.display()))?;
if !status.status.success() {
let stderr_raw = String::from_utf8_lossy(&status.stderr);
let raw = format!("git status failed: {}", stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
if !status.stdout.is_empty() {
bail!(
"refusing to revert in a dirty working tree at {}\nstatus:\n{}",
cwd.display(),
String::from_utf8_lossy(&status.stdout),
);
}
let mut revert_cmd = Command::new("git");
revert_cmd
.current_dir(cwd)
.args(["revert", "--no-edit", sha])
.env("LC_ALL", "C")
.env("GIT_TERMINAL_PROMPT", "0");
identity.apply_to(&mut revert_cmd);
let out = revert_cmd
.output()
.with_context(|| format!("revert_commit_in: git revert in {}", cwd.display()))?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let _ = Command::new("git")
.current_dir(cwd)
.args(["revert", "--abort"])
.env("LC_ALL", "C")
.env("GIT_TERMINAL_PROMPT", "0")
.output();
let raw = format!(
"git revert {sha} hit conflicts and was aborted (working tree restored). \
The bump commit overlaps with later changes — resolve manually, \
or re-run with --mode=reset to force.\nstderr: {}",
stderr_raw.trim()
);
bail!("{}", crate::redact::redact_process_env(&raw));
}
if let Some(msg) = message {
let mut amend_cmd = Command::new("git");
amend_cmd
.current_dir(cwd)
.args(["commit", "--amend", "-m", msg])
.env("LC_ALL", "C")
.env("GIT_TERMINAL_PROMPT", "0");
identity.apply_to(&mut amend_cmd);
let out = amend_cmd.output().with_context(|| {
format!("revert_commit_in: git commit --amend in {}", cwd.display())
})?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let raw = format!("git commit --amend failed: {}", stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
}
Ok(())
}
pub fn reset_hard_in(cwd: &Path, sha: &str) -> Result<()> {
git_output_in(cwd, &["reset", "--hard", sha])?;
Ok(())
}
pub fn push_branch_in(cwd: &Path, branch: &str) -> Result<()> {
if !super::has_remote_in(cwd, "origin") {
bail!("no 'origin' remote configured, cannot push branch '{branch}'");
}
let refspec = format!("HEAD:refs/heads/{}", branch);
let out = Command::new("git")
.current_dir(cwd)
.args(["push", "origin", &refspec])
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()
.with_context(|| format!("push_branch_in: git push origin {refspec}"))?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let raw = format!("git push origin {} failed: {}", refspec, stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
Ok(())
}
pub fn head_commit_timestamp_in(repo: &std::path::Path) -> Result<i64> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["log", "-1", "--format=%ct", "HEAD"])
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C")
.output()
.context("failed to invoke git log -1 --format=%ct HEAD")?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let raw = format!("git log -1 --format=%ct HEAD failed: {}", stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
let text = String::from_utf8_lossy(&out.stdout).trim().to_string();
text.parse::<i64>()
.with_context(|| format!("git log --format=%ct returned non-i64 timestamp: {}", text))
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn init_repo_with_commits(dir: &Path, files: &[&str]) {
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?} failed");
};
run(&["init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
for (i, f) in files.iter().enumerate() {
std::fs::write(dir.join(f), format!("c{i}")).unwrap();
run(&["add", "."]);
run(&["commit", "-m", &format!("commit-{i}: {f}")]);
}
}
#[test]
fn changelog_provenance_marker_binds_to_its_crate_and_version() {
let tmp = tempfile::tempdir().unwrap();
init_repo_with_commits(tmp.path(), &["a"]);
std::fs::write(tmp.path().join("CHANGELOG.md"), "notes").unwrap();
let msg = format!(
"{}\n\n{}",
release_bump_subject("core→0.12.0", ""),
changelog_regenerated_marker("core", "0.12.0")
);
stage_and_commit_in(tmp.path(), &["CHANGELOG.md"], &msg).unwrap();
let probe = |name: &str, ver: &str| {
changelog_regenerated_recorded_in(tmp.path(), name, ver, "CHANGELOG.md").unwrap()
};
assert!(probe("core", "0.12.0"));
assert!(!probe("cli", "0.12.0"));
assert!(!probe("core", "0.12.1"));
assert!(!probe("core", "0.12.01"));
}
#[test]
fn changelog_provenance_scopes_to_the_probed_changelog_path() {
let tmp = tempfile::tempdir().unwrap();
init_repo_with_commits(tmp.path(), &["a"]);
std::fs::create_dir_all(tmp.path().join("crates/core")).unwrap();
std::fs::write(tmp.path().join("crates/core/CHANGELOG.md"), "notes").unwrap();
let msg = format!(
"{}\n\n{}",
release_bump_subject("core→0.2.0", ""),
changelog_regenerated_marker("core", "0.2.0")
);
stage_and_commit_in(tmp.path(), &["crates/core/CHANGELOG.md"], &msg).unwrap();
assert!(
changelog_regenerated_recorded_in(
tmp.path(),
"core",
"0.2.0",
"crates/core/CHANGELOG.md"
)
.unwrap()
);
assert!(
!changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md")
.unwrap()
);
}
#[test]
fn changelog_provenance_superseded_by_a_later_hand_edit() {
let tmp = tempfile::tempdir().unwrap();
init_repo_with_commits(tmp.path(), &["a"]);
std::fs::write(tmp.path().join("CHANGELOG.md"), "notes").unwrap();
let msg = format!(
"{}\n\n{}",
release_bump_subject("core→0.2.0", ""),
changelog_regenerated_marker("core", "0.2.0")
);
stage_and_commit_in(tmp.path(), &["CHANGELOG.md"], &msg).unwrap();
assert!(
changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md").unwrap()
);
std::fs::write(tmp.path().join("other.txt"), "x").unwrap();
stage_and_commit_in(tmp.path(), &["other.txt"], "chore: unrelated").unwrap();
assert!(
changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md").unwrap()
);
std::fs::write(tmp.path().join("CHANGELOG.md"), "notes\nedited").unwrap();
stage_and_commit_in(tmp.path(), &["CHANGELOG.md"], "docs: tweak changelog").unwrap();
assert!(
!changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md")
.unwrap()
);
}
#[test]
fn changelog_provenance_absent_when_no_marker_committed() {
let tmp = tempfile::tempdir().unwrap();
init_repo_with_commits(tmp.path(), &["a"]);
assert!(
!changelog_regenerated_recorded_in(tmp.path(), "core", "0.2.0", "CHANGELOG.md")
.unwrap()
);
}
#[test]
fn get_head_commit_in_returns_tempdirs_head_sha() {
let tmp = tempfile::tempdir().unwrap();
init_repo_with_commits(tmp.path(), &["a"]);
let expected = String::from_utf8(
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["rev-parse", "HEAD"]).current_dir(tmp.path());
cmd
},
"git",
)
.stdout,
)
.unwrap()
.trim()
.to_string();
let sha = get_head_commit_in(tmp.path()).unwrap();
assert_eq!(sha, expected);
}
#[test]
fn get_short_commit_in_returns_tempdirs_short_sha() {
let tmp = tempfile::tempdir().unwrap();
init_repo_with_commits(tmp.path(), &["a"]);
let expected = String::from_utf8(
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["rev-parse", "--short", "HEAD"])
.current_dir(tmp.path());
cmd
},
"git",
)
.stdout,
)
.unwrap()
.trim()
.to_string();
let short = get_short_commit_in(tmp.path()).unwrap();
assert_eq!(short, expected);
}
#[test]
fn has_commits_since_tag_in_returns_false_when_tag_is_head() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_commits(dir, &["a"]);
let run = |args: &[&str]| {
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
};
run(&["tag", "v1.0.0"]);
assert!(!has_commits_since_tag_in(dir, "v1.0.0").unwrap());
}
fn git_in(dir: &Path, args: &[&str]) {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?} failed");
}
#[test]
fn count_commits_since_last_tag_counts_commits_after_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_commits(dir, &["a", "b"]);
git_in(dir, &["tag", "v1.0.0"]);
for f in ["c", "d", "e"] {
std::fs::write(dir.join(f), "x").unwrap();
git_in(dir, &["add", "."]);
git_in(dir, &["commit", "-m", f]);
}
assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
}
#[test]
fn count_commits_since_last_tag_resets_on_newer_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_commits(dir, &["a"]);
git_in(dir, &["tag", "v1.0.0"]);
for f in ["b", "c"] {
std::fs::write(dir.join(f), "x").unwrap();
git_in(dir, &["add", "."]);
git_in(dir, &["commit", "-m", f]);
}
assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 2);
git_in(dir, &["tag", "v1.1.0"]);
assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 0);
std::fs::write(dir.join("d"), "x").unwrap();
git_in(dir, &["add", "."]);
git_in(dir, &["commit", "-m", "d"]);
assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 1);
}
#[test]
fn count_commits_since_last_tag_counts_all_when_no_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_commits(dir, &["a", "b", "c"]);
assert_eq!(count_commits_since_last_tag_in(dir, None).unwrap(), 3);
}
#[test]
fn count_commits_since_last_tag_respects_monorepo_prefix() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_commits(dir, &["a"]);
git_in(dir, &["tag", "core/v1.0.0"]); for f in ["b", "c"] {
std::fs::write(dir.join(f), "x").unwrap();
git_in(dir, &["add", "."]);
git_in(dir, &["commit", "-m", f]);
}
git_in(dir, &["tag", "api/v2.0.0"]); std::fs::write(dir.join("d"), "x").unwrap();
git_in(dir, &["add", "."]);
git_in(dir, &["commit", "-m", "d"]);
assert_eq!(
count_commits_since_last_tag_in(dir, Some("core/")).unwrap(),
3,
"must count since the matching-prefix tag, ignoring api/v2.0.0",
);
assert_eq!(
count_commits_since_last_tag_in(dir, None).unwrap(),
1,
"unfiltered count picks the nearest (wrong) subproject tag",
);
}
#[test]
fn get_current_branch_in_returns_branch_name() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?} failed");
};
run(&["-c", "init.defaultBranch=t1-test-branch", "init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "c1"]);
let branch = get_current_branch_in(dir).unwrap();
assert_eq!(branch, "t1-test-branch");
}
#[test]
fn get_current_branch_in_resolves_detached_head_via_points_at() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?} failed");
};
run(&["-c", "init.defaultBranch=master", "init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "c1"]);
let sha = get_head_commit_in(dir).unwrap();
run(&["checkout", "--detach", &sha]);
let branch = get_current_branch_in(dir).unwrap();
assert_eq!(
branch, "master",
"detached HEAD pointing at master must resolve to master, not literal HEAD"
);
}
#[test]
fn is_branchlike_rejects_lockstep_tag_shapes() {
assert!(!is_branchlike("v0.4.5"));
assert!(!is_branchlike("v1.2.3"));
assert!(!is_branchlike("v10.20.30"));
assert!(!is_branchlike("v1.2.3-rc.1"));
assert!(!is_branchlike("v1.2.3+build.42"));
}
#[test]
fn is_branchlike_rejects_per_crate_tag_shapes() {
assert!(!is_branchlike("mycrate-v1.2.3"));
assert!(!is_branchlike("cfgd-operator-v0.4.0"));
assert!(!is_branchlike("anodize-core-v1.2.3-rc.1"));
}
#[test]
fn is_branchlike_accepts_real_branch_names() {
assert!(is_branchlike("master"));
assert!(is_branchlike("main"));
assert!(is_branchlike("publisher-required-config"));
assert!(is_branchlike("release/v1.2.3-prep"));
assert!(is_branchlike("dependabot/cargo/serde-1.0.200"));
}
#[test]
fn is_branchlike_accepts_slashed_branch_with_embedded_version() {
assert!(is_branchlike("feature/fix-v2.0.0"));
assert!(is_branchlike("hotfix/release-v1.0.0-blocker"));
assert!(is_branchlike("user/wip-v3.1.4"));
}
#[test]
fn get_current_branch_in_rejects_tag_shaped_github_ref_name() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?} failed");
};
run(&["-c", "init.defaultBranch=master", "init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "c1"]);
let sha = get_head_commit_in(dir).unwrap();
std::fs::write(dir.join("a"), "2").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "c2"]);
run(&["checkout", "--detach", &sha]);
let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "v0.4.5");
let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
assert!(
err.to_string().contains("could not resolve current branch"),
"tag-shaped GITHUB_REF_NAME must trigger bail: {err}"
);
let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "mycrate-v1.2.3");
let err = get_current_branch_in_with_env(dir, &env).unwrap_err();
assert!(
err.to_string().contains("could not resolve current branch"),
"per-crate tag GITHUB_REF_NAME must trigger bail: {err}"
);
let env = crate::MapEnvSource::new().with("GITHUB_REF_NAME", "master");
let branch = get_current_branch_in_with_env(dir, &env).unwrap();
assert_eq!(branch, "master");
}
#[test]
fn branches_containing_sha_in_returns_empty_without_remote() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?} failed");
};
run(&["-c", "init.defaultBranch=master", "init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "1").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "c1"]);
let sha = get_head_commit_in(dir).unwrap();
let branches = branches_containing_sha_in(dir, &sha).unwrap();
assert!(branches.is_empty(), "no remote → no remote branches");
}
#[test]
fn branches_containing_sha_in_finds_remote_branch_after_push() {
let tmp = tempfile::tempdir().unwrap();
let bare = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run_in = |cwd: &Path, args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(cwd)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?} failed");
};
run_in(
bare.path(),
&["-c", "init.defaultBranch=master", "init", "--bare"],
);
run_in(dir, &["-c", "init.defaultBranch=master", "init"]);
run_in(dir, &["config", "user.email", "t@t.com"]);
run_in(dir, &["config", "user.name", "t"]);
run_in(
dir,
&["remote", "add", "origin", bare.path().to_str().unwrap()],
);
std::fs::write(dir.join("a"), "1").unwrap();
run_in(dir, &["add", "."]);
run_in(dir, &["commit", "-m", "c1"]);
let sha = get_head_commit_in(dir).unwrap();
run_in(dir, &["push", "-u", "origin", "master"]);
let branches = branches_containing_sha_in(dir, &sha).unwrap();
assert_eq!(branches, vec!["master".to_string()]);
}
#[test]
fn stage_and_commit_in_returns_false_when_no_diff() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_commits(dir, &["a"]);
let created = stage_and_commit_in(dir, &["a"], "chore: should be a no-op").unwrap();
assert!(!created, "no diff → no commit should be created");
let log = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["log", "--oneline"]).current_dir(dir);
cmd
},
"git",
);
let log_text = String::from_utf8_lossy(&log.stdout);
assert!(
!log_text.contains("should be a no-op"),
"stage_and_commit_in must not create a commit when no diff: {log_text}"
);
}
#[test]
fn stage_and_commit_in_returns_true_when_file_changed() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_commits(dir, &["a"]);
std::fs::write(dir.join("a"), "changed").unwrap();
let created = stage_and_commit_in(dir, &["a"], "chore: real change").unwrap();
assert!(created, "real change → commit must be created");
let log = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
cmd
},
"git",
);
let subject = String::from_utf8_lossy(&log.stdout).trim().to_string();
assert_eq!(subject, "chore: real change");
}
#[test]
fn git_output_in_error_falls_back_to_stdout_when_stderr_empty() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_repo_with_commits(dir, &["a"]);
let err = git_output_in(dir, &["commit", "-m", "no-op"]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("nothing to commit") || msg.contains("clean"),
"error must include stdout detail when stderr is empty: {msg}"
);
}
#[test]
fn default_for_rollback_populates_both_name_and_email() {
let id = CommitterIdentity::default_for_rollback();
assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
let email = id.email.expect("email must be Some");
assert!(
email.starts_with("anodize-rollback@"),
"email must use the anodize-rollback@<host> shape; got {email}"
);
assert!(!email.ends_with('@'), "host portion must not be empty");
}
#[test]
fn revert_commit_in_uses_injected_identity_envs() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run_env = |args: &[&str], extra: &[(&str, &str)]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "bootstrap")
.env("GIT_AUTHOR_EMAIL", "bootstrap@b.com")
.env("GIT_COMMITTER_NAME", "bootstrap")
.env("GIT_COMMITTER_EMAIL", "bootstrap@b.com");
for (k, v) in extra {
cmd.env(k, v);
}
cmd
},
"git",
);
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
};
run_env(&["init", "-b", "master"], &[]);
std::fs::write(dir.join("a"), "0").unwrap();
run_env(&["add", "."], &[]);
run_env(&["commit", "-m", "initial"], &[]);
std::fs::write(dir.join("a"), "1").unwrap();
run_env(&["add", "."], &[]);
run_env(&["commit", "-m", "chore(release): v1.0.0"], &[]);
let bump_sha = get_head_commit_in(dir).unwrap();
let identity = CommitterIdentity {
name: Some("rollback-bot".to_string()),
email: Some("rollback-bot@anodize.test".to_string()),
};
revert_commit_in(dir, &bump_sha, Some("chore(release): rollback"), &identity)
.expect("revert with injected identity must succeed");
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.args(["log", "-1", "--format=%ae"])
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C");
cmd
},
"git",
);
let author_email = String::from_utf8_lossy(&out.stdout).trim().to_string();
assert_eq!(
author_email, "rollback-bot@anodize.test",
"revert commit must carry the injected committer identity"
);
let cfg = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.current_dir(dir)
.args(["config", "--local", "--get", "user.email"])
.env("GIT_TERMINAL_PROMPT", "0")
.env("LC_ALL", "C");
cmd
},
"git",
);
assert!(
!cfg.status.success() || cfg.stdout.is_empty(),
"revert must not write user.email into the repo's local config; got: {}",
String::from_utf8_lossy(&cfg.stdout)
);
}
#[test]
fn revert_commit_in_aborts_on_conflict_and_leaves_tree_clean() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
};
run(&["init", "-b", "master"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("x"), "v1\n").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
std::fs::write(dir.join("x"), "v2\n").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "chore(release): v2"]);
let bump_sha = get_head_commit_in(dir).unwrap();
std::fs::write(dir.join("x"), "v3\n").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "feat: overlap"]);
let identity = CommitterIdentity::default();
let err = revert_commit_in(dir, &bump_sha, None, &identity)
.expect_err("revert against overlapping HEAD must conflict and bail");
let msg = format!("{err}");
assert!(
msg.contains("aborted"),
"bail message must mention abort recovery: {msg}"
);
assert!(
!dir.join(".git/REVERT_HEAD").exists(),
".git/REVERT_HEAD must be cleaned up after --abort"
);
let status_out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["status", "--porcelain"]).current_dir(dir);
cmd
},
"git",
);
assert!(
status_out.stdout.is_empty(),
"working tree must be clean after revert --abort; got:\n{}",
String::from_utf8_lossy(&status_out.stdout)
);
}
#[test]
fn commits_with_subjects_in_returns_all_pairs_in_one_call() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
);
assert!(out.status.success(), "git {args:?} failed");
};
run(&["init", "-b", "master"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("a"), "0").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
let base = get_head_commit_in(dir).unwrap();
std::fs::write(dir.join("a"), "1").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "feat: A with extra detail"]);
std::fs::write(dir.join("a"), "2").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "fix: B"]);
let pairs = commits_with_subjects_in(dir, &base).unwrap();
assert_eq!(pairs.len(), 2, "two commits sit on top of base");
assert_eq!(pairs[0].1, "fix: B");
assert_eq!(pairs[1].1, "feat: A with extra detail");
let head = get_head_commit_in(dir).unwrap();
assert!(commits_with_subjects_in(dir, &head).unwrap().is_empty());
}
#[test]
fn parse_commit_output_with_files_pairs_each_commit_with_its_files() {
let raw = "h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\nh0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f\x1e\ncrates/core/lib.rs\nCargo.toml\n";
let parsed = parse_commit_output_with_files(raw);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[0].commit.message, "fix: B");
assert_eq!(parsed[0].files, vec!["crates/cli/main.rs".to_string()]);
assert_eq!(parsed[1].commit.message, "feat: A");
assert_eq!(
parsed[1].files,
vec!["crates/core/lib.rs".to_string(), "Cargo.toml".to_string()]
);
}
#[test]
fn parse_commit_output_with_files_preserves_multiline_body_at_idx_gt_0() {
let body0 = "detail line one\ndetail line two\n\nCo-Authored-By: Bob <bob@b.com>";
let raw = format!(
"h1\x1fs1\x1ffix: B\x1ft\x1ft@t\x1f\x1e\ncrates/cli/main.rs\n\n\
h0\x1fs0\x1ffeat: A\x1ft\x1ft@t\x1f{body0}\x1e\ncrates/core/lib.rs\n"
);
let parsed = parse_commit_output_with_files(&raw);
assert_eq!(parsed.len(), 2);
assert_eq!(parsed[1].commit.message, "feat: A");
assert_eq!(parsed[1].commit.body, body0);
assert!(
parsed[1]
.commit
.body
.contains("Co-Authored-By: Bob <bob@b.com>"),
"multi-line body trailer dropped: {:?}",
parsed[1].commit.body
);
assert_eq!(parsed[1].files, vec!["crates/core/lib.rs".to_string()]);
}
#[test]
fn get_commits_between_paths_with_files_in_reports_touched_files() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
assert!(
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
)
.status
.success()
);
};
run(&["init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("base"), "0").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
let base = get_head_commit_in(dir).unwrap();
std::fs::create_dir_all(dir.join("crates/core")).unwrap();
std::fs::write(dir.join("crates/core/lib.rs"), "1").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "feat: core"]);
let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].commit.message, "feat: core");
assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
}
#[test]
fn get_commits_between_paths_with_files_in_preserves_multiline_body_for_later_commits() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
let run = |args: &[&str]| {
assert!(
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "t")
.env("GIT_AUTHOR_EMAIL", "t@t.com")
.env("GIT_COMMITTER_NAME", "t")
.env("GIT_COMMITTER_EMAIL", "t@t.com");
cmd
},
"git",
)
.status
.success()
);
};
run(&["init"]);
run(&["config", "user.email", "t@t.com"]);
run(&["config", "user.name", "t"]);
std::fs::write(dir.join("base"), "0").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "initial"]);
let base = get_head_commit_in(dir).unwrap();
std::fs::write(dir.join("a.rs"), "1").unwrap();
run(&["add", "."]);
run(&[
"commit",
"-m",
"feat: with body\n\nfirst body line\nsecond body line\n\nCo-Authored-By: Bob <bob@b.com>",
]);
std::fs::write(dir.join("b.rs"), "2").unwrap();
run(&["add", "."]);
run(&["commit", "-m", "fix: later"]);
let pairs = get_commits_between_paths_with_files_in(dir, &base, "HEAD", &[]).unwrap();
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].commit.message, "fix: later");
let body = &pairs[1].commit.body;
assert!(
body.contains("first body line") && body.contains("second body line"),
"multi-line body truncated for idx>0 commit: {body:?}"
);
assert!(
body.contains("Co-Authored-By: Bob <bob@b.com>"),
"Co-Authored-By trailer dropped for idx>0 commit: {body:?}"
);
}
#[test]
fn parse_commit_output_empty_input_yields_no_commits() {
assert!(parse_commit_output("").is_empty());
}
#[test]
fn parse_commit_output_decodes_all_six_fields() {
let raw =
"abc123def\x1fabc123d\x1ffeat: add thing\x1fAlice\x1falice@x.com\x1fbody text\x1e";
let commits = parse_commit_output(raw);
assert_eq!(commits.len(), 1);
let c = &commits[0];
assert_eq!(c.hash, "abc123def");
assert_eq!(c.short_hash, "abc123d");
assert_eq!(c.message, "feat: add thing");
assert_eq!(c.author_name, "Alice");
assert_eq!(c.author_email, "alice@x.com");
assert_eq!(c.body, "body text");
}
#[test]
fn parse_commit_output_trims_hash_and_body_but_keeps_inner_subject() {
let raw = " abc \x1fabc\x1ffix: keep spaces\x1ft\x1ft@t\x1f\n\nbody\n\x1e";
let commits = parse_commit_output(raw);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].hash, "abc", "hash is trimmed");
assert_eq!(commits[0].message, "fix: keep spaces", "subject verbatim");
assert_eq!(commits[0].body, "body", "body is trimmed");
}
#[test]
fn parse_commit_output_absent_body_field_defaults_to_empty() {
let raw = "h\x1fh\x1fsubject\x1fname\x1fmail\x1e";
let commits = parse_commit_output(raw);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].body, "");
assert_eq!(commits[0].message, "subject");
}
#[test]
fn parse_commit_output_skips_records_with_too_few_fields() {
let raw = "only\x1ftwo\x1e\
h\x1fh\x1fgood: subject\x1fn\x1fe\x1fbody\x1e";
let commits = parse_commit_output(raw);
assert_eq!(commits.len(), 1, "malformed record dropped, good one kept");
assert_eq!(commits[0].message, "good: subject");
}
#[test]
fn parse_commit_output_multiline_body_survives_record_separator_split() {
let raw = "h1\x1fh1\x1ffeat: A\x1fA\x1fa@x\x1fline one\nline two\n\nCo-Authored-By: B <b@x>\x1e\
h0\x1fh0\x1ffix: B\x1fB\x1fb@x\x1f\x1e";
let commits = parse_commit_output(raw);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].message, "feat: A");
assert!(commits[0].body.contains("line one\nline two"));
assert!(commits[0].body.contains("Co-Authored-By: B <b@x>"));
assert_eq!(commits[1].message, "fix: B");
assert_eq!(commits[1].body, "");
}
#[test]
fn short_commit_str_truncates_long_sha_to_seven() {
assert_eq!(short_commit_str("abcdef0123456789"), "abcdef0");
assert_eq!(short_commit_str("abcdef0123456789").len(), SHORT_COMMIT_LEN);
}
#[test]
fn short_commit_str_returns_shorter_or_equal_input_unchanged() {
assert_eq!(short_commit_str("abc"), "abc", "shorter than 7 unchanged");
assert_eq!(
short_commit_str("abcdefg"),
"abcdefg",
"exactly 7 unchanged"
);
assert_eq!(short_commit_str(""), "", "empty stays empty");
}
fn g(dir: &Path, args: &[&str]) {
let out = anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(args)
.current_dir(dir)
.env("GIT_AUTHOR_NAME", "Ada")
.env("GIT_AUTHOR_EMAIL", "ada@x.com")
.env("GIT_COMMITTER_NAME", "Ada")
.env("GIT_COMMITTER_EMAIL", "ada@x.com")
.env("GIT_AUTHOR_DATE", "1715000000 +0000")
.env("GIT_COMMITTER_DATE", "1715000000 +0000");
cmd
},
"git",
);
assert!(
out.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
fn init_bare_repo(dir: &Path) {
g(dir, &["init", "-b", "master"]);
g(dir, &["config", "user.email", "ada@x.com"]);
g(dir, &["config", "user.name", "Ada"]);
}
fn commit_file(dir: &Path, path: &str, content: &str, subject: &str) {
let full = dir.join(path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(full, content).unwrap();
g(dir, &["add", "."]);
g(dir, &["commit", "-m", subject]);
}
#[test]
fn get_commits_between_in_returns_only_post_base_commits() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let base = get_head_commit_in(dir).unwrap();
commit_file(dir, "a", "1", "feat: one");
commit_file(dir, "a", "2", "fix: two");
let commits = get_commits_between_in(dir, &base, "HEAD", None).unwrap();
assert_eq!(commits.len(), 2, "two commits sit above base");
assert_eq!(commits[0].message, "fix: two");
assert_eq!(commits[1].message, "feat: one");
assert_eq!(commits[1].author_name, "Ada");
assert_eq!(commits[1].author_email, "ada@x.com");
}
#[test]
fn get_commits_between_in_path_filter_excludes_untouched_files() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "base", "0", "initial");
let base = get_head_commit_in(dir).unwrap();
commit_file(dir, "src/lib.rs", "1", "feat: touch lib");
commit_file(dir, "docs/readme", "2", "docs: touch docs only");
let commits = get_commits_between_in(dir, &base, "HEAD", Some("src")).unwrap();
assert_eq!(commits.len(), 1, "only the src-touching commit survives");
assert_eq!(commits[0].message, "feat: touch lib");
}
#[test]
fn get_commits_between_paths_in_unions_multiple_paths() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "base", "0", "initial");
let base = get_head_commit_in(dir).unwrap();
commit_file(dir, "a/x", "1", "feat: a");
commit_file(dir, "b/y", "2", "feat: b");
commit_file(dir, "c/z", "3", "feat: c");
let commits =
get_commits_between_paths_in(dir, &base, "HEAD", &["a".into(), "b".into()]).unwrap();
let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
assert_eq!(
commits.len(),
2,
"a and b touched, c excluded: {subjects:?}"
);
assert!(subjects.contains(&"feat: a"));
assert!(subjects.contains(&"feat: b"));
assert!(!subjects.contains(&"feat: c"));
}
#[test]
fn get_all_commits_in_returns_every_commit_on_head() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "first");
commit_file(dir, "a", "1", "second");
commit_file(dir, "a", "2", "third");
let commits = get_all_commits_in(dir, None).unwrap();
assert_eq!(commits.len(), 3);
assert_eq!(commits[0].message, "third", "newest-first");
assert_eq!(commits[2].message, "first");
}
#[test]
fn get_all_commits_paths_in_filters_to_path() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "keep/x", "0", "feat: keep");
commit_file(dir, "drop/y", "1", "feat: drop");
let commits = get_all_commits_paths_in(dir, &["keep".into()]).unwrap();
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].message, "feat: keep");
}
#[test]
fn get_all_commits_paths_with_files_in_pairs_files() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "crates/core/lib.rs", "0", "feat: core");
let pairs = get_all_commits_paths_with_files_in(dir, &[]).unwrap();
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].commit.message, "feat: core");
assert_eq!(pairs[0].files, vec!["crates/core/lib.rs".to_string()]);
}
#[test]
fn get_commits_reachable_paths_in_stops_at_the_given_rev() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "first");
commit_file(dir, "a", "1", "second");
let mid = get_head_commit_in(dir).unwrap();
commit_file(dir, "a", "2", "third-after-mid");
let commits = get_commits_reachable_paths_in(dir, &mid, &[]).unwrap();
let subjects: Vec<&str> = commits.iter().map(|c| c.message.as_str()).collect();
assert_eq!(commits.len(), 2, "only ancestors of mid: {subjects:?}");
assert!(subjects.contains(&"first"));
assert!(subjects.contains(&"second"));
assert!(!subjects.contains(&"third-after-mid"));
}
#[test]
fn get_commits_reachable_paths_with_files_in_pairs_touched_files() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "src/main.rs", "0", "feat: main");
let head = get_head_commit_in(dir).unwrap();
let pairs = get_commits_reachable_paths_with_files_in(dir, &head, &[]).unwrap();
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].commit.message, "feat: main");
assert_eq!(pairs[0].files, vec!["src/main.rs".to_string()]);
}
#[test]
fn get_last_commit_messages_in_returns_n_subjects_newest_first() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "one");
commit_file(dir, "a", "1", "two");
commit_file(dir, "a", "2", "three");
let msgs = get_last_commit_messages_in(dir, 2).unwrap();
assert_eq!(msgs, vec!["three".to_string(), "two".to_string()]);
}
#[test]
fn get_commit_messages_between_in_lists_post_base_subjects() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let base = get_head_commit_in(dir).unwrap();
commit_file(dir, "a", "1", "feat: x");
commit_file(dir, "a", "2", "fix: y");
let msgs = get_commit_messages_between_in(dir, &base, "HEAD").unwrap();
assert_eq!(msgs, vec!["fix: y".to_string(), "feat: x".to_string()]);
}
#[test]
fn get_last_commit_messages_path_in_filters_to_path() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "keep/a", "0", "feat: keep");
commit_file(dir, "other/b", "1", "feat: other");
let msgs = get_last_commit_messages_path_in(dir, 10, "keep").unwrap();
assert_eq!(msgs, vec!["feat: keep".to_string()]);
}
#[test]
fn get_commit_messages_between_path_in_filters_range_and_path() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "base", "0", "initial");
let base = get_head_commit_in(dir).unwrap();
commit_file(dir, "src/x", "1", "feat: src");
commit_file(dir, "doc/y", "2", "docs: doc");
let msgs = get_commit_messages_between_path_in(dir, &base, "HEAD", "src").unwrap();
assert_eq!(msgs, vec!["feat: src".to_string()]);
}
#[test]
fn has_changes_since_in_detects_path_touched_after_tag() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "watched", "0", "initial");
g(dir, &["tag", "v1.0.0"]);
assert!(!has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
commit_file(dir, "watched", "1", "feat: change watched");
assert!(has_changes_since_in(dir, "v1.0.0", "watched").unwrap());
assert!(!has_changes_since_in(dir, "v1.0.0", "unrelated").unwrap());
}
#[test]
fn paths_changed_since_tag_in_true_when_any_path_changed() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
g(dir, &["tag", "v1.0.0"]);
commit_file(dir, "b", "1", "feat: add b");
assert!(paths_changed_since_tag_in(dir, "v1.0.0", &["a", "b"]).unwrap());
assert!(!paths_changed_since_tag_in(dir, "v1.0.0", &["a"]).unwrap());
}
#[test]
fn paths_changed_since_tag_in_returns_false_when_git_fails() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
assert!(!paths_changed_since_tag_in(dir, "nope-no-such-tag", &["a"]).unwrap());
}
#[test]
fn head_commit_hash_in_matches_rev_parse_head() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let expected = get_head_commit_in(dir).unwrap();
assert_eq!(head_commit_hash_in(dir).unwrap(), expected);
}
#[test]
fn head_commit_hash_in_errors_on_non_repo() {
let tmp = tempfile::tempdir().unwrap();
assert!(head_commit_hash_in(tmp.path()).is_err());
}
#[test]
fn rev_parse_in_resolves_branch_to_full_sha() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let head = get_head_commit_in(dir).unwrap();
assert_eq!(rev_parse_in(dir, "master").unwrap(), head);
}
#[test]
fn rev_verify_commit_in_accepts_commit_rejects_unknown() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let head = get_head_commit_in(dir).unwrap();
assert_eq!(rev_verify_commit_in(dir, "HEAD").unwrap(), head);
assert!(rev_verify_commit_in(dir, "deadbeefdeadbeef").is_err());
}
#[test]
fn commits_between_in_lists_shas_above_base_and_empty_at_head() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let base = get_head_commit_in(dir).unwrap();
commit_file(dir, "a", "1", "second");
let head = get_head_commit_in(dir).unwrap();
let shas = commits_between_in(dir, &base).unwrap();
assert_eq!(
shas,
vec![head.clone()],
"exactly the one commit above base"
);
assert!(commits_between_in(dir, &head).unwrap().is_empty());
}
#[test]
fn commit_subject_in_returns_single_commit_subject() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "feat: only-subject\n\nignored body");
let head = get_head_commit_in(dir).unwrap();
assert_eq!(commit_subject_in(dir, &head).unwrap(), "feat: only-subject");
}
#[test]
fn head_commit_timestamp_in_returns_pinned_committer_epoch() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
assert_eq!(head_commit_timestamp_in(dir).unwrap(), 1_715_000_000);
}
#[test]
fn log_subjects_for_range_returns_full_bodies_for_path() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "watched", "0", "feat: A\n\nbody of A");
commit_file(dir, "watched", "1", "fix: B");
let bodies = log_subjects_for_range(dir, "HEAD", "watched").unwrap();
assert_eq!(bodies.len(), 2);
assert!(bodies[0].starts_with("fix: B"));
assert!(bodies[1].contains("feat: A") && bodies[1].contains("body of A"));
}
#[test]
fn log_subjects_for_range_returns_empty_when_range_invalid() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let bodies = log_subjects_for_range(dir, "no-such-ref..HEAD", "a").unwrap();
assert!(bodies.is_empty());
}
#[test]
fn log_subjects_for_range_propagates_pathspec_fatal() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let err = log_subjects_for_range(dir, "HEAD", "")
.expect_err("empty pathspec must be an error, not empty success")
.to_string();
assert!(err.contains("git log HEAD failed"), "{err}");
}
#[test]
fn add_path_in_then_commit_in_creates_commit() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "seed", "0", "initial");
std::fs::write(dir.join("new.txt"), "hello").unwrap();
add_path_in(dir, std::path::Path::new("new.txt")).unwrap();
commit_in(dir, "feat: add new.txt", false).unwrap();
let subject = String::from_utf8(
anodizer_core::test_helpers::output_with_spawn_retry(
|| {
let mut cmd = Command::new("git");
cmd.args(["log", "-1", "--pretty=%s"]).current_dir(dir);
cmd
},
"git",
)
.stdout,
)
.unwrap()
.trim()
.to_string();
assert_eq!(subject, "feat: add new.txt");
}
#[test]
fn add_path_in_errors_on_missing_file() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
let err = add_path_in(dir, std::path::Path::new("does-not-exist")).unwrap_err();
assert!(
err.to_string().contains("git add"),
"error must name the failing git add: {err}"
);
}
#[test]
fn reset_hard_in_moves_head_and_restores_tree() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "first", "first");
let target = get_head_commit_in(dir).unwrap();
commit_file(dir, "a", "second", "second");
assert_ne!(get_head_commit_in(dir).unwrap(), target);
reset_hard_in(dir, &target).unwrap();
assert_eq!(get_head_commit_in(dir).unwrap(), target, "HEAD moved back");
assert_eq!(
std::fs::read_to_string(dir.join("a")).unwrap(),
"first",
"working tree restored to target content"
);
}
#[test]
fn push_branch_in_bails_without_origin_remote() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
commit_file(dir, "a", "0", "initial");
let err = push_branch_in(dir, "master").unwrap_err();
assert!(
err.to_string().contains("no 'origin' remote"),
"missing-remote bail must be explicit: {err}"
);
}
#[test]
#[serial_test::serial(git_env)]
fn resolve_rollback_identity_inherits_when_repo_has_identity() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
init_bare_repo(dir);
struct EnvGuard(Vec<(&'static str, Option<String>)>);
impl Drop for EnvGuard {
fn drop(&mut self) {
for (k, v) in &self.0 {
match v {
Some(val) => unsafe { std::env::set_var(k, val) },
None => unsafe { std::env::remove_var(k) },
}
}
}
}
let keys = [
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
];
let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
for k in keys {
unsafe { std::env::remove_var(k) };
}
let id = resolve_rollback_identity(dir);
assert!(
id.name.is_none() && id.email.is_none(),
"configured repo identity must be inherited, not overridden: {id:?}"
);
}
#[test]
#[serial_test::serial(git_env)]
fn resolve_rollback_identity_synthesizes_when_no_identity_anywhere() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
g(dir, &["init", "-b", "master"]);
struct EnvGuard(Vec<(&'static str, Option<String>)>);
impl Drop for EnvGuard {
fn drop(&mut self) {
for (k, v) in &self.0 {
match v {
Some(val) => unsafe { std::env::set_var(k, val) },
None => unsafe { std::env::remove_var(k) },
}
}
}
}
let keys = [
"GIT_AUTHOR_NAME",
"GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME",
"GIT_COMMITTER_EMAIL",
];
let _g = EnvGuard(keys.iter().map(|k| (*k, std::env::var(k).ok())).collect());
for k in keys {
unsafe { std::env::remove_var(k) };
}
let (n, e) = read_git_identity(dir);
if n.is_none() || e.is_none() {
let id = resolve_rollback_identity(dir);
assert_eq!(id.name.as_deref(), Some("anodize-rollback"));
assert!(
id.email
.as_deref()
.unwrap_or("")
.starts_with("anodize-rollback@"),
"synthetic identity required when no config present: {id:?}"
);
}
}
#[test]
fn read_git_identity_reads_configured_values() {
let tmp = tempfile::tempdir().unwrap();
let dir = tmp.path();
g(dir, &["init", "-b", "master"]);
g(dir, &["config", "user.name", "Configured Name"]);
g(dir, &["config", "user.email", "configured@x.com"]);
let (name, email) = read_git_identity(dir);
assert_eq!(name.as_deref(), Some("Configured Name"));
assert_eq!(email.as_deref(), Some("configured@x.com"));
}
}