use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SignKind {
Added,
Modified,
Removed,
}
pub type LineSigns = HashMap<PathBuf, Vec<(usize, SignKind)>>;
pub fn line_signs(workspace: &Path) -> LineSigns {
let Ok(out) = Command::new("git")
.args(["diff", "HEAD", "--unified=0", "--no-color", "--", "."])
.current_dir(workspace)
.output()
else {
return LineSigns::new();
};
if !out.status.success() {
return LineSigns::new();
}
parse(&String::from_utf8_lossy(&out.stdout), workspace)
}
fn flush(signs: &mut LineSigns, path: &mut Option<PathBuf>, cur: &mut Vec<(usize, SignKind)>) {
if let Some(p) = path.take() {
let mut v = std::mem::take(cur);
v.sort_unstable_by_key(|&(l, _)| l);
v.dedup();
if !v.is_empty() {
signs.insert(p, v);
}
} else {
cur.clear();
}
}
fn parse(diff: &str, workspace: &Path) -> LineSigns {
let mut signs: LineSigns = HashMap::new();
let mut cur: Vec<(usize, SignKind)> = Vec::new();
let mut cur_path: Option<PathBuf> = None;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("+++ ") {
flush(&mut signs, &mut cur_path, &mut cur);
cur_path = if rest == "/dev/null" {
None
} else {
Some(workspace.join(rest.strip_prefix("b/").unwrap_or(rest)))
};
} else if cur_path.is_some()
&& let Some(rest) = line.strip_prefix("@@ ")
{
let Some(((_old_start, old_count), (new_start, new_count))) = parse_hunk_header(rest)
else {
continue;
};
if new_count == 0 {
let l = new_start.saturating_sub(1).max(1) - 1;
cur.push((l, SignKind::Removed));
} else {
let kind = if old_count == 0 {
SignKind::Added
} else {
SignKind::Modified
};
for n in 0..new_count {
cur.push((new_start.saturating_sub(1) + n, kind));
}
}
}
}
flush(&mut signs, &mut cur_path, &mut cur);
signs
}
pub fn parse_hunk_header(s: &str) -> Option<((usize, usize), (usize, usize))> {
let mut parts = s.split_whitespace();
let minus = parts.next()?.strip_prefix('-')?;
let plus = parts.next()?.strip_prefix('+')?;
let pair = |t: &str| -> Option<(usize, usize)> {
match t.split_once(',') {
Some((a, b)) => Some((a.parse().ok()?, b.parse().ok()?)),
None => Some((t.parse().ok()?, 1)),
}
};
Some((pair(minus)?, pair(plus)?))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum HunkLine {
Context(String),
Added(String),
Removed(String),
NoNewline,
}
#[derive(Debug, Clone)]
pub struct Hunk {
pub file: PathBuf,
pub file_rel: String,
pub header: String,
pub new_start: usize,
pub lines: Vec<HunkLine>,
pub body: String,
}
impl Hunk {
pub fn patch(&self) -> String {
format!("--- a/{0}\n+++ b/{0}\n{1}", self.file_rel, self.body)
}
pub fn new_line_count(&self) -> usize {
self.lines
.iter()
.filter(|l| matches!(l, HunkLine::Context(_) | HunkLine::Added(_)))
.count()
}
pub fn contains_new_line(&self, line_0based: usize) -> bool {
let start = self.new_start.saturating_sub(1);
let count = self.new_line_count();
if count == 0 {
return line_0based == start;
}
line_0based >= start && line_0based < start + count
}
}
pub fn intraline_diff(old: &str, new: &str) -> ((usize, usize), (usize, usize)) {
let o: Vec<char> = old.chars().collect();
let n: Vec<char> = new.chars().collect();
let mut p = 0;
while p < o.len() && p < n.len() && o[p] == n[p] {
p += 1;
}
let mut s = 0;
while s < o.len() - p && s < n.len() - p && o[o.len() - 1 - s] == n[n.len() - 1 - s] {
s += 1;
}
((p, o.len() - s), (p, n.len() - s))
}
pub fn peek_hunk_at(workspace: &Path, rel: &str, line_0based: usize) -> Option<Hunk> {
let hunks = run_diff(workspace, &["diff", "HEAD", "--no-color", "--", rel]);
hunks.into_iter().find(|h| h.contains_new_line(line_0based))
}
pub fn diff_file(workspace: &Path, rel: &str) -> Vec<Hunk> {
run_diff(workspace, &["diff", "--no-color", "--", rel])
}
pub fn diff_worktree(workspace: &Path) -> Vec<Hunk> {
run_diff(workspace, &["diff", "--no-color"])
}
pub fn diff_vs_head(workspace: &Path) -> Vec<Hunk> {
run_diff(workspace, &["diff", "HEAD", "--no-color"])
}
pub fn diff_staged(workspace: &Path) -> Vec<Hunk> {
run_diff(workspace, &["diff", "--no-color", "--cached"])
}
pub fn diff_staged_file(workspace: &Path, rel: &str) -> Vec<Hunk> {
run_diff(workspace, &["diff", "--no-color", "--cached", "--", rel])
}
pub fn diff_staged_file_full(workspace: &Path, rel: &str) -> Vec<Hunk> {
run_diff(
workspace,
&["diff", "--no-color", "--cached", "-U99999", "--", rel],
)
}
pub fn show_commit(workspace: &Path, hash: &str) -> Vec<Hunk> {
run_diff(workspace, &["show", "--no-color", "--format=", hash])
}
pub fn show_commit_file(workspace: &Path, hash: &str, rel_path: &str) -> Vec<Hunk> {
run_diff(
workspace,
&["show", "--no-color", "--format=", hash, "--", rel_path],
)
}
pub fn diff_file_full(workspace: &Path, rel: &str) -> Vec<Hunk> {
run_diff(workspace, &["diff", "--no-color", "-U99999", "--", rel])
}
pub fn diff_worktree_full(workspace: &Path) -> Vec<Hunk> {
run_diff(workspace, &["diff", "--no-color", "-U99999"])
}
pub fn diff_vs_head_full(workspace: &Path) -> Vec<Hunk> {
run_diff(workspace, &["diff", "HEAD", "--no-color", "-U99999"])
}
pub fn diff_staged_full(workspace: &Path) -> Vec<Hunk> {
run_diff(workspace, &["diff", "--no-color", "--cached", "-U99999"])
}
pub fn show_commit_full(workspace: &Path, hash: &str) -> Vec<Hunk> {
run_diff(
workspace,
&["show", "--no-color", "--format=", "-U99999", hash],
)
}
pub fn show_commit_file_full(workspace: &Path, hash: &str, rel_path: &str) -> Vec<Hunk> {
run_diff(
workspace,
&[
"show",
"--no-color",
"--format=",
"-U99999",
hash,
"--",
rel_path,
],
)
}
fn run_diff(workspace: &Path, args: &[&str]) -> Vec<Hunk> {
let Ok(out) = Command::new("git")
.args(args)
.current_dir(workspace)
.output()
else {
return Vec::new();
};
if !out.status.success() {
return Vec::new();
}
parse_hunks(&String::from_utf8_lossy(&out.stdout), workspace)
}
pub fn discard_hunk(workspace: &Path, hunk: &Hunk) -> Result<(), String> {
use std::io::Write;
let args = ["apply", "--unidiff-zero", "--reverse", "-"];
let mut child = Command::new("git")
.args(args)
.current_dir(workspace)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("spawn git apply: {e}"))?;
child
.stdin
.take()
.ok_or("no stdin")?
.write_all(hunk.patch().as_bytes())
.map_err(|e| format!("write patch: {e}"))?;
let out = child
.wait_with_output()
.map_err(|e| format!("git apply: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
pub fn apply_hunk(workspace: &Path, hunk: &Hunk, reverse: bool) -> Result<(), String> {
use std::io::Write;
let mut args = vec!["apply", "--cached", "--unidiff-zero"];
if reverse {
args.push("--reverse");
}
args.push("-");
let mut child = Command::new("git")
.args(&args)
.current_dir(workspace)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
.map_err(|e| format!("spawn git apply: {e}"))?;
child
.stdin
.take()
.ok_or("no stdin")?
.write_all(hunk.patch().as_bytes())
.map_err(|e| format!("write patch: {e}"))?;
let out = child
.wait_with_output()
.map_err(|e| format!("git apply: {e}"))?;
if out.status.success() {
Ok(())
} else {
Err(String::from_utf8_lossy(&out.stderr).trim().to_string())
}
}
pub fn parse_hunks(diff: &str, workspace: &Path) -> Vec<Hunk> {
let mut starts = vec![0usize];
for (i, b) in diff.bytes().enumerate() {
if b == b'\n' {
starts.push(i + 1);
}
}
let line_at = |k: usize| -> &str {
let a = starts[k];
let b = starts.get(k + 1).copied().unwrap_or(diff.len());
&diff[a..b]
};
let n = starts.len();
let mut hunks: Vec<Hunk> = Vec::new();
let mut file_rel: Option<String> = None;
let mut open: Option<(String, String, usize, usize, Vec<HunkLine>)> = None;
let flush = |hunks: &mut Vec<Hunk>,
open: &mut Option<(String, String, usize, usize, Vec<HunkLine>)>,
end_line: usize| {
if let Some((rel, header, new_start, start_k, lines)) = open.take() {
let body = diff[starts[start_k]..starts.get(end_line).copied().unwrap_or(diff.len())]
.to_string();
hunks.push(Hunk {
file: workspace.join(&rel),
file_rel: rel,
header,
new_start,
lines,
body,
});
}
};
for k in 0..n {
let line = line_at(k).trim_end_matches(['\n', '\r']);
if line.starts_with("diff --git ") {
flush(&mut hunks, &mut open, k);
file_rel = None;
} else if let Some(rest) = line.strip_prefix("+++ ") {
if rest != "/dev/null" {
file_rel = Some(rest.strip_prefix("b/").unwrap_or(rest).to_string());
}
} else if line.starts_with("@@ ") {
flush(&mut hunks, &mut open, k);
if let (Some(rel), Some(after)) = (file_rel.clone(), line.strip_prefix("@@ ")) {
let new_start = parse_hunk_header(after)
.map(|(_, (c, _))| c)
.unwrap_or(1)
.max(1);
open = Some((rel, line.to_string(), new_start, k, Vec::new()));
}
} else if let Some((_, _, _, _, lines)) = open.as_mut() {
match line.as_bytes().first() {
Some(b' ') => lines.push(HunkLine::Context(line[1..].to_string())),
Some(b'+') => lines.push(HunkLine::Added(line[1..].to_string())),
Some(b'-') => lines.push(HunkLine::Removed(line[1..].to_string())),
Some(b'\\') => lines.push(HunkLine::NoNewline),
_ => {} }
}
}
flush(&mut hunks, &mut open, n);
hunks
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_added_modified_removed() {
let ws = Path::new("/repo");
let diff = "\
diff --git a/foo.rs b/foo.rs
index e69de29..1234567 100644
--- a/foo.rs
+++ b/foo.rs
@@ -0,0 +1,2 @@
+line one
+line two
@@ -10 +12,1 @@
-old
+new
@@ -20,2 +22,0 @@
-gone a
-gone b
";
let s = parse(diff, ws);
let v = s.get(&ws.join("foo.rs")).unwrap();
assert!(v.contains(&(0, SignKind::Added)));
assert!(v.contains(&(1, SignKind::Added)));
assert!(v.contains(&(11, SignKind::Modified)));
assert!(v.iter().any(|&(_, k)| k == SignKind::Removed));
assert!(v.windows(2).all(|w| w[0].0 <= w[1].0));
}
#[test]
fn dev_null_target_skipped() {
let ws = Path::new("/repo");
let diff = "\
diff --git a/del.txt b/del.txt
--- a/del.txt
+++ /dev/null
@@ -1,3 +0,0 @@
-a
-b
-c
";
let s = parse(diff, ws);
assert!(s.is_empty());
}
#[test]
fn parse_hunks_splits_files_and_hunks() {
let ws = Path::new("/repo");
let diff = "\
diff --git a/src/a.rs b/src/a.rs
index 111..222 100644
--- a/src/a.rs
+++ b/src/a.rs
@@ -1,3 +1,4 @@
fn main() {
- old();
+ new();
+ extra();
}
diff --git a/b.txt b/b.txt
--- a/b.txt
+++ b/b.txt
@@ -5 +5 @@
-x
+y
";
let hs = parse_hunks(diff, ws);
assert_eq!(hs.len(), 2);
assert_eq!(hs[0].file, ws.join("src/a.rs"));
assert_eq!(hs[0].file_rel, "src/a.rs");
assert_eq!(hs[0].new_start, 1);
assert!(hs[0].header.starts_with("@@ -1,3 +1,4 @@"));
assert!(matches!(hs[0].lines[0], HunkLine::Context(_)));
assert!(matches!(hs[0].lines[1], HunkLine::Removed(_)));
assert!(matches!(hs[0].lines[2], HunkLine::Added(_)));
let patch = hs[0].patch();
assert!(patch.starts_with("--- a/src/a.rs\n+++ b/src/a.rs\n@@ -1,3 +1,4 @@"));
assert!(patch.contains("+ new();\n"));
assert_eq!(hs[1].file_rel, "b.txt");
assert_eq!(hs[1].new_start, 5);
}
#[test]
fn contains_new_line_modified() {
let ws = Path::new("/repo");
let diff = "\
diff --git a/a.rs b/a.rs
--- a/a.rs
+++ b/a.rs
@@ -10,2 +10,3 @@
ctx
-old
+new
+extra
";
let hs = parse_hunks(diff, ws);
let h = &hs[0];
assert_eq!(h.new_line_count(), 3);
assert!(!h.contains_new_line(8));
assert!(h.contains_new_line(9));
assert!(h.contains_new_line(11));
assert!(!h.contains_new_line(12));
}
#[test]
fn intraline_diff_basic_cases() {
let ((a, b), (c, d)) = intraline_diff("hello", "hello");
assert_eq!((a, b), (5, 5));
assert_eq!((c, d), (5, 5));
let ((a, b), (c, d)) = intraline_diff("hello!", "hello?");
assert_eq!(&"hello!"[..a].chars().count(), &5);
assert_eq!(b - a, 1);
assert_eq!(d - c, 1);
let ((a, b), (c, d)) = intraline_diff("fn foo()", "fn bar()");
assert_eq!(a, 3);
assert_eq!(b, 6);
assert_eq!(c, 3);
assert_eq!(d, 6);
let ((a, b), (c, d)) = intraline_diff("abc", "xyz");
assert_eq!((a, b), (0, 3));
assert_eq!((c, d), (0, 3));
}
#[test]
fn contains_new_line_pure_deletion_sticks_to_anchor() {
let ws = Path::new("/repo");
let diff = "\
diff --git a/a.rs b/a.rs
--- a/a.rs
+++ b/a.rs
@@ -20,2 +19,0 @@
-gone a
-gone b
";
let hs = parse_hunks(diff, ws);
let h = &hs[0];
assert_eq!(h.new_line_count(), 0);
assert!(h.contains_new_line(18));
assert!(!h.contains_new_line(17));
assert!(!h.contains_new_line(19));
}
}