use git2::{Oid, Repository as _Repo, RepositoryOpenFlags, Signature};
use regex::Regex;
use std::error::Error;
use std::fmt::{Display, Formatter};
use std::path::Path;
use std::sync::LazyLock;
use tracing::{trace, warn};
use crate::git::message::GitMessage;
use crate::utils::env;
const EXCLUDED_FILES: &[&str] = &[
"go.mod",
"go.sum",
"Cargo.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
];
static EMAIL_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"^[^@\s]+@[^@\s]+\.[^@\s]+$").expect("valid email regex"));
pub struct Author {
pub name: String,
pub email: String,
}
pub struct Repository {
repository: _Repo,
}
impl Display for Repository {
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
write!(f, "Git repository at {}", self.repository.path().display())
}
}
impl Repository {
pub fn new(path: &str) -> Result<Repository, Box<dyn Error>> {
trace!("opening repository at {path}");
let no_ceilings: [&str; 0] = [];
let repository = _Repo::open_ext(
path,
RepositoryOpenFlags::empty(),
no_ceilings.iter().copied(),
)?;
trace!("repository opened successfully");
if let Some(work_dir) = repository.workdir() {
trace!("the repository workdir is: {work_dir:?}");
} else {
return Err(
"the repository has no workdir (bare repositories are not supported)".into(),
);
}
Ok(Repository { repository })
}
pub fn git_dir(&self) -> &Path {
self.repository.path()
}
pub fn commit(&self, message: &GitMessage) -> Result<Oid, Box<dyn Error>> {
let message = message.to_string();
let mut index = self.repository.index()?;
let oid = index.write_tree()?;
let tree = self.repository.find_tree(oid)?;
let parents = match self.repository.head() {
Ok(head_ref) => {
let head_commit = head_ref.peel_to_commit()?;
vec![head_commit]
}
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
trace!("creating initial commit (no parent commits)");
vec![]
}
Err(e) => return Err(Box::new(e)),
};
let author = self.get_author()?;
let signature = Signature::now(&author.name, &author.email)?;
let parent_refs: Vec<&git2::Commit> = parents.iter().collect();
let result = self.repository.commit(
Some("HEAD"),
&signature,
&signature,
&message,
&tree,
&parent_refs,
)?;
Ok(result)
}
fn git_cli_config(&self, key: &str) -> Option<String> {
let workdir = self.repository.workdir()?;
let output = std::process::Command::new("git")
.arg("-C")
.arg(workdir)
.args(["config", "--get", key])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let value = String::from_utf8(output.stdout).ok()?;
let value = value.trim();
if value.is_empty() {
None
} else {
trace!("resolved {key} via git CLI: {value}");
Some(value.to_string())
}
}
fn resolve_identity(&self, config: &git2::Config, key: &str, env_var: &str) -> Option<String> {
self.git_cli_config(key)
.or_else(|| config.get_string(key).ok())
.or_else(|| std::env::var(env_var).ok())
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}
pub fn get_author(&self) -> Result<Author, Box<dyn Error>> {
let config = self.repository.config()?;
const UNKNOWN_EMAIL: &str = "unknown@users.noreply.github.com";
const UNKNOWN_AUTHOR: &str = "Unknown Author";
let email = self
.resolve_identity(&config, "user.email", "GIT_AUTHOR_EMAIL")
.filter(|email| EMAIL_RE.is_match(email))
.unwrap_or_else(|| {
warn!("user.email missing or invalid, using default: {UNKNOWN_EMAIL}");
env::get("GIT_FALLBACK_EMAIL", UNKNOWN_EMAIL)
});
let name = self
.resolve_identity(&config, "user.name", "GIT_AUTHOR_NAME")
.unwrap_or_else(|| {
warn!("user.name missing or empty, using default: {UNKNOWN_AUTHOR}");
env::get("GIT_FALLBACK_NAME", UNKNOWN_AUTHOR)
});
Ok(Author { name, email })
}
pub fn get_diff(&self) -> Result<Vec<String>, Box<dyn Error>> {
let index = self.repository.index()?;
let head_tree = match self.repository.head() {
Ok(head_ref) => Some(head_ref.peel_to_commit()?.tree()?),
Err(e) if e.code() == git2::ErrorCode::UnbornBranch => {
trace!("generating diff for initial commit");
None
}
Err(e) => return Err(Box::new(e)),
};
let mut diffopts = git2::DiffOptions::new();
diffopts
.show_binary(false)
.force_binary(false)
.ignore_submodules(true)
.minimal(true)
.context_lines(3);
let diff = self.repository.diff_tree_to_index(
head_tree.as_ref(),
Some(&index),
Some(&mut diffopts),
)?;
let mut result = Vec::new();
diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
if let Some(name) = delta
.new_file()
.path()
.and_then(|p| p.file_name())
.map(|f| f.to_string_lossy().into_owned())
&& EXCLUDED_FILES.contains(&name.as_str())
{
warn!("skipping excluded file: {name}");
return true;
}
let content = String::from_utf8_lossy(line.content());
let trimmed = content.trim();
if !trimmed.is_empty() {
result.push(trimmed.to_string());
}
true
})?;
Ok(result)
}
pub fn should_signoff(&self) -> bool {
const SIGNOFF_KEY: &str = "aigitcommit.signoff";
let from_config = self
.repository
.config()
.ok()
.and_then(|c| c.get_bool(SIGNOFF_KEY).ok())
.unwrap_or(false);
trace!("✍️ git config signoff: {}", from_config);
from_config || env::get_bool("AIGITCOMMIT_SIGNOFF")
}
pub fn get_logs(&self, size: usize) -> Result<Vec<String>, Box<dyn Error>> {
let mut revwalk = self.repository.revwalk()?;
revwalk.push_head()?;
revwalk.set_sorting(git2::Sort::TIME)?;
let commits: Vec<String> = revwalk
.take(size)
.filter_map(|oid_result| {
oid_result
.ok()
.and_then(|oid| self.repository.find_commit(oid).ok())
.and_then(|commit| {
commit
.message()
.map(str::trim)
.filter(|msg| !msg.is_empty())
.map(String::from)
})
})
.collect();
trace!("retrieved {} commit messages", commits.len());
Ok(commits)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU32, Ordering};
struct TempRepo {
path: PathBuf,
repo: Repository,
}
impl TempRepo {
fn new(name: Option<&str>, email: Option<&str>) -> Self {
static COUNTER: AtomicU32 = AtomicU32::new(0);
let id = COUNTER.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("aigitcommit-test-{}-{id}", std::process::id()));
let _ = std::fs::remove_dir_all(&path);
std::fs::create_dir_all(&path).expect("create temp dir");
let raw = _Repo::init(&path).expect("init repo");
let mut config = raw.config().expect("open config");
if let Some(name) = name {
config.set_str("user.name", name).expect("set name");
}
if let Some(email) = email {
config.set_str("user.email", email).expect("set email");
}
drop(config);
drop(raw);
let repo = Repository::new(path.to_str().unwrap()).expect("open repo");
Self { path, repo }
}
}
impl Drop for TempRepo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
#[test]
fn new_rejects_nonexistent_path() {
assert!(Repository::new("/nonexistent/path/should/not/exist").is_err());
}
#[test]
fn get_author_reads_local_identity() {
let tmp = TempRepo::new(Some("Local Dev"), Some("local.dev@example.com"));
let author = tmp.repo.get_author().unwrap();
assert_eq!(author.name, "Local Dev");
assert_eq!(author.email, "local.dev@example.com");
}
#[test]
fn get_author_falls_back_when_email_invalid() {
let tmp = TempRepo::new(Some("Local Dev"), Some("not-an-email"));
let author = tmp.repo.get_author().unwrap();
assert_ne!(author.email, "not-an-email");
assert!(EMAIL_RE.is_match(&author.email));
}
#[test]
fn git_cli_config_returns_none_for_missing_key() {
let tmp = TempRepo::new(None, None);
assert!(tmp.repo.git_cli_config("aigitcommit.nonexistent").is_none());
}
#[test]
fn resolve_identity_trims_and_filters_empty() {
let tmp = TempRepo::new(None, None);
let mut config = tmp.repo.repository.config().unwrap();
config.set_str("user.name", " Padded Name ").unwrap();
let resolved = tmp
.repo
.resolve_identity(&config, "user.name", "GIT_AUTHOR_NAME");
assert_eq!(resolved.as_deref(), Some("Padded Name"));
}
#[test]
fn get_logs_errors_on_unborn_branch() {
let tmp = TempRepo::new(Some("Local Dev"), Some("local.dev@example.com"));
assert!(tmp.repo.get_logs(5).is_err());
}
}