use std::collections::{HashMap, HashSet};
use globset::{Glob, GlobSet, GlobSetBuilder};
pub fn parse_valid_lines(diff: &str) -> HashMap<String, HashSet<u64>> {
let mut map: HashMap<String, HashSet<u64>> = HashMap::new();
for_each_new_side_line(diff, |path, new_line, _text| {
map.entry(path.to_string()).or_default().insert(new_line);
});
map
}
fn for_each_new_side_line(diff: &str, mut f: impl FnMut(&str, u64, &str)) {
let mut cur_path: Option<String> = None;
let mut new_line: u64 = 0;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("+++ ") {
let p = rest.trim();
let p = p.strip_prefix("b/").unwrap_or(p);
cur_path = (p != "/dev/null").then(|| p.to_string());
} else if line.starts_with("@@") {
if let Some(plus) = line.split('+').nth(1) {
let num: String = plus.chars().take_while(|c| c.is_ascii_digit()).collect();
new_line = num.parse().unwrap_or(0);
}
} else if let Some(path) = &cur_path {
if let Some(text) = line.strip_prefix('+').or_else(|| line.strip_prefix(' ')) {
f(path, new_line, text);
new_line += 1;
}
}
}
}
pub fn diff_line_texts(diff: &str) -> HashMap<String, HashMap<u64, String>> {
let mut map: HashMap<String, HashMap<u64, String>> = HashMap::new();
for_each_new_side_line(diff, |path, new_line, text| {
map.entry(path.to_string())
.or_default()
.insert(new_line, text.to_string());
});
map
}
fn build_globset(patterns: &[String]) -> Option<GlobSet> {
let mut builder = GlobSetBuilder::new();
for p in patterns {
builder.add(Glob::new(p).ok()?);
}
builder.build().ok()
}
fn git_header_path(line: &str) -> Option<String> {
let rest = line.strip_prefix("diff --git ")?;
let idx = rest.find(" b/")?;
let p = rest[idx + 3..].trim();
(!p.is_empty()).then(|| p.to_string())
}
fn section_path(section: &[&str], has_git: bool) -> Option<String> {
if has_git {
if let Some(p) = section.first().and_then(|l| git_header_path(l)) {
return Some(p);
}
}
for l in section {
if let Some(rest) = l.strip_prefix("+++ ") {
let p = rest.trim();
let p = p.strip_prefix("b/").unwrap_or(p);
return (p != "/dev/null").then(|| p.to_string());
}
}
None
}
pub fn split_diff_sections(diff: &str) -> Vec<(String, String)> {
let lines: Vec<&str> = diff.lines().collect();
let has_git = lines.iter().any(|l| l.starts_with("diff --git "));
let starts: Vec<usize> = if has_git {
lines
.iter()
.enumerate()
.filter(|(_, l)| l.starts_with("diff --git "))
.map(|(i, _)| i)
.collect()
} else {
let mut s = Vec::new();
for (i, l) in lines.iter().enumerate() {
if l.starts_with("+++ ") {
if i > 0 && lines[i - 1].starts_with("--- ") {
s.push(i - 1);
} else {
s.push(i);
}
}
}
s
};
if starts.is_empty() {
return vec![(String::new(), diff.to_string())];
}
let emit = |slice: &[&str]| -> String {
let mut t = slice.join("\n");
if !t.is_empty() {
t.push('\n');
}
t
};
let mut sections: Vec<(String, String)> = Vec::new();
if starts[0] > 0 {
sections.push((String::new(), emit(&lines[..starts[0]])));
}
for (idx, &start) in starts.iter().enumerate() {
let end = starts.get(idx + 1).copied().unwrap_or(lines.len());
let section = &lines[start..end];
let path = section_path(section, has_git).unwrap_or_default();
sections.push((path, emit(section)));
}
sections
}
fn file_priority(path: &str) -> u8 {
let p = path.to_ascii_lowercase();
if p.contains("test")
|| p.contains("spec")
|| p.contains("__tests__")
|| p.contains(".snap")
|| p.contains("fixtures")
{
return 1;
}
if p.ends_with(".md")
|| p.ends_with(".txt")
|| p.starts_with("docs/")
|| p.contains("/docs/")
|| p.ends_with(".lock")
|| (p.ends_with(".json") && !p.contains('/'))
{
return 0;
}
2
}
pub fn pack_diff(diff: &str, max_chars: usize) -> (String, Vec<String>) {
pack_impl(diff, max_chars, false)
}
pub fn pack_diff_bundled(diff: &str, max_chars: usize) -> (String, Vec<String>) {
pack_impl(diff, max_chars, true)
}
fn is_locale(s: &str) -> bool {
matches!(
s,
"en" | "zh" | "ja" | "ko" | "fr" | "de" | "es" | "it" | "pt" | "ru" | "vi" | "th" | "ar"
| "hi" | "id" | "nl" | "pl" | "tr" | "uk" | "cs" | "sv" | "da" | "fi" | "no" | "ro"
| "hu" | "el"
)
}
fn bundle_key(path: &str) -> String {
let (dir, file) = match path.rfind('/') {
Some(i) => (&path[..i], &path[i + 1..]),
None => ("", path),
};
let mut f = file.to_ascii_lowercase();
if let Some(i) = f.rfind('.') {
if i > 0 {
f.truncate(i);
}
}
for suf in ["-test", "-spec", "_test", "_spec", ".test", ".spec"] {
if let Some(s) = f.strip_suffix(suf) {
let n = s.len();
f.truncate(n);
break;
}
}
if let Some(s) = f.strip_prefix("test_") {
f = s.to_string();
}
if let Some(i) = f.rfind(['.', '_']) {
if is_locale(&f[i + 1..]) {
f.truncate(i);
}
}
format!("{dir}/{f}")
}
fn pack_impl(diff: &str, max_chars: usize, bundle: bool) -> (String, Vec<String>) {
if diff.chars().count() <= max_chars {
return (diff.to_string(), Vec::new());
}
let sections = split_diff_sections(diff);
let units: Vec<Vec<usize>> = if bundle {
let mut keys: Vec<String> = Vec::new();
let mut groups: Vec<Vec<usize>> = Vec::new();
for (i, (path, _)) in sections.iter().enumerate() {
let key = if path.is_empty() {
format!("\0{i}")
} else {
bundle_key(path)
};
match keys.iter().position(|k| k == &key) {
Some(p) => groups[p].push(i),
None => {
keys.push(key);
groups.push(vec![i]);
}
}
}
groups
} else {
(0..sections.len()).map(|i| vec![i]).collect()
};
let len_of = |i: usize| sections[i].1.chars().count();
let unit_len = |u: &[usize]| -> usize { u.iter().map(|&i| len_of(i)).sum() };
let unit_priority =
|u: &[usize]| -> u8 { u.iter().map(|&i| file_priority(§ions[i].0)).max().unwrap_or(0) };
let mut order: Vec<usize> = (0..units.len()).collect();
order.sort_by(|&a, &b| {
unit_priority(&units[b])
.cmp(&unit_priority(&units[a]))
.then_with(|| unit_len(&units[a]).cmp(&unit_len(&units[b])))
});
let mut keep = vec![false; sections.len()];
let mut used = 0usize;
for &ui in &order {
let u = &units[ui];
let ul = unit_len(u);
if used + ul <= max_chars {
for &i in u {
keep[i] = true;
}
used += ul;
} else if u.len() > 1 {
let mut members = u.clone();
members.sort_by(|&a, &b| {
file_priority(§ions[b].0)
.cmp(&file_priority(§ions[a].0))
.then_with(|| len_of(a).cmp(&len_of(b)))
});
for &i in &members {
if used + len_of(i) <= max_chars {
keep[i] = true;
used += len_of(i);
}
}
}
}
if !keep.iter().any(|&k| k) {
if let Some(&i) = order.first().and_then(|&ui| units[ui].first()) {
keep[i] = true;
}
}
let mut unit_order: Vec<usize> = (0..units.len()).collect();
unit_order.sort_by_key(|&ui| *units[ui].iter().min().unwrap());
let mut out = String::new();
let mut dropped: Vec<String> = Vec::new();
for &ui in &unit_order {
for &i in &units[ui] {
let (path, text) = §ions[i];
if keep[i] {
out.push_str(text);
} else if !path.is_empty() {
dropped.push(path.clone());
}
}
}
(out, dropped)
}
pub fn filter_diff_by_globs(
diff: &str,
include: &[String],
exclude: &[String],
) -> (String, Vec<String>) {
let (include_set, exclude_set) = match (build_globset(include), build_globset(exclude)) {
(Some(i), Some(e)) => (i, e),
_ => return (diff.to_string(), Vec::new()),
};
let sections = split_diff_sections(diff);
let mut out = String::new();
let mut dropped: Vec<String> = Vec::new();
for (path, text) in §ions {
let keep = path.is_empty()
|| ((include.is_empty() || include_set.is_match(path)) && !exclude_set.is_match(path));
if keep {
out.push_str(text);
} else {
dropped.push(path.clone());
}
}
if dropped.is_empty() {
return (diff.to_string(), Vec::new());
}
(out, dropped)
}
#[must_use]
pub fn path_matches_globs(path: &str, include: &[String], exclude: &[String]) -> bool {
let (include_set, exclude_set) = match (build_globset(include), build_globset(exclude)) {
(Some(i), Some(e)) => (i, e),
_ => return true,
};
(include.is_empty() || include_set.is_match(path)) && !exclude_set.is_match(path)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HygieneIssue {
pub file: String,
pub severity: &'static str,
pub body: String,
}
const LARGE_ADDED_LINES: usize = 1000;
fn is_routine_asset(path: &str) -> bool {
let p = path.to_ascii_lowercase();
const ASSET_EXT: &[&str] = &[
".png", ".jpg", ".jpeg", ".gif", ".ico", ".webp", ".avif", ".bmp", ".tiff", ".woff",
".woff2", ".ttf", ".otf", ".eot",
];
ASSET_EXT.iter().any(|e| p.ends_with(e))
}
#[must_use]
pub fn diff_hygiene(diff: &str) -> Vec<HygieneIssue> {
let mut issues = Vec::new();
for (path, section) in split_diff_sections(diff) {
if path.is_empty() {
continue; }
if !section.lines().any(|l| l.starts_with("new file mode")) {
continue;
}
if section.lines().any(|l| l.starts_with("Binary files ")) {
if !is_routine_asset(&path) {
issues.push(HygieneIssue {
file: path.clone(),
severity: "MEDIUM",
body: format!(
"A binary file `{path}` was added in this change — binaries bloat the repo permanently and are invisible in a normal diff view. Fix: drop it from the commit (`.gitignore`, Git LFS, or a release asset) unless it is intentional."
),
});
}
continue;
}
let added = section
.lines()
.filter(|l| l.starts_with('+') && !l.starts_with("+++"))
.count();
if added >= LARGE_ADDED_LINES {
issues.push(HygieneIssue {
file: path.clone(),
severity: "LOW",
body: format!(
"`{path}` adds {added} lines in a single new file. If this is generated or vendored output it shouldn't be committed. Fix: confirm it's hand-written; otherwise `.gitignore` or exclude it."
),
});
}
}
issues
}
#[cfg(test)]
mod tests {
use super::{
bundle_key, diff_hygiene, diff_line_texts, filter_diff_by_globs, pack_diff,
pack_diff_bundled, parse_valid_lines, path_matches_globs, split_diff_sections,
};
#[test]
fn hygiene_flags_an_added_binary_file() {
let diff = "diff --git a/assets/ime.zip b/assets/ime.zip\n\
new file mode 100644\n\
index 0000000..abc1234\n\
Binary files /dev/null and b/assets/ime.zip differ\n";
let issues = diff_hygiene(diff);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].file, "assets/ime.zip");
assert_eq!(issues[0].severity, "MEDIUM");
assert!(issues[0].body.contains("binary"));
}
#[test]
fn hygiene_ignores_a_routine_image_asset() {
let diff = "diff --git a/apps/web/public/assistant/be-na.webp b/apps/web/public/assistant/be-na.webp\n\
new file mode 100644\n\
index 0000000..abc1234\n\
Binary files /dev/null and b/apps/web/public/assistant/be-na.webp differ\n";
assert!(diff_hygiene(diff).is_empty());
let zip = "diff --git a/apps/web/public/data.zip b/apps/web/public/data.zip\n\
new file mode 100644\nBinary files /dev/null and b/apps/web/public/data.zip differ\n";
assert_eq!(diff_hygiene(zip).len(), 1);
}
#[test]
fn hygiene_ignores_a_normal_added_source_file() {
let diff = "diff --git a/src/a.rs b/src/a.rs\n\
new file mode 100644\n\
--- /dev/null\n+++ b/src/a.rs\n@@ -0,0 +1,2 @@\n+fn a() {}\n+// ok\n";
assert!(diff_hygiene(diff).is_empty());
}
#[test]
fn hygiene_ignores_a_modified_binary() {
let diff = "diff --git a/logo.png b/logo.png\n\
index 111..222 100644\n\
Binary files a/logo.png and b/logo.png differ\n";
assert!(diff_hygiene(diff).is_empty());
}
#[test]
fn hygiene_flags_a_large_added_file() {
let mut section = String::from(
"diff --git a/gen/bundle.js b/gen/bundle.js\n\
new file mode 100644\n--- /dev/null\n+++ b/gen/bundle.js\n@@ -0,0 +1,1500 @@\n",
);
for i in 0..1500 {
section.push_str(&format!("+line{i}\n"));
}
let issues = diff_hygiene(§ion);
assert_eq!(issues.len(), 1);
assert_eq!(issues[0].severity, "LOW");
assert!(issues[0].body.contains("1500 lines"));
}
#[test]
fn path_matches_globs_respects_include_and_exclude() {
assert!(path_matches_globs("src/lib.rs", &[], &[]));
assert!(!path_matches_globs(
"secrets/prod.env",
&[],
&["secrets/**".to_string()]
));
assert!(path_matches_globs("src/a.rs", &["src/**".to_string()], &[]));
assert!(!path_matches_globs(
"docs/x.md",
&["src/**".to_string()],
&[]
));
assert!(!path_matches_globs(
"src/gen.rs",
&["src/**".to_string()],
&["**/gen.rs".to_string()]
));
assert!(path_matches_globs("anything", &["[".to_string()], &[]));
}
fn section(path: &str, body: &str) -> String {
format!("diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n+{body}\n")
}
#[test]
fn bundle_key_groups_source_test_spec_and_i18n() {
let k = bundle_key("src/foo.ts");
assert_eq!(bundle_key("src/foo.test.ts"), k);
assert_eq!(bundle_key("src/foo.spec.tsx"), k);
assert_eq!(bundle_key("pkg/user_test.go"), bundle_key("pkg/user.go"));
assert_eq!(bundle_key("app/test_users.py"), bundle_key("app/users.py"));
let m = bundle_key("i18n/msg.en.json");
assert_eq!(bundle_key("i18n/msg.zh.json"), m);
assert_ne!(bundle_key("src/foo.ts"), bundle_key("src/bar.ts"));
assert_ne!(bundle_key("src/util.io.ts"), bundle_key("src/util.ts"));
}
#[test]
fn bundled_pack_keeps_source_and_test_adjacent() {
let foo = section("src/foo.ts", "aa");
let other = section("src/other.ts", &"x".repeat(400));
let foot = section("src/foo.test.ts", "bb");
let diff = format!("{foo}{other}{foot}");
let budget = foo.chars().count() + foot.chars().count() + 5;
let (packed, dropped) = pack_diff_bundled(&diff, budget);
assert_eq!(
dropped,
vec!["src/other.ts".to_string()],
"big unrelated file dropped"
);
let s = packed.find("src/foo.ts").unwrap();
let t = packed.find("src/foo.test.ts").unwrap();
assert!(s < t, "source before its test");
assert!(!packed.contains("src/other.ts"), "other not emitted");
}
#[test]
fn unbundled_pack_matches_historical_order() {
let a = section("src/a.ts", "aa");
let big = section("src/big.ts", &"y".repeat(400));
let diff = format!("{a}{big}");
let (packed, dropped) = pack_diff(&diff, a.chars().count() + 5);
assert!(packed.contains("src/a.ts"));
assert_eq!(dropped, vec!["src/big.ts".to_string()]);
}
#[test]
fn diff_line_texts_captures_new_side_content() {
let d = "+++ b/a.ts\n@@ -1,2 +1,3 @@\n ctx\n+added line\n ctx2\n";
let m = &diff_line_texts(d)["a.ts"];
assert_eq!(m[&1], "ctx");
assert_eq!(m[&2], "added line");
assert_eq!(m[&3], "ctx2");
}
#[test]
fn anchors_added_and_context_lines() {
let d = "diff --git a/a.rs b/a.rs\n--- a/a.rs\n+++ b/a.rs\n@@ -1,2 +1,3 @@\n ctx1\n+added\n ctx2\n";
let m = parse_valid_lines(d);
let s = &m["a.rs"];
assert!(s.contains(&1)); assert!(s.contains(&2)); assert!(s.contains(&3)); }
#[test]
fn removed_lines_do_not_advance_new_side() {
let d = "+++ b/x.rs\n@@ -1,3 +1,2 @@\n keep1\n-removed\n keep2\n";
let m = parse_valid_lines(d);
let s = &m["x.rs"];
assert!(s.contains(&1)); assert!(s.contains(&2)); assert!(!s.contains(&3));
}
#[test]
fn handles_multiple_files() {
let d = "+++ b/a.rs\n@@ -0,0 +1 @@\n+one\n+++ b/b.rs\n@@ -0,0 +1 @@\n+two\n";
let m = parse_valid_lines(d);
assert!(m["a.rs"].contains(&1));
assert!(m["b.rs"].contains(&1));
}
#[test]
fn skips_dev_null_deletions() {
let d = "+++ /dev/null\n@@ -1,1 +0,0 @@\n-gone\n";
let m = parse_valid_lines(d);
assert!(m.is_empty());
}
#[test]
fn drops_lockfile_section_keeps_source_section() {
let d = "diff --git a/package-lock.json b/package-lock.json\n\
index abc..def 100644\n\
--- a/package-lock.json\n\
+++ b/package-lock.json\n\
@@ -1 +1 @@\n\
-old\n\
+new\n\
diff --git a/src/x.ts b/src/x.ts\n\
index 111..222 100644\n\
--- a/src/x.ts\n\
+++ b/src/x.ts\n\
@@ -1 +1 @@\n\
-a\n\
+b\n";
let exclude = vec!["**/package-lock.json".to_string()];
let (kept, dropped) = filter_diff_by_globs(d, &[], &exclude);
assert_eq!(dropped, vec!["package-lock.json".to_string()]);
assert!(kept.contains("src/x.ts"));
assert!(!kept.contains("package-lock.json"));
}
#[test]
fn fallback_splits_on_plusplusplus_when_no_git_headers() {
let d = "--- a/foo.js\n\
+++ b/foo.js\n\
@@ -1 +1 @@\n\
-x\n\
+y\n\
--- a/bar.min.js\n\
+++ b/bar.min.js\n\
@@ -1 +1 @@\n\
-a\n\
+b\n";
let exclude = vec!["**/*.min.js".to_string()];
let (kept, dropped) = filter_diff_by_globs(d, &[], &exclude);
assert_eq!(dropped, vec!["bar.min.js".to_string()]);
assert!(kept.contains("foo.js"));
assert!(!kept.contains("bar.min.js"));
}
#[test]
fn split_sections_roundtrips_multi_file_diff() {
let d = "diff --git a/src/a.rs b/src/a.rs\n\
+++ b/src/a.rs\n\
@@ -1 +1 @@\n\
+a\n\
diff --git a/README.md b/README.md\n\
+++ b/README.md\n\
@@ -1 +1 @@\n\
+docs\n";
let secs = split_diff_sections(d);
assert_eq!(secs.len(), 2);
assert_eq!(secs[0].0, "src/a.rs");
assert_eq!(secs[1].0, "README.md");
let joined: String = secs.iter().map(|(_, t)| t.as_str()).collect();
assert_eq!(joined, d);
}
#[test]
fn pack_under_budget_returns_unchanged() {
let d = "diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+a\n";
let (packed, dropped) = pack_diff(d, 10_000);
assert_eq!(packed, d);
assert!(dropped.is_empty());
}
#[test]
fn pack_drops_low_priority_large_file_keeps_small_source() {
let big_docs = "x".repeat(500);
let d = format!(
"diff --git a/README.md b/README.md\n\
+++ b/README.md\n\
@@ -1 +1 @@\n\
+{big_docs}\n\
diff --git a/src/a.rs b/src/a.rs\n\
+++ b/src/a.rs\n\
@@ -1 +1 @@\n\
+small\n"
);
let (packed, dropped) = pack_diff(&d, 120);
assert_eq!(dropped, vec!["README.md".to_string()]);
assert!(packed.contains("src/a.rs"));
assert!(!packed.contains("README.md"));
}
#[test]
fn pack_preserves_original_file_order() {
let d = "diff --git a/src/a.rs b/src/a.rs\n\
+++ b/src/a.rs\n\
@@ -1 +1 @@\n\
+alpha\n\
diff --git a/src/a.spec.rs b/src/a.spec.rs\n\
+++ b/src/a.spec.rs\n\
@@ -1 +1 @@\n\
+beta\n\
diff --git a/README.md b/README.md\n\
+++ b/README.md\n\
@@ -1 +1 @@\n\
+gammagammagammagammagammagamma\n";
let (packed, dropped) = pack_diff(d, 160);
assert_eq!(dropped, vec!["README.md".to_string()]);
let a = packed.find("src/a.rs").expect("source kept");
let b = packed.find("src/a.spec.rs").expect("spec kept");
assert!(a < b, "kept sections should be in original diff order");
}
#[test]
fn pack_keeps_single_oversized_file_rather_than_empty() {
let big = "y".repeat(400);
let d = format!("diff --git a/src/a.rs b/src/a.rs\n+++ b/src/a.rs\n@@ -1 +1 @@\n+{big}\n");
let (packed, dropped) = pack_diff(&d, 50);
assert!(!packed.is_empty());
assert!(packed.contains("src/a.rs"));
assert!(dropped.is_empty());
}
}