use std::path::{Path, PathBuf};
use std::process::Command;
use serde::{Deserialize, Serialize};
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum FileStatus {
Added,
Modified,
Deleted,
Renamed,
Copied,
TypeChanged,
Unknown,
}
impl FileStatus {
fn from_code(code: char) -> Self {
match code {
'A' => FileStatus::Added,
'M' => FileStatus::Modified,
'D' => FileStatus::Deleted,
'R' => FileStatus::Renamed,
'C' => FileStatus::Copied,
'T' => FileStatus::TypeChanged,
_ => FileStatus::Unknown,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiffHunk {
pub old_start: u32,
pub old_lines: u32,
pub new_start: u32,
pub new_lines: u32,
pub body: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FileDiff {
pub path: PathBuf,
pub old_path: Option<PathBuf>,
pub status: FileStatus,
pub hunks: Vec<DiffHunk>,
pub binary: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Diff {
pub range: Option<String>,
pub files: Vec<FileDiff>,
}
impl Diff {
pub fn is_empty(&self) -> bool {
self.files.is_empty()
}
pub fn changed_paths(&self) -> impl Iterator<Item = &Path> {
self.files.iter().map(|f| f.path.as_path())
}
}
pub fn git_diff(root: &Path, range: Option<&str>) -> Result<Diff> {
let name_status = run_git(root, &mk_name_status_args(range))?;
let files = parse_name_status(&name_status);
if files.is_empty() {
return Ok(Diff {
range: range.map(str::to_owned),
files: Vec::new(),
});
}
let patch = run_git(root, &mk_patch_args(range))?;
let hunks_by_path = parse_patch(&patch);
let files = files
.into_iter()
.map(|mut f| {
if let Some(hunks) = hunks_by_path.get(&f.path) {
f.hunks = hunks.clone();
}
f
})
.collect();
Ok(Diff {
range: range.map(str::to_owned),
files,
})
}
fn mk_name_status_args(range: Option<&str>) -> Vec<String> {
let mut args = vec![
"diff".into(),
"--name-status".into(),
"--find-renames".into(),
"-z".into(),
];
match range {
Some(r) => args.push(r.into()),
None => args.push("HEAD".into()),
}
args
}
fn mk_patch_args(range: Option<&str>) -> Vec<String> {
let mut args = vec![
"diff".into(),
"--no-color".into(),
"--find-renames".into(),
"--unified=3".into(),
];
match range {
Some(r) => args.push(r.into()),
None => args.push("HEAD".into()),
}
args
}
fn run_git(root: &Path, args: &[String]) -> Result<String> {
let output = Command::new("git")
.current_dir(root)
.args(args)
.output()
.map_err(|e| Error::Tool(format!("failed to spawn git: {e}")))?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
let lower = stderr.to_lowercase();
if lower.contains("not a git repository")
|| lower.contains("unknown revision")
|| lower.contains("use --no-index")
{
return Ok(String::new());
}
return Err(Error::Tool(format!(
"git {:?} failed: {}",
args,
stderr.trim()
)));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
fn parse_name_status(raw: &str) -> Vec<FileDiff> {
let mut out = Vec::new();
let mut iter = raw.split('\0').filter(|s| !s.is_empty());
while let Some(status_str) = iter.next() {
let code = status_str.chars().next().unwrap_or('?');
let status = FileStatus::from_code(code);
let first_path = match iter.next() {
Some(p) => p,
None => break,
};
let (path, old_path) = match status {
FileStatus::Renamed | FileStatus::Copied => {
let new_path = match iter.next() {
Some(p) => PathBuf::from(p),
None => break,
};
(new_path, Some(PathBuf::from(first_path)))
}
_ => (PathBuf::from(first_path), None),
};
out.push(FileDiff {
path,
old_path,
status,
hunks: Vec::new(),
binary: false,
});
}
out
}
fn parse_patch(raw: &str) -> std::collections::HashMap<PathBuf, Vec<DiffHunk>> {
let mut map: std::collections::HashMap<PathBuf, Vec<DiffHunk>> =
std::collections::HashMap::new();
let mut current_path: Option<PathBuf> = None;
let mut current_hunk: Option<DiffHunk> = None;
for line in raw.lines() {
if let Some(rest) = line.strip_prefix("diff --git ") {
if let (Some(path), Some(hunk)) = (current_path.clone(), current_hunk.take()) {
map.entry(path).or_default().push(hunk);
}
current_path = parse_b_path(rest);
continue;
}
if line.starts_with("Binary files ") {
continue;
}
if let Some(rest) = line.strip_prefix("@@ ") {
if let (Some(path), Some(hunk)) = (current_path.clone(), current_hunk.take()) {
map.entry(path).or_default().push(hunk);
}
if let Some(h) = parse_hunk_header(rest) {
current_hunk = Some(h);
}
continue;
}
if let Some(h) = current_hunk.as_mut() {
if line.starts_with("--- ") || line.starts_with("+++ ") {
continue;
}
h.body.push_str(line);
h.body.push('\n');
}
}
if let (Some(path), Some(hunk)) = (current_path, current_hunk) {
map.entry(path).or_default().push(hunk);
}
map
}
fn parse_b_path(header_rest: &str) -> Option<PathBuf> {
let (_, b) = header_rest.rsplit_once(" b/")?;
Some(PathBuf::from(b))
}
fn parse_hunk_header(rest: &str) -> Option<DiffHunk> {
let (ranges, _) = rest.split_once(" @@").unwrap_or((rest, ""));
let mut parts = ranges.split_whitespace();
let old = parts.next()?.strip_prefix('-')?;
let new = parts.next()?.strip_prefix('+')?;
let (old_start, old_lines) = parse_range(old);
let (new_start, new_lines) = parse_range(new);
Some(DiffHunk {
old_start,
old_lines,
new_start,
new_lines,
body: String::new(),
})
}
fn parse_range(s: &str) -> (u32, u32) {
match s.split_once(',') {
Some((a, b)) => (a.parse().unwrap_or(0), b.parse().unwrap_or(1)),
None => (s.parse().unwrap_or(0), 1),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_name_status_simple() {
let raw = "M\0src/foo.rs\0A\0src/bar.rs\0";
let v = parse_name_status(raw);
assert_eq!(v.len(), 2);
assert_eq!(v[0].path, PathBuf::from("src/foo.rs"));
assert_eq!(v[0].status, FileStatus::Modified);
assert_eq!(v[1].status, FileStatus::Added);
}
#[test]
fn parses_name_status_rename() {
let raw = "R100\0old.rs\0new.rs\0";
let v = parse_name_status(raw);
assert_eq!(v.len(), 1);
assert_eq!(v[0].status, FileStatus::Renamed);
assert_eq!(v[0].path, PathBuf::from("new.rs"));
assert_eq!(v[0].old_path.as_deref(), Some(Path::new("old.rs")));
}
#[test]
fn parses_hunk_header() {
let h = parse_hunk_header("-5,7 +10,9 @@ fn foo").unwrap();
assert_eq!(h.old_start, 5);
assert_eq!(h.old_lines, 7);
assert_eq!(h.new_start, 10);
assert_eq!(h.new_lines, 9);
}
#[test]
fn parses_hunk_header_single_line() {
let h = parse_hunk_header("-5 +5 @@").unwrap();
assert_eq!(h.old_lines, 1);
assert_eq!(h.new_lines, 1);
}
#[test]
fn parses_b_path() {
let p = parse_b_path("a/src/foo.rs b/src/foo.rs").unwrap();
assert_eq!(p, PathBuf::from("src/foo.rs"));
}
#[test]
fn parses_patch_with_multiple_files() {
let raw = "\
diff --git a/a.rs b/a.rs
--- a/a.rs
+++ b/a.rs
@@ -1,2 +1,3 @@
keep
+added
keep
diff --git a/b.rs b/b.rs
--- a/b.rs
+++ b/b.rs
@@ -5,1 +5,1 @@
-old
+new
";
let map = parse_patch(raw);
assert_eq!(map.len(), 2);
assert_eq!(map[&PathBuf::from("a.rs")].len(), 1);
assert_eq!(map[&PathBuf::from("b.rs")].len(), 1);
assert_eq!(map[&PathBuf::from("a.rs")][0].new_lines, 3);
}
#[test]
fn git_diff_returns_empty_outside_repo() {
let tmp = tempfile::tempdir().unwrap();
let d = git_diff(tmp.path(), None).unwrap();
assert!(d.is_empty());
}
}