use std::io::BufRead;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::str;
use std::{collections::HashMap, sync::Arc};
use chrono::{DateTime, FixedOffset};
use thiserror::Error;
const GIT_EXEC: &str = "git";
#[derive(Error, Debug)]
pub enum GitMapError {
#[error("IO operation failed: {0}")]
Io(#[from] std::io::Error),
#[error("Git executable not found: {0}")]
GitNotFound(String),
#[error("Git command failed with status {status}: {stderr}")]
GitCommandFailed {
status: std::process::ExitStatus,
stderr: String,
},
#[error("Failed to parse date '{input}': {source}")]
DateParse {
input: String,
#[source]
source: chrono::ParseError,
},
#[error("Invalid UTF-8 in git output: {0}")]
Utf8(#[from] std::string::FromUtf8Error),
#[error("Git log entry malformed: expected {expected} fields, found {found}")]
MalformedLogEntry { expected: usize, found: usize },
#[error("Canonicalization of path '{path}' failed: {source}")]
PathResolution {
path: std::path::PathBuf,
#[source]
source: std::io::Error,
},
}
pub type Result<T> = std::result::Result<T, GitMapError>;
#[derive(Debug, Clone)]
pub struct GitInfo {
pub hash: String,
pub abbreviated_hash: String,
pub subject: String,
pub author_name: String,
pub author_email: String,
pub author_date: DateTime<FixedOffset>,
pub commit_date: DateTime<FixedOffset>,
pub body: String,
}
pub type GitHistory = Vec<Arc<GitInfo>>;
pub type GitMap = HashMap<String, GitHistory>;
#[derive(Debug, Clone)]
pub struct GitRepo {
pub top_level_path: PathBuf,
pub files: GitMap,
}
pub struct Options {
pub repository: PathBuf,
pub revision: String,
pub git_binary: String,
}
impl Default for Options {
fn default() -> Self {
Self {
repository: PathBuf::from("."),
revision: "HEAD".to_string(),
git_binary: GIT_EXEC.to_string(),
}
}
}
impl Options {
pub fn new(revision: impl AsRef<str>) -> Self {
Self {
revision: revision.as_ref().to_string(),
..Default::default()
}
}
}
pub fn map(opts: Options) -> Result<GitRepo> {
let repo_path = opts
.repository
.canonicalize()
.map_err(|e| GitMapError::PathResolution {
path: opts.repository.clone(),
source: e,
})?;
let top_level_path = find_top_level(&opts.git_binary, &repo_path)?;
let mut child = Command::new(&opts.git_binary)
.args([
"-c",
"diff.renames=0",
"-c",
"log.showSignature=0",
"-C",
repo_path.to_str().unwrap_or("."),
"log",
"--name-only",
"--no-merges",
"--format=format:%x1e%H%x1f%h%x1f%s%x1f%aN%x1f%aE%x1f%ai%x1f%ci%x1f%b%x1d",
&opts.revision,
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|_| GitMapError::GitNotFound(opts.git_binary.clone()))?;
let stdout = child.stdout.take().ok_or_else(|| {
std::io::Error::new(std::io::ErrorKind::BrokenPipe, "Could not capture stdout")
})?;
let mut reader = std::io::BufReader::new(stdout);
let mut buffer = Vec::new();
let mut map: GitMap = HashMap::new();
reader.read_until(b'\x1e', &mut buffer)?;
buffer.clear();
loop {
let bytes_read = reader.read_until(b'\x1e', &mut buffer)?;
if bytes_read == 0 {
break;
}
let slice = if buffer.ends_with(&[0x1e]) {
&buffer[..buffer.len() - 1]
} else {
&buffer[..]
};
if let Err(e) = parse_entry(slice, &mut map) {
eprintln!("Skipping malformed entry: {}", e);
}
buffer.clear();
}
let output = child.wait_with_output()?;
if !output.status.success() {
return Err(GitMapError::GitCommandFailed {
status: output.status,
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
Ok(GitRepo {
top_level_path: PathBuf::from(top_level_path),
files: map,
})
}
fn find_top_level(binary: &str, path: &Path) -> Result<String> {
let output = Command::new(binary)
.arg("-C")
.arg(path)
.args(["rev-parse", "--show-toplevel"])
.output()
.map_err(|_| GitMapError::GitNotFound(binary.to_string()))?;
if !output.status.success() {
return Err(GitMapError::GitCommandFailed {
status: output.status,
stderr: String::from_utf8_lossy(&output.stderr).to_string(),
});
}
Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
fn parse_entry(raw: &[u8], map: &mut GitMap) -> Result<()> {
let s = String::from_utf8_lossy(raw);
let parts: Vec<&str> = s.split('\x1d').collect();
if parts.len() < 2 {
return Ok(());
}
let meta_str = parts[0];
let files_str = parts[1];
let info = Arc::new(parse_git_info(meta_str)?);
for line in files_str.lines() {
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
map.entry(String::from(trimmed))
.or_default()
.push(Arc::clone(&info));
}
Ok(())
}
fn parse_git_info(entry: &str) -> Result<GitInfo> {
let items: Vec<&str> = entry.split('\x1f').collect();
if items.len() < 8 {
return Err(GitMapError::MalformedLogEntry {
expected: 8,
found: items.len(),
});
}
Ok(GitInfo {
hash: items[0].to_string(),
abbreviated_hash: items[1].to_string(),
subject: items[2].to_string(),
author_name: items[3].to_string(),
author_email: items[4].to_string(),
author_date: parse_date(items[5])?,
commit_date: parse_date(items[6])?,
body: items[7].trim().to_string(),
})
}
fn parse_date(date_str: &str) -> Result<DateTime<FixedOffset>> {
DateTime::parse_from_str(date_str, "%Y-%m-%d %H:%M:%S %z").map_err(|e| GitMapError::DateParse {
input: date_str.to_string(),
source: e,
})
}