use std::collections::{HashMap, HashSet};
use std::error::Error;
use std::ops::ControlFlow;
use std::path::{Path, PathBuf};
use git2::{Delta, Diff, DiffDelta, DiffFindOptions, DiffOptions, Patch, Tree};
use super::GitRepo;
use super::touched::Touched;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ChangeKind {
Added,
Modified,
Deleted,
Renamed,
}
#[derive(Debug, Clone, PartialEq)]
pub struct FileDiffStat {
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub kind: ChangeKind,
pub added: usize,
pub deleted: usize,
pub lines: Vec<usize>,
pub old_lines: Vec<usize>,
pub probe: Vec<(usize, String)>,
}
impl FileDiffStat {
pub fn lines_in(&self, text: &str) -> &[usize] {
let lines: Vec<&str> = text.lines().collect();
let applied = self
.probe
.iter()
.all(|(number, added)| lines.get(number - 1).is_some_and(|l| l.trim_end() == added));
if applied {
&self.lines
} else {
&self.old_lines
}
}
}
#[derive(Default)]
pub struct CoChangeHistory {
pub commits: HashMap<PathBuf, usize>,
pub shared: HashMap<PathBuf, HashMap<PathBuf, usize>>,
pub skipped_commits: usize,
}
impl CoChangeHistory {
fn record(&mut self, paths: &[PathBuf], targets: &HashSet<PathBuf>) {
for target in paths.iter().filter(|p| targets.contains(*p)) {
*self.commits.entry(target.clone()).or_default() += 1;
let shared = self.shared.entry(target.clone()).or_default();
for other in paths.iter().filter(|p| *p != target) {
*shared.entry(other.clone()).or_default() += 1;
}
}
}
}
fn classify(delta: &DiffDelta) -> Option<(ChangeKind, PathBuf, Option<PathBuf>)> {
let old = delta.old_file().path().map(Path::to_path_buf);
let new = delta.new_file().path().map(Path::to_path_buf);
let (kind, path, old_path) = match delta.status() {
Delta::Added | Delta::Untracked | Delta::Copied => (ChangeKind::Added, new, None),
Delta::Modified | Delta::Typechange => (ChangeKind::Modified, new, old),
Delta::Renamed => (ChangeKind::Renamed, new, old),
Delta::Deleted => (ChangeKind::Deleted, old.clone(), old),
_ => return None,
};
Some((kind, path?, old_path))
}
fn line_counts(diff: &Diff, idx: usize) -> Result<(usize, usize, Touched), Box<dyn Error>> {
let Some(patch) = Patch::from_diff(diff, idx)? else {
return Ok((0, 0, Touched::default()));
};
let (_, added, deleted) = patch.line_stats()?;
Ok((added, deleted, Touched::of(&patch)?))
}
fn stats_of(diff: &Diff) -> Result<Vec<FileDiffStat>, Box<dyn Error>> {
let mut stats = Vec::new();
for (idx, delta) in diff.deltas().enumerate() {
let Some((kind, path, old_path)) = classify(&delta) else {
continue;
};
let (added, deleted, touched) = line_counts(diff, idx)?;
stats.push(FileDiffStat {
path,
old_path,
kind,
added,
deleted,
lines: touched.new,
old_lines: touched.old,
probe: touched.probe,
});
}
Ok(stats)
}
pub fn patch_stats(patch: &[u8]) -> Result<Vec<FileDiffStat>, Box<dyn Error>> {
let diff = Diff::from_buffer(patch).map_err(|e| format!("cannot read the patch: {e}"))?;
stats_of(&diff)
}
impl GitRepo {
pub(super) fn workdir_diff(
&self,
tree: &Tree,
untracked_content: bool,
) -> Result<Diff<'_>, Box<dyn Error>> {
let mut opts = DiffOptions::new();
opts.include_untracked(true)
.recurse_untracked_dirs(true)
.show_untracked_content(untracked_content);
let mut diff = self
.repo
.diff_tree_to_workdir_with_index(Some(tree), Some(&mut opts))?;
let mut find = DiffFindOptions::new();
find.renames(true).for_untracked(true);
diff.find_similar(Some(&mut find))?;
Ok(diff)
}
fn commit_of(&self, refspec: &str) -> Result<git2::Commit<'_>, Box<dyn Error>> {
Ok(self
.repo
.revparse_single(refspec)
.map_err(|e| format!("cannot resolve ref '{refspec}': {e}"))?
.peel_to_commit()
.map_err(|e| format!("'{refspec}' is not a commit: {e}"))?)
}
fn merge_base(&self, a: &str, b: &str) -> Result<git2::Commit<'_>, Box<dyn Error>> {
let base = self
.repo
.merge_base(self.commit_of(a)?.id(), self.commit_of(b)?.id())
.map_err(|e| format!("no common ancestor between '{a}' and '{b}': {e}"))?;
Ok(self.repo.find_commit(base)?)
}
pub fn diff_stats_since(&self, refspec: &str) -> Result<Vec<FileDiffStat>, Box<dyn Error>> {
let tree = self.merge_base(refspec, "HEAD")?.tree()?;
stats_of(&self.workdir_diff(&tree, true)?)
}
pub fn diff_stats_between(
&self,
since: &str,
until: &str,
) -> Result<Vec<FileDiffStat>, Box<dyn Error>> {
let base = self.merge_base(since, until)?.tree()?;
let tip = self.commit_of(until)?.tree()?;
let mut diff = self.repo.diff_tree_to_tree(Some(&base), Some(&tip), None)?;
diff.find_similar(Some(DiffFindOptions::new().renames(true)))?;
stats_of(&diff)
}
pub fn has_commit(&self, refspec: &str) -> bool {
self.commit_of(refspec).is_ok()
}
pub fn is_ancestor(&self, ancestor: &str, descendant: &str) -> bool {
let (Ok(ancestor), Ok(descendant)) = (self.commit_of(ancestor), self.commit_of(descendant))
else {
return false;
};
self.repo
.graph_descendant_of(descendant.id(), ancestor.id())
.unwrap_or(false)
}
pub fn co_change_history(
&self,
(since, until): (Option<&str>, &str),
since_ts: Option<i64>,
targets: &HashSet<PathBuf>,
max_files: usize,
) -> Result<CoChangeHistory, Box<dyn Error>> {
let end = match since {
Some(since) => self.merge_base(since, until)?,
None => self.commit_of(until)?,
};
let mut revwalk = self.repo.revwalk()?;
revwalk.push(end.id())?;
let mut history = CoChangeHistory::default();
self.walk(revwalk, since_ts, |commit| {
let paths = self.changed_files(commit)?;
if paths.len() <= max_files {
history.record(&paths, targets);
} else if paths.iter().any(|p| targets.contains(p)) {
history.skipped_commits += 1;
}
Ok(ControlFlow::Continue(()))
})?;
Ok(history)
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn has_file(&self, rel_path: &Path) -> bool {
self.root.join(rel_path).is_file()
}
}