use git2::{Diff, DiffOptions, Oid, Repository};
use std::path::Path;
pub(crate) fn list_commit_files(
path: &Path,
oid_str: &str,
) -> color_eyre::Result<(String, Vec<(String, String)>)> {
let repo = Repository::open(path)?;
let oid = Oid::from_str(oid_str)?;
let commit = repo.find_commit(oid)?;
let message = commit.message().unwrap_or("").trim().to_string();
let tree = commit.tree()?;
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None)?;
let mut files = Vec::new();
for delta in diff.deltas() {
let status = match delta.status() {
git2::Delta::Added => "A",
git2::Delta::Deleted => "D",
git2::Delta::Modified => "M",
git2::Delta::Renamed => "R",
_ => "?",
};
let file_path = delta
.new_file()
.path()
.or_else(|| delta.old_file().path())
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
files.push((status.to_string(), file_path));
}
Ok((message, files))
}
pub(crate) fn commit_file_diff(
path: &Path,
oid_str: &str,
file_path: &str,
) -> color_eyre::Result<String> {
let repo = Repository::open(path)?;
let oid = Oid::from_str(oid_str)?;
let commit = repo.find_commit(oid)?;
let tree = commit.tree()?;
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let mut opts = DiffOptions::new();
opts.pathspec(file_path);
let diff = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), Some(&mut opts))?;
let mut output = String::new();
diff_to_string(&diff, &mut output)?;
if output.is_empty() {
output = "(no diff available)".to_string();
}
Ok(output)
}
use crate::git::graph::DiffStat;
pub(crate) fn batch_diff_stats(
path: &Path,
oids: &[Oid],
) -> color_eyre::Result<Vec<(Oid, DiffStat)>> {
let repo = Repository::open(path)?;
let mut results = Vec::with_capacity(oids.len());
for &oid in oids {
let Ok(commit) = repo.find_commit(oid) else {
continue;
};
let Ok(tree) = commit.tree() else {
continue;
};
let parent_tree = commit.parent(0).ok().and_then(|p| p.tree().ok());
let Ok(diff) = repo.diff_tree_to_tree(parent_tree.as_ref(), Some(&tree), None) else {
continue;
};
let Ok(stats) = diff.stats() else {
continue;
};
results.push((
oid,
DiffStat {
additions: stats.insertions(),
deletions: stats.deletions(),
},
));
}
Ok(results)
}
fn diff_to_string(diff: &Diff<'_>, output: &mut String) -> color_eyre::Result<()> {
diff.print(git2::DiffFormat::Patch, |_delta, _hunk, line| {
let prefix = match line.origin() {
'+' => "+",
'-' => "-",
' ' => " ",
_ => "",
};
output.push_str(prefix);
output.push_str(&String::from_utf8_lossy(line.content()));
true
})?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use git2::{Repository, Signature};
use std::fs;
use tempfile::TempDir;
fn create_repo_with_file(file_name: &str, content: &str) -> (TempDir, Repository, String) {
let tmp = TempDir::new().unwrap();
let repo = Repository::init(tmp.path()).unwrap();
fs::write(tmp.path().join(file_name), content).unwrap();
let oid = {
let mut index = repo.index().unwrap();
index.add_path(Path::new(file_name)).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = Signature::now("Test", "test@test.com").unwrap();
repo.commit(Some("HEAD"), &sig, &sig, "Add file", &tree, &[])
.unwrap()
};
(tmp, repo, oid.to_string())
}
#[test]
fn test_list_commit_files_on_known_commit() {
let (tmp, repo, first_oid) = create_repo_with_file("hello.txt", "hello");
fs::write(tmp.path().join("hello.txt"), "world").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("hello.txt")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = Signature::now("Test", "test@test.com").unwrap();
let parent = repo
.find_commit(git2::Oid::from_str(&first_oid).unwrap())
.unwrap();
let oid2 = repo
.commit(Some("HEAD"), &sig, &sig, "Modify file", &tree, &[&parent])
.unwrap();
let (message, files) = list_commit_files(tmp.path(), &oid2.to_string()).unwrap();
assert_eq!(message, "Modify file");
assert_eq!(files.len(), 1);
assert_eq!(files[0].0, "M");
assert_eq!(files[0].1, "hello.txt");
}
#[test]
fn test_root_commit_lists_files() {
let (tmp, _repo, oid) = create_repo_with_file("root.txt", "content");
let (message, files) = list_commit_files(tmp.path(), &oid).unwrap();
assert_eq!(message, "Add file");
assert_eq!(files.len(), 1);
assert_eq!(files[0].0, "A");
assert_eq!(files[0].1, "root.txt");
}
#[test]
fn test_batch_diff_stats_returns_additions() {
let (tmp, repo, first_oid) = create_repo_with_file("file.txt", "line1\n");
fs::write(tmp.path().join("file.txt"), "line1\nline2\nline3\n").unwrap();
let mut index = repo.index().unwrap();
index.add_path(Path::new("file.txt")).unwrap();
index.write().unwrap();
let tree_id = index.write_tree().unwrap();
let tree = repo.find_tree(tree_id).unwrap();
let sig = Signature::now("Test", "test@test.com").unwrap();
let parent = repo
.find_commit(Oid::from_str(&first_oid).unwrap())
.unwrap();
let oid2 = repo
.commit(Some("HEAD"), &sig, &sig, "Add lines", &tree, &[&parent])
.unwrap();
let stats = batch_diff_stats(tmp.path(), &[oid2]).unwrap();
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].0, oid2);
assert!(
stats[0].1.additions > 0,
"expected additions, got {}",
stats[0].1.additions
);
}
#[test]
fn test_batch_diff_stats_root_commit() {
let (tmp, _repo, oid_str) = create_repo_with_file("root.txt", "content\n");
let oid = Oid::from_str(&oid_str).unwrap();
let stats = batch_diff_stats(tmp.path(), &[oid]).unwrap();
assert_eq!(stats.len(), 1);
assert_eq!(stats[0].0, oid);
assert!(stats[0].1.additions > 0);
assert_eq!(stats[0].1.deletions, 0);
}
}