use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
pub fn collect_tracked_paths(root: &Path) -> Option<HashSet<PathBuf>> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["ls-files", "-z"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let mut out = HashSet::new();
for chunk in output.stdout.split(|&b| b == 0) {
if chunk.is_empty() {
continue;
}
let s = std::str::from_utf8(chunk).ok()?;
out.insert(PathBuf::from(s));
}
Some(out)
}
pub fn collect_changed_paths(root: &Path, base: Option<&str>) -> Option<HashSet<PathBuf>> {
let output = match base {
Some(base) => Command::new("git")
.arg("-C")
.arg(root)
.args(["diff", "--name-only", "--relative", "-z"])
.arg(format!("{base}...HEAD"))
.output()
.ok()?,
None => Command::new("git")
.arg("-C")
.arg(root)
.args([
"ls-files",
"--modified",
"--others",
"--exclude-standard",
"-z",
])
.output()
.ok()?,
};
if !output.status.success() {
return None;
}
let mut out = HashSet::new();
for chunk in output.stdout.split(|&b| b == 0) {
if chunk.is_empty() {
continue;
}
let s = std::str::from_utf8(chunk).ok()?;
out.insert(PathBuf::from(s));
}
Some(out)
}
pub fn head_commit_message(root: &Path) -> Option<String> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["log", "-1", "--format=%B"])
.output()
.ok()?;
if !output.status.success() {
return None;
}
let raw = String::from_utf8(output.stdout).ok()?;
Some(raw.trim_end_matches('\n').to_string())
}
#[derive(Debug, Clone)]
pub struct BlameLine {
pub line_number: usize,
pub author_time: SystemTime,
pub content: String,
}
pub fn blame_lines(root: &Path, rel_path: &Path) -> Option<Vec<BlameLine>> {
let output = Command::new("git")
.arg("-C")
.arg(root)
.args(["blame", "--line-porcelain", "--"])
.arg(rel_path)
.output()
.ok()?;
if !output.status.success() {
return None;
}
let text = std::str::from_utf8(&output.stdout).ok()?;
Some(parse_porcelain(text))
}
fn parse_porcelain(text: &str) -> Vec<BlameLine> {
let mut out = Vec::new();
let mut final_line: Option<usize> = None;
let mut author_time: Option<SystemTime> = None;
for line in text.lines() {
if let Some(rest) = line.strip_prefix('\t') {
if let (Some(n), Some(t)) = (final_line.take(), author_time.take()) {
out.push(BlameLine {
line_number: n,
author_time: t,
content: rest.to_string(),
});
}
continue;
}
let mut parts = line.splitn(2, ' ');
let key = parts.next().unwrap_or("");
let value = parts.next().unwrap_or("");
match key {
"author-time" => {
if let Ok(secs) = value.parse::<u64>() {
author_time = Some(UNIX_EPOCH + Duration::from_secs(secs));
}
}
sha if sha.len() == 40 && sha.chars().all(|c| c.is_ascii_hexdigit()) => {
let mut cols = value.split(' ');
let _orig = cols.next();
if let Some(final_str) = cols.next()
&& let Ok(n) = final_str.parse::<usize>()
{
final_line = Some(n);
}
}
_ => {}
}
}
out
}
#[derive(Debug)]
pub struct BlameCache {
root: PathBuf,
inner: Mutex<HashMap<PathBuf, CacheEntry>>,
}
#[derive(Debug, Clone)]
enum CacheEntry {
Ok(Arc<Vec<BlameLine>>),
Failed,
}
impl BlameCache {
pub fn new(root: PathBuf) -> Self {
Self {
root,
inner: Mutex::new(HashMap::new()),
}
}
pub fn get(&self, rel_path: &Path) -> Option<Arc<Vec<BlameLine>>> {
let mut guard = self.inner.lock().expect("blame cache lock poisoned");
if let Some(entry) = guard.get(rel_path) {
return match entry {
CacheEntry::Ok(arc) => Some(Arc::clone(arc)),
CacheEntry::Failed => None,
};
}
let computed = blame_lines(&self.root, rel_path);
if let Some(v) = computed {
let arc = Arc::new(v);
guard.insert(rel_path.to_path_buf(), CacheEntry::Ok(Arc::clone(&arc)));
Some(arc)
} else {
guard.insert(rel_path.to_path_buf(), CacheEntry::Failed);
None
}
}
}
pub fn dir_has_tracked_files<S>(
dir_rel: &Path,
tracked: &std::collections::HashSet<PathBuf, S>,
) -> bool
where
S: std::hash::BuildHasher,
{
tracked.iter().any(|p| p.starts_with(dir_rel))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn collect_returns_none_outside_git() {
let tmp = tempfile::tempdir().unwrap();
let result = collect_tracked_paths(tmp.path());
assert!(result.is_none());
}
#[test]
fn collect_changed_returns_none_outside_git() {
let tmp = tempfile::tempdir().unwrap();
assert!(collect_changed_paths(tmp.path(), None).is_none());
assert!(collect_changed_paths(tmp.path(), Some("main")).is_none());
}
#[test]
fn head_message_returns_none_outside_git() {
let tmp = tempfile::tempdir().unwrap();
assert!(head_commit_message(tmp.path()).is_none());
}
#[test]
fn parse_porcelain_two_lines_two_commits() {
let porcelain = "\
abcd1234abcd1234abcd1234abcd1234abcd1234 1 1 1
author Old Author
author-mail <old@example.com>
author-time 1700000000
author-tz +0000
committer Old Author
committer-mail <old@example.com>
committer-time 1700000000
committer-tz +0000
summary first commit
filename src/main.rs
\told line content
ef01ef01ef01ef01ef01ef01ef01ef01ef01ef01 2 2 1
author New Author
author-mail <new@example.com>
author-time 1750000000
author-tz +0000
committer New Author
committer-mail <new@example.com>
committer-time 1750000000
committer-tz +0000
summary recent commit
filename src/main.rs
\tnew line content
";
let lines = parse_porcelain(porcelain);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0].line_number, 1);
assert_eq!(lines[0].content, "old line content");
assert_eq!(
lines[0].author_time,
UNIX_EPOCH + Duration::from_secs(1_700_000_000)
);
assert_eq!(lines[1].line_number, 2);
assert_eq!(lines[1].content, "new line content");
assert_eq!(
lines[1].author_time,
UNIX_EPOCH + Duration::from_secs(1_750_000_000)
);
}
#[test]
fn parse_porcelain_handles_previous_marker() {
let porcelain = "\
abcd1234abcd1234abcd1234abcd1234abcd1234 5 5 1
author X
author-mail <x@example.com>
author-time 1700000000
author-tz +0000
committer X
committer-mail <x@example.com>
committer-time 1700000000
committer-tz +0000
summary did a thing
previous 1111111111111111111111111111111111111111 src/old.rs
filename src/main.rs
\tline body
";
let lines = parse_porcelain(porcelain);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].line_number, 5);
assert_eq!(lines[0].content, "line body");
}
#[test]
fn parse_porcelain_skips_blocks_missing_metadata() {
let porcelain = "\
abcd1234abcd1234abcd1234abcd1234abcd1234 1 1 1
author X
author-time not-a-number
filename a.rs
\tbroken
ef01ef01ef01ef01ef01ef01ef01ef01ef01ef01 2 2 1
author Y
author-time 1700000000
filename a.rs
\tworks
";
let lines = parse_porcelain(porcelain);
assert_eq!(lines.len(), 1);
assert_eq!(lines[0].content, "works");
}
#[test]
fn blame_lines_returns_none_outside_git() {
let tmp = tempfile::tempdir().unwrap();
let result = blame_lines(tmp.path(), Path::new("missing.rs"));
assert!(result.is_none());
}
#[test]
fn blame_cache_memoises_failure() {
let tmp = tempfile::tempdir().unwrap();
let cache = BlameCache::new(tmp.path().to_path_buf());
assert!(cache.get(Path::new("missing.rs")).is_none());
assert!(cache.get(Path::new("missing.rs")).is_none());
let guard = cache.inner.lock().unwrap();
assert!(matches!(
guard.get(Path::new("missing.rs")),
Some(CacheEntry::Failed)
));
}
#[test]
fn dir_has_tracked_files_walks_prefix() {
let mut set = HashSet::new();
set.insert(PathBuf::from("src/main.rs"));
set.insert(PathBuf::from("README.md"));
assert!(dir_has_tracked_files(Path::new("src"), &set));
assert!(!dir_has_tracked_files(Path::new("target"), &set));
assert!(!dir_has_tracked_files(Path::new("tar"), &set));
}
}