use git2::{Oid, Repository as _Repo, RepositoryOpenFlags, Signature};
use regex::Regex;
use std::error::Error;
use std::fmt::{Display, Formatter};
use tracing::{trace, warn};
use crate::git::message::GitMessage;
use crate::utils::env;
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 repository = _Repo::open_ext(path, RepositoryOpenFlags::empty(), vec![path])?;
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 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 Regex::new(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
.unwrap()
.is_match(&email)
{
email
} else {
warn!("invalid email format: {}, using default", UNKNOWN_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) => {
let head_commit = head_ref.peel_to_commit()?;
Some(head_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 excluded_files = Self::get_excluded_files();
let mut result = Vec::new();
diff.print(git2::DiffFormat::Patch, |delta, _hunk, line| {
if let Some(path) = delta.new_file().path()
&& let Some(filename) = path.file_name()
&& excluded_files.contains(&filename.to_string_lossy().as_ref())
{
warn!("skipping excluded file: {}", filename.to_string_lossy());
return true; }
let content = String::from_utf8_lossy(line.content()).trim().to_string();
if !content.is_empty() {
result.push(content);
}
true
})?;
Ok(result)
}
pub fn should_signoff(&self) -> bool {
const SIGNOFF_KEY: &str = "aigitcommit.signoff";
if let Ok(config) = self.repository.config() {
let signoff = config.get_bool(SIGNOFF_KEY).unwrap_or(false);
trace!("✍️ git config signoff: {}", signoff);
return signoff;
}
env::get_bool("AIGITCOMMIT_SIGNOFF")
}
fn get_excluded_files() -> Vec<&'static str> {
vec![
"go.mod",
"go.sum",
"Cargo.lock",
"package-lock.json",
"yarn.lock",
"pnpm-lock.yaml",
]
}
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| match oid_result {
Ok(oid) => self.repository.find_commit(oid).ok(),
Err(e) => {
warn!("failed to get commit OID: {}", e);
None
}
})
.filter_map(|commit| {
let msg = commit.message().unwrap_or("");
let trimmed = msg.trim();
if trimmed.is_empty() {
None
} else {
Some(trimmed.to_string())
}
})
.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);
}
}