use std::collections::HashMap;
use std::path::{Path, PathBuf};
use git2::{Diff, DiffFormat, DiffOptions, Repository, Status};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FileStatus {
Added,
Modified,
Deleted,
Renamed,
Untracked,
}
impl FileStatus {
pub fn letter(self) -> char {
match self {
FileStatus::Added => 'A',
FileStatus::Modified => 'M',
FileStatus::Deleted => 'D',
FileStatus::Renamed => 'R',
FileStatus::Untracked => '?',
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffKind {
Hunk,
Context,
Add,
Del,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub kind: DiffKind,
pub old_lineno: Option<u32>,
pub new_lineno: Option<u32>,
pub content: String,
}
pub struct GitInfo {
repo: Repository,
workdir: PathBuf,
}
impl GitInfo {
pub fn discover(root: &Path) -> Option<Self> {
let repo = Repository::discover(root).ok()?;
let workdir = repo.workdir()?.to_path_buf();
Some(Self { repo, workdir })
}
pub fn statuses(&self) -> HashMap<PathBuf, FileStatus> {
let mut map = HashMap::new();
let mut opts = git2::StatusOptions::new();
opts.include_untracked(true).recurse_untracked_dirs(true);
let Ok(statuses) = self.repo.statuses(Some(&mut opts)) else {
return map;
};
for entry in statuses.iter() {
let Ok(rel) = entry.path() else { continue };
let s = entry.status();
let kind = if s.intersects(Status::WT_NEW) {
FileStatus::Untracked
} else if s.intersects(Status::INDEX_NEW) {
FileStatus::Added
} else if s.intersects(Status::WT_DELETED | Status::INDEX_DELETED) {
FileStatus::Deleted
} else if s.intersects(Status::WT_RENAMED | Status::INDEX_RENAMED) {
FileStatus::Renamed
} else if s.intersects(Status::WT_MODIFIED | Status::INDEX_MODIFIED) {
FileStatus::Modified
} else {
continue;
};
map.insert(self.workdir.join(rel), kind);
}
map
}
pub fn diff_file(&self, path: &Path) -> Option<Vec<DiffLine>> {
let rel = path.strip_prefix(&self.workdir).ok()?;
let head_tree = self
.repo
.head()
.ok()
.and_then(|h| h.peel_to_commit().ok())
.and_then(|c| c.tree().ok());
let mut opts = DiffOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(true)
.pathspec(rel.to_string_lossy().to_string());
let diff = self
.repo
.diff_tree_to_workdir_with_index(head_tree.as_ref(), Some(&mut opts))
.ok()?;
Some(collect_diff_lines(&diff))
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::Command;
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.status()
.expect("run git");
assert!(status.success(), "git {args:?} failed");
}
#[test]
fn detects_modified_file_and_diff() {
let dir = std::env::temp_dir().join(format!("srev_git_test_{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
git(&dir, &["init", "-q"]);
git(&dir, &["config", "user.email", "t@example.com"]);
git(&dir, &["config", "user.name", "tester"]);
std::fs::write(dir.join("a.txt"), "one\ntwo\n").unwrap();
git(&dir, &["add", "."]);
git(&dir, &["commit", "-q", "-m", "init"]);
std::fs::write(dir.join("a.txt"), "one\nTWO\nthree\n").unwrap();
let info = GitInfo::discover(&dir).expect("repo");
let statuses = info.statuses();
let (path, status) = statuses
.iter()
.find(|(p, _)| p.file_name().unwrap() == "a.txt")
.expect("a.txt in statuses");
assert_eq!(*status, FileStatus::Modified);
let diff = info.diff_file(path).expect("diff");
let added: Vec<_> = diff
.iter()
.filter(|l| l.kind == DiffKind::Add)
.map(|l| l.content.as_str())
.collect();
let deleted: Vec<_> = diff
.iter()
.filter(|l| l.kind == DiffKind::Del)
.map(|l| l.content.as_str())
.collect();
assert!(added.contains(&"TWO"), "added={added:?}");
assert!(added.contains(&"three"), "added={added:?}");
assert!(deleted.contains(&"two"), "deleted={deleted:?}");
let _ = std::fs::remove_dir_all(&dir);
}
}
fn collect_diff_lines(diff: &Diff) -> Vec<DiffLine> {
let mut lines = Vec::new();
let _ = diff.print(DiffFormat::Patch, |_delta, _hunk, line| {
let content = String::from_utf8_lossy(line.content())
.trim_end_matches(['\r', '\n'])
.to_string();
let kind = match line.origin() {
'+' => DiffKind::Add,
'-' => DiffKind::Del,
' ' => DiffKind::Context,
'H' => DiffKind::Hunk,
_ => return true,
};
lines.push(DiffLine {
kind,
old_lineno: line.old_lineno(),
new_lineno: line.new_lineno(),
content,
});
true
});
lines
}