use anyhow::{Context as _, Result, bail};
use std::process::Command;
use super::git_output;
#[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,
}
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()
}
pub fn get_commits_between(from: &str, to: &str, path_filter: Option<&str>) -> Result<Vec<Commit>> {
get_commits_between_paths(
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>> {
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(&arg_refs)?;
Ok(parse_commit_output(&output))
}
pub fn get_all_commits(path_filter: Option<&str>) -> Result<Vec<Commit>> {
get_all_commits_paths(
&path_filter
.into_iter()
.map(String::from)
.collect::<Vec<_>>(),
)
}
pub fn get_all_commits_paths(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(&arg_refs)?;
Ok(parse_commit_output(&output))
}
pub fn get_last_commit_messages(count: usize) -> Result<Vec<String>> {
let output = git_output(&[
"-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>> {
let output = git_output(&[
"-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> {
git_output(&["rev-parse", "--abbrev-ref", "HEAD"])
}
pub fn has_commits_since_tag(tag: &str) -> Result<bool> {
let range = format!("{}..HEAD", tag);
let output = git_output(&["-c", "log.showSignature=false", "log", "--oneline", &range])?;
Ok(!output.is_empty())
}
pub fn get_short_commit() -> Result<String> {
git_output(&["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> {
git_output(&["rev-parse", "HEAD"])
}
pub fn has_changes_since(tag: &str, path: &str) -> Result<bool> {
let output = git_output(&["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>> {
let output = git_output(&[
"-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>> {
let output = git_output(&[
"-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<()> {
let mut args = vec!["add", "--"];
args.extend(files.iter().copied());
git_output(&args)?;
git_output(&["commit", "-m", message])?;
Ok(())
}
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,
])
.output()?;
if !out.status.success() {
return Ok(Vec::new());
}
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)
.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);
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> {
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").args(&arg_refs).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> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["rev-parse", "HEAD"])
.output()
.context("failed to invoke git rev-parse HEAD")?;
if !out.status.success() {
let stderr_raw = String::from_utf8_lossy(&out.stderr);
let raw = format!("git rev-parse HEAD failed: {}", stderr_raw.trim());
bail!("{}", crate::redact::redact_process_env(&raw));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
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"])
.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))
}