use std::path::Path;
use std::process::Command;
use anyhow::{Context, Result};
#[derive(Debug, Clone, PartialEq)]
pub struct ChangedFile {
pub path: String,
pub added: Vec<(usize, String)>,
}
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DiffTarget {
WorkingTree,
Staged,
Base(String),
}
pub fn changed_files(repo_root: &Path, target: &DiffTarget) -> Result<Vec<ChangedFile>> {
let mut args = vec![
"-C".to_string(),
repo_root.to_string_lossy().into_owned(),
"diff".to_string(),
"--unified=0".to_string(),
"--no-color".to_string(),
];
match target {
DiffTarget::WorkingTree => {}
DiffTarget::Staged => args.push("--cached".to_string()),
DiffTarget::Base(base) => args.push(base.clone()),
}
let output = Command::new("git")
.args(&args)
.output()
.context("failed to run `git` (is it installed and on PATH?)")?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("git diff failed: {}", stderr.trim());
}
let text = String::from_utf8_lossy(&output.stdout);
Ok(parse_unified_diff(&text))
}
pub fn parse_unified_diff(diff: &str) -> Vec<ChangedFile> {
let mut files: Vec<ChangedFile> = Vec::new();
let mut current: Option<ChangedFile> = None;
let mut new_line = 0usize;
for line in diff.lines() {
if let Some(path) = line.strip_prefix("+++ ") {
if let Some(f) = current.take() {
if !f.added.is_empty() {
files.push(f);
}
}
if path == "/dev/null" {
current = None;
} else {
let clean = path
.strip_prefix("b/")
.unwrap_or(path)
.split('\t')
.next()
.unwrap_or(path)
.to_string();
current = Some(ChangedFile {
path: clean,
added: Vec::new(),
});
}
continue;
}
if line.starts_with("--- ") || line.starts_with("diff --git") {
continue;
}
if let Some(rest) = line.strip_prefix("@@") {
new_line = parse_hunk_new_start(rest).unwrap_or(new_line);
continue;
}
let Some(file) = current.as_mut() else {
continue;
};
if let Some(added) = line.strip_prefix('+') {
file.added.push((new_line, added.to_string()));
new_line += 1;
} else if line.starts_with('-') {
} else if line.starts_with(' ') {
new_line += 1;
}
}
if let Some(f) = current.take() {
if !f.added.is_empty() {
files.push(f);
}
}
files
}
fn parse_hunk_new_start(rest: &str) -> Option<usize> {
let plus = rest.split('+').nth(1)?;
let num: String = plus.chars().take_while(|c| c.is_ascii_digit()).collect();
num.parse().ok()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_added_lines_with_line_numbers() {
let diff = "\
diff --git a/src/pay.rs b/src/pay.rs
index 111..222 100644
--- a/src/pay.rs
+++ b/src/pay.rs
@@ -10,0 +11,2 @@ fn withdraw() {
+ balance -= amount;
+ save(balance);
@@ -20,1 +22,1 @@ fn other() {
+ let x = 1;
";
let files = parse_unified_diff(diff);
assert_eq!(files.len(), 1);
assert_eq!(files[0].path, "src/pay.rs");
assert_eq!(
files[0].added,
vec![
(11, " balance -= amount;".to_string()),
(12, " save(balance);".to_string()),
(22, " let x = 1;".to_string()),
]
);
}
#[test]
fn skips_deleted_files() {
let diff = "\
diff --git a/gone.rs b/gone.rs
--- a/gone.rs
+++ /dev/null
@@ -1,2 +0,0 @@
-old line
-another
";
assert!(parse_unified_diff(diff).is_empty());
}
#[test]
fn handles_multiple_files() {
let diff = "\
--- a/a.rs
+++ b/a.rs
@@ -0,0 +1 @@
+fn a() {}
--- a/b.rs
+++ b/b.rs
@@ -0,0 +1 @@
+fn b() {}
";
let files = parse_unified_diff(diff);
assert_eq!(files.len(), 2);
assert_eq!(files[0].path, "a.rs");
assert_eq!(files[1].path, "b.rs");
}
}