use std::path::{Path, PathBuf};
use git2::{Commit, ObjectType, Repository};
use crate::error::SaraError;
use crate::model::Item;
use crate::parser::InputFormat;
#[derive(Debug, Clone)]
pub enum GitRef {
Head,
Commit(String),
Branch(String),
Tag(String),
}
impl GitRef {
pub fn parse(s: &str) -> Self {
let s = s.trim();
if s.eq_ignore_ascii_case("head") {
GitRef::Head
} else if s.starts_with("refs/heads/") {
GitRef::Branch(s.trim_start_matches("refs/heads/").to_string())
} else if s.starts_with("refs/tags/") {
GitRef::Tag(s.trim_start_matches("refs/tags/").to_string())
} else if s.len() >= 7 && s.chars().all(|c| c.is_ascii_hexdigit()) {
GitRef::Commit(s.to_string())
} else {
GitRef::Branch(s.to_string())
}
}
}
pub struct GitReader {
repo: Repository,
repo_path: PathBuf,
}
impl GitReader {
pub fn open(path: &Path) -> Result<Self, SaraError> {
let repo = Repository::open(path)?;
let repo_path = path.to_path_buf();
Ok(Self { repo, repo_path })
}
pub fn discover(path: &Path) -> Result<Self, SaraError> {
let repo = Repository::discover(path)?;
let repo_path = repo
.workdir()
.ok_or_else(|| SaraError::Git("Bare repository not supported".to_string()))?
.to_path_buf();
Ok(Self { repo, repo_path })
}
pub fn repo_path(&self) -> &Path {
&self.repo_path
}
pub fn resolve_ref(&self, git_ref: &GitRef) -> Result<Commit<'_>, SaraError> {
match git_ref {
GitRef::Head => {
let head = self.repo.head()?;
Ok(head.peel_to_commit()?)
}
GitRef::Commit(sha) => {
let obj = self.repo.revparse_single(sha)?;
Ok(obj.peel_to_commit()?)
}
GitRef::Branch(name) => {
let branch = self.repo.find_branch(name, git2::BranchType::Local)?;
Ok(branch.get().peel_to_commit()?)
}
GitRef::Tag(name) => {
let tag_ref = format!("refs/tags/{}", name);
let obj = self.repo.revparse_single(&tag_ref)?;
Ok(obj.peel_to_commit()?)
}
}
}
pub fn read_file(&self, commit: &Commit<'_>, path: &Path) -> Result<String, SaraError> {
let tree = commit.tree()?;
let entry = tree.get_path(path)?;
let blob = entry.to_object(&self.repo)?.peel_to_blob()?;
String::from_utf8(blob.content().to_vec())
.map_err(|e| SaraError::Git(format!("Invalid UTF-8 in file: {}", e)))
}
pub fn list_markdown_files(&self, commit: &Commit<'_>) -> Result<Vec<PathBuf>, SaraError> {
let tree = commit.tree()?;
let mut files = Vec::new();
self.walk_tree(&tree, PathBuf::new(), &mut files)?;
Ok(files)
}
fn walk_tree(
&self,
tree: &git2::Tree<'_>,
prefix: PathBuf,
files: &mut Vec<PathBuf>,
) -> Result<(), SaraError> {
for entry in tree.iter() {
let name = entry
.name()
.ok_or_else(|| SaraError::Git("Invalid file name".to_string()))?;
if name.starts_with('.') {
continue;
}
let path = prefix.join(name);
match entry.kind() {
Some(ObjectType::Blob) => {
if name.ends_with(".md") || name.ends_with(".markdown") {
files.push(path);
}
}
Some(ObjectType::Tree) => {
let subtree = entry.to_object(&self.repo)?.peel_to_tree()?;
self.walk_tree(&subtree, path, files)?;
}
_ => {}
}
}
Ok(())
}
pub fn parse_commit(&self, git_ref: &GitRef) -> Result<Vec<Item>, SaraError> {
let commit = self.resolve_ref(git_ref)?;
let files = self.list_markdown_files(&commit)?;
let mut items = Vec::new();
let mut parse_errors = Vec::new();
for file_path in files {
let content = match self.read_file(&commit, &file_path) {
Ok(c) => c,
Err(e) => {
tracing::warn!("Failed to read {}: {}", file_path.display(), e);
continue;
}
};
if !crate::parser::has_frontmatter(&content) {
continue;
}
match crate::parser::parse_metadata(
&content,
&file_path,
&self.repo_path,
InputFormat::Markdown,
) {
Ok(item) => items.push(item),
Err(e) => {
tracing::warn!("Failed to parse {}: {}", file_path.display(), e);
parse_errors.push(e);
}
}
}
if !parse_errors.is_empty() && items.is_empty() {
return Err(parse_errors.remove(0));
}
Ok(items)
}
}
pub fn is_git_repo(path: &Path) -> bool {
Repository::discover(path).is_ok()
}
pub fn get_repo_root(path: &Path) -> Option<PathBuf> {
Repository::discover(path)
.ok()
.and_then(|r| r.workdir().map(|p| p.to_path_buf()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_git_ref_parse_head() {
assert!(matches!(GitRef::parse("HEAD"), GitRef::Head));
assert!(matches!(GitRef::parse("head"), GitRef::Head));
}
#[test]
fn test_git_ref_parse_commit() {
assert!(matches!(GitRef::parse("abc1234"), GitRef::Commit(_)));
assert!(matches!(GitRef::parse("abc123456789"), GitRef::Commit(_)));
}
#[test]
fn test_git_ref_parse_branch() {
assert!(matches!(GitRef::parse("main"), GitRef::Branch(_)));
assert!(matches!(
GitRef::parse("refs/heads/main"),
GitRef::Branch(_)
));
}
#[test]
fn test_git_ref_parse_tag() {
assert!(matches!(GitRef::parse("refs/tags/v1.0"), GitRef::Tag(_)));
}
#[test]
fn test_is_git_repo() {
let current_dir = std::env::current_dir().unwrap();
assert!(is_git_repo(¤t_dir));
}
}