pub fn split_diff(text: &str) -> Option<(String, Vec<String>)> {
let first_hunk = text.find("\n@@")? + 1;
let header = text[..first_hunk].to_string();
let mut hunks = Vec::new();
let body = &text[first_hunk..];
let mut starts: Vec<usize> = vec![0];
let mut pos = 0;
while let Some(next) = body[pos..].find("\n@@") {
pos += next + 1;
starts.push(pos);
}
for (i, &s) in starts.iter().enumerate() {
let end = starts.get(i + 1).copied().unwrap_or(body.len());
hunks.push(body[s..end].to_string());
}
Some((header, hunks))
}
pub fn hunk_patch(header: &str, hunk: &str) -> String {
let mut patch = format!("{header}{hunk}");
if !patch.ends_with('\n') {
patch.push('\n');
}
patch
}
pub fn subset_hunk(hunk: &str, selected: &[usize]) -> Option<String> {
let mut lines = hunk.lines();
let head = lines.next()?;
let (old_start, new_start) = parse_header(head)?;
let mut body = String::new();
let (mut old_count, mut new_count) = (0u32, 0u32);
for (i, line) in lines.enumerate() {
let take = selected.contains(&i);
let (kind, rest) = line
.split_at(line.char_indices().next().map_or(0, |(_, c)| c.len_utf8()).min(line.len()));
match (kind, take) {
("\\", _) => {
body.push_str(line);
body.push('\n');
continue;
}
("+", true) => {
body.push_str(line);
new_count += 1;
}
("+", false) => continue,
("-", true) => {
body.push_str(line);
old_count += 1;
}
("-", false) => {
body.push(' ');
body.push_str(rest);
old_count += 1;
new_count += 1;
}
_ => {
body.push_str(line);
old_count += 1;
new_count += 1;
}
}
body.push('\n');
}
if old_count == new_count
&& !body.contains("\n+")
&& !body.starts_with('+')
&& !body.contains("\n-")
&& !body.starts_with('-')
{
return None;
}
Some(format!("@@ -{old_start},{old_count} +{new_start},{new_count} @@\n{body}"))
}
fn parse_header(head: &str) -> Option<(u32, u32)> {
let mid = head.strip_prefix("@@ ")?.split(" @@").next()?;
let mut parts = mid.split_whitespace();
let old = parts.next()?.strip_prefix('-')?;
let new = parts.next()?.strip_prefix('+')?;
let first = |s: &str| s.split(',').next().unwrap_or("").parse::<u32>().ok();
Some((first(old)?, first(new)?))
}
#[cfg(test)]
mod tests {
use super::*;
use std::process::{Command, Stdio};
fn git(dir: &std::path::Path, args: &[&str]) -> String {
let out = Command::new("git").arg("-C").arg(dir).args(args).output().unwrap();
assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
String::from_utf8_lossy(&out.stdout).into_owned()
}
fn apply_cached(dir: &std::path::Path, patch: &str, reverse: bool) {
let mut args = vec!["-C", dir.to_str().unwrap(), "apply", "--cached"];
if reverse {
args.push("-R");
}
let mut child = Command::new("git").args(&args).stdin(Stdio::piped()).spawn().unwrap();
use std::io::Write;
child.stdin.take().unwrap().write_all(patch.as_bytes()).unwrap();
assert!(child.wait().unwrap().success());
}
#[test]
fn subset_keeps_only_the_selected_change() {
let hunk = "@@ -1,2 +1,4 @@\n line one\n+added A\n+added B\n line two\n";
let out = subset_hunk(hunk, &[2]).unwrap();
assert_eq!(out, "@@ -1,2 +1,3 @@\n line one\n+added B\n line two\n");
}
#[test]
fn a_removal_that_is_not_selected_becomes_context() {
let hunk = "@@ -1,3 +1,1 @@\n keep\n-gone A\n-gone B\n";
let out = subset_hunk(hunk, &[1]).unwrap();
assert_eq!(out, "@@ -1,3 +1,2 @@\n keep\n-gone A\n gone B\n");
}
#[test]
fn a_subset_with_no_change_gives_nothing() {
let hunk = "@@ -1,2 +1,3 @@\n line one\n+added A\n line two\n";
assert!(subset_hunk(hunk, &[]).is_none());
}
#[test]
fn one_line_goes_to_the_index() {
let dir = std::env::temp_dir().join(format!("lazier-line-{}", 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@t"]);
git(&dir, &["config", "user.name", "t"]);
let start: Vec<String> = (1..=6).map(|i| format!("line {i}")).collect();
std::fs::write(dir.join("f.txt"), start.join("\n") + "\n").unwrap();
git(&dir, &["add", "."]);
git(&dir, &["commit", "-qm", "init"]);
let mut now = start.clone();
now[1] = "line 2 CHANGED".into();
now[2] = "line 3 CHANGED".into();
now[3] = "line 4 CHANGED".into();
std::fs::write(dir.join("f.txt"), now.join("\n") + "\n").unwrap();
let diff = git(&dir, &["diff", "--", "f.txt"]);
let (header, hunks) = split_diff(&diff).unwrap();
assert_eq!(hunks.len(), 1, "expected one hunk:\n{diff}");
let body: Vec<&str> = hunks[0].lines().skip(1).collect();
let minus3 = body.iter().position(|l| *l == "-line 3").unwrap();
let plus3 = body.iter().position(|l| *l == "+line 3 CHANGED").unwrap();
let subset = subset_hunk(&hunks[0], &[minus3, plus3]).unwrap();
apply_cached(&dir, &hunk_patch(&header, &subset), false);
let staged = git(&dir, &["diff", "--cached", "--", "f.txt"]);
assert!(staged.contains("+line 3 CHANGED"), "staged:\n{staged}");
assert!(!staged.contains("+line 2 CHANGED"), "line 2 must stay out:\n{staged}");
assert!(!staged.contains("+line 4 CHANGED"), "line 4 must stay out:\n{staged}");
let rest = git(&dir, &["diff", "--", "f.txt"]);
assert!(
rest.contains("+line 2 CHANGED") && rest.contains("+line 4 CHANGED"),
"rest:\n{rest}"
);
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hunk_round_trip() {
let dir = std::env::temp_dir().join(format!("lazier-patch-{}", 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@t"]);
git(&dir, &["config", "user.name", "t"]);
let lines: Vec<String> = (1..=20).map(|i| format!("line {i}")).collect();
std::fs::write(dir.join("f.txt"), lines.join("\n") + "\n").unwrap();
git(&dir, &["add", "."]);
git(&dir, &["commit", "-qm", "init"]);
let clean_index = git(&dir, &["write-tree"]);
let mut changed = lines.clone();
changed[1] = "line 2 CHANGED".into();
changed[17] = "line 18 CHANGED".into();
std::fs::write(dir.join("f.txt"), changed.join("\n") + "\n").unwrap();
let diff = git(&dir, &["diff", "--", "f.txt"]);
let (header, hunks) = split_diff(&diff).unwrap();
assert_eq!(hunks.len(), 2, "expected two hunks:\n{diff}");
let patch = hunk_patch(&header, &hunks[0]);
apply_cached(&dir, &patch, false);
let cached = git(&dir, &["diff", "--cached", "--", "f.txt"]);
assert!(cached.contains("line 2 CHANGED"));
assert!(!cached.contains("line 18 CHANGED"));
apply_cached(&dir, &patch, true);
assert_eq!(git(&dir, &["write-tree"]), clean_index);
let _ = std::fs::remove_dir_all(&dir);
}
}