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)
}
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 = config
.get_string("user.email")
.or_else(|_| {
warn!("user.email not configured in git config");
std::env::var("GIT_AUTHOR_EMAIL")
})
.unwrap_or_else(|_| {
warn!("using default email: {}", UNKNOWN_EMAIL);
env::get("GIT_FALLBACK_EMAIL", UNKNOWN_EMAIL)
});
let email = if EMAIL_RE.is_match(&email) {
email
} else {
warn!("invalid email format: {}, using default", email);
env::get("GIT_FALLBACK_EMAIL", UNKNOWN_EMAIL)
};
let name = config
.get_string("user.name")
.or_else(|_| {
warn!("user.name not configured in git config");
std::env::var("GIT_AUTHOR_NAME")
})
.unwrap_or_else(|_| {
warn!("using default name: Unknown User");
"Unknown User".to_string()
});
let name = if name.trim().is_empty() {
warn!("author name is empty, using default: Unknown Author");
env::get("GIT_FALLBACK_NAME", UNKNOWN_AUTHOR)
} else {
name
};
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 tracing::error;
fn setup() -> Result<Repository, Box<dyn Error>> {
let repo_path = std::env::var("TEST_REPO_PATH").unwrap_or(".".to_string());
Repository::new(&repo_path)
}
#[test]
fn test_new() {
if setup().is_err() {
error!("please specify the repository path");
return;
}
assert!(setup().is_ok());
}
#[test]
fn test_get_author() {
let repo = setup().unwrap();
let author = repo.get_author().unwrap();
assert!(!author.name.is_empty());
assert!(!author.email.is_empty());
}
#[test]
fn test_logs() {
let repo = setup();
if repo.is_err() {
error!("please specify the repository path");
return;
}
let logs = repo.unwrap().get_logs(5);
assert!(logs.is_ok());
let log_list = logs.unwrap();
assert!(log_list.len() <= 5);
}
}