//! `diff [repo]` — print the unified diff for a PR. //! //! By default any per-file section whose target path matches a //! known autogenerated lockfile (`flake.lock`, `Cargo.lock`, //! `package-lock.json`, …) is collapsed to a single //! `[: contents changed (+N -M, --full for content)]` //! line so a `flake.lock` rev bump doesn't drown the human- //! authored changes in 5 000 lines of lock churn (#222). The //! per-file git headers (`diff --git`, `index`, `---`, `+++`, //! and any rename / mode metadata) are suppressed alongside the //! hunks since the placeholder already carries the file path and //! the +/- magnitude — the headers add four lines of noise per //! lockfile without any information the reader can act on. //! Pass `--full` to dump the unfiltered diff. use anyhow::Result; use clap::Args as ClapArgs; use crate::client::Client; #[derive(ClapArgs)] pub struct Args { /// PR number. number: u64, /// Print the unfiltered diff including autogenerated-file /// hunks (`flake.lock`, `Cargo.lock`, etc.). Default is to /// collapse those hunks to a placeholder so the human-authored /// changes aren't drowned in lock churn. #[arg(long)] full: bool, } pub fn run(client: &Client, args: Args) -> Result<()> { let repo = client.repo(); let diff = client.get_text( &format!("/repos/{repo}/pulls/{}.diff", args.number), "text/plain", )?; let out = if args.full { diff } else { collapse_autogenerated(&diff) }; print!("{out}"); Ok(()) } /// Walk a unified diff line-by-line. When a `diff --git` line /// names a known autogenerated file (`is_autogenerated`), suppress /// every line of that per-file section (the git headers AND the /// hunks) and emit a single `[file.lock: contents changed (+N -M, /// --full for content)]` line in their place. Non-autogenerated /// files pass through unchanged. fn collapse_autogenerated(diff: &str) -> String { let mut out = String::with_capacity(diff.len()); let mut state: Option = None; for line in diff.lines() { if let Some(rest) = line.strip_prefix("diff --git ") { // New file section — flush prior collapse counter. if let Some(s) = state.take() { out.push_str(&s.placeholder()); } let path = parse_diff_git_path(rest); if let Some(p) = path.as_deref() && is_autogenerated(p) { // Suppress the entire per-file block (headers and // hunks alike). The placeholder we emit on flush // carries the path, so the git headers are pure // noise. state = Some(CollapseState::new(p.to_owned())); continue; } out.push_str(line); out.push('\n'); continue; } if let Some(s) = state.as_mut() { // Inside an autogenerated section. Tally `+`/`-` body // lines for the placeholder; drop everything else // (headers, `@@` markers, context lines). if s.in_body || line.starts_with("@@") { s.in_body = true; if !line.starts_with("@@") { match line.as_bytes().first() { Some(b'+') => s.added += 1, Some(b'-') => s.removed += 1, _ => {} } } } continue; } out.push_str(line); out.push('\n'); } if let Some(s) = state.take() { out.push_str(&s.placeholder()); } out } /// Per-file accumulator for the collapsed placeholder. Tracks /// the file's display name plus `+`/`-` line counts (excluding /// the `@@` hunk header), so the placeholder shows operator- /// meaningful magnitude instead of "N lines omitted" (which /// mixed context + added + removed indistinguishably). struct CollapseState { path: String, in_body: bool, added: u32, removed: u32, } impl CollapseState { fn new(path: String) -> Self { Self { path, in_body: false, added: 0, removed: 0, } } fn placeholder(&self) -> String { format!( "[{}: contents changed (+{} -{}, --full for content)]\n", self.path, self.added, self.removed ) } } /// `rest` is everything after `diff --git `, e.g. `a/foo b/foo` /// or `"a/path with space" "b/path with space"`. Return the /// post-rename (`b/`-side) path so renames report the new path. /// /// Git uses C-style quoting (`\"`, `\\`, octal escapes) for paths /// with spaces or unusual bytes — we don't unescape because we /// only need the bytewise file name for the lockfile allowlist /// match. We do parse the quoted-vs-unquoted form correctly so a /// whitespace-containing path doesn't break `split_whitespace`. fn parse_diff_git_path(rest: &str) -> Option { if let Some(after_open) = rest.strip_prefix('"') { // Quoted form: `"a/" "b/"`. Find the closing // quote of the a-side (skipping `\"` escapes so a path // containing `"` doesn't terminate early). let mut iter = after_open.char_indices(); let mut a_close = None; while let Some((i, c)) = iter.next() { if c == '\\' { // Skip the next char — it's part of the escape. iter.next(); continue; } if c == '"' { a_close = Some(i); break; } } let a_close = a_close?; // After the a-side closing quote, we expect `" "b/...`. // `after_open` skipped the leading `"`, so the b-side // starts in `after_open[a_close + 1..]` — strip leading // space + opening quote + `b/`, then strip trailing `"`. let after_a = after_open.get(a_close + 1..)?; let after_a = after_a.strip_prefix(' ')?; let b_inside = after_a.strip_prefix('"')?; // Find b-side's closing quote with the same escape rule. let mut iter = b_inside.char_indices(); let mut b_close = None; while let Some((i, c)) = iter.next() { if c == '\\' { iter.next(); continue; } if c == '"' { b_close = Some(i); break; } } let b_close = b_close?; let b_path = b_inside.get(..b_close)?; Some(b_path.strip_prefix("b/").unwrap_or(b_path).to_owned()) } else { // Unquoted: paths have no whitespace, so `split_whitespace` // gives exactly two tokens. let token = rest.split_whitespace().nth(1)?; Some(token.strip_prefix("b/").unwrap_or(token).to_owned()) } } /// File-name match against a small whitelist of well-known /// machine-generated lockfiles. We deliberately don't pattern-match /// extensions like `*.lock` because some real human-authored files /// use that suffix (e.g. `keep.lock` config markers); explicit /// listing avoids surprising the user. fn is_autogenerated(path: &str) -> bool { let name = path.rsplit('/').next().unwrap_or(path); matches!( name, "flake.lock" | "Cargo.lock" | "package-lock.json" | "pnpm-lock.yaml" | "yarn.lock" | "Gemfile.lock" | "poetry.lock" | "Pipfile.lock" | "composer.lock" | "go.sum" ) } #[cfg(test)] mod tests { use super::*; #[test] fn detects_known_lockfiles() { assert!(is_autogenerated("flake.lock")); assert!(is_autogenerated("a/flake.lock")); assert!(is_autogenerated("hive-c0re/Cargo.lock")); assert!(is_autogenerated( "frontend/packages/dashboard/package-lock.json" )); assert!(!is_autogenerated("src/main.rs")); assert!(!is_autogenerated("Cargo.toml")); // Suffix-only files we deliberately don't match — keep // `is_autogenerated` allowlist-driven, not pattern-driven. assert!(!is_autogenerated("config/keep.lock")); } #[test] fn collapse_replaces_lockfile_hunks_with_placeholder() { let diff = "\ diff --git a/Cargo.lock b/Cargo.lock index 1111..2222 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,3 +1,3 @@ [[package]] -name = \"old\" +name = \"new\" diff --git a/src/main.rs b/src/main.rs index 3333..4444 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,2 +1,2 @@ -fn main() {} +fn main() { println!(\"hi\"); } "; let out = collapse_autogenerated(diff); // Per-file git headers for the lockfile are suppressed — // the placeholder already carries the path, so the four // header lines are pure noise (4 lines per lockfile, stacks // fast on a multi-lockfile PR). assert!(!out.contains("diff --git a/Cargo.lock")); assert!(!out.contains("--- a/Cargo.lock")); assert!(!out.contains("+++ b/Cargo.lock")); assert!(!out.contains("[[package]]")); // Stat-style placeholder: 1 added (`+name = "new"`), 1 // removed (`-name = "old"`); context line and `@@` header // don't count. assert!( out.contains("[Cargo.lock: contents changed (+1 -1, --full for content)]"), "expected one-line placeholder, got: {out}" ); // Non-lockfile file passes through untouched. assert!(out.contains("diff --git a/src/main.rs")); assert!(out.contains("fn main() {}")); assert!(out.contains("println!(\"hi\")")); } #[test] fn collapse_passes_normal_diff_through() { let diff = "\ diff --git a/src/lib.rs b/src/lib.rs index aaaa..bbbb 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,1 +1,1 @@ -fn old() {} +fn new() {} "; let out = collapse_autogenerated(diff); assert_eq!(out, diff); } #[test] fn collapse_handles_trailing_lockfile() { // Lockfile is the LAST file in the diff — exercises the // post-loop flush path. let diff = "\ diff --git a/README.md b/README.md index aaaa..bbbb 100644 --- a/README.md +++ b/README.md @@ -1,1 +1,1 @@ -old +new diff --git a/flake.lock b/flake.lock index 1111..2222 100644 --- a/flake.lock +++ b/flake.lock @@ -1,2 +1,2 @@ lock-line-one -lock-line-two +lock-line-two-bumped "; let out = collapse_autogenerated(diff); // README.md (non-lockfile) passes through with its full // header + hunks intact. assert!(out.contains("diff --git a/README.md")); assert!(out.contains("-old")); assert!(out.contains("+new")); // Lockfile is fully suppressed — no headers, no content. assert!(!out.contains("diff --git a/flake.lock")); assert!(!out.contains("lock-line-one")); assert!( out.contains("[flake.lock: contents changed (+1 -1, --full for content)]"), "expected one-line placeholder, got: {out}" ); } #[test] fn collapse_counts_distinguish_added_and_removed() { // Big asymmetric churn — flake.lock rev bump with several // adds and one removal in the worker fields. Verifies // we don't conflate `+`/`-` totals. let diff = "\ diff --git a/flake.lock b/flake.lock index 1111..2222 100644 --- a/flake.lock +++ b/flake.lock @@ -1,5 +1,8 @@ { - \"old\": 1 + \"new\": 1, + \"another\": 2, + \"and\": 3, + \"more\": 4 } "; let out = collapse_autogenerated(diff); assert!( out.contains("[flake.lock: contents changed (+4 -1, --full for content)]"), "expected +4 -1, got: {out}" ); } #[test] fn parse_diff_git_path_picks_b_side() { assert_eq!(parse_diff_git_path("a/foo b/foo"), Some("foo".to_owned())); assert_eq!( parse_diff_git_path("a/old.txt b/new.txt"), Some("new.txt".to_owned()) ); assert_eq!( parse_diff_git_path("a/dir/Cargo.lock b/dir/Cargo.lock"), Some("dir/Cargo.lock".to_owned()) ); } #[test] fn parse_diff_git_path_handles_quoted_paths_with_spaces() { // Git quotes paths with spaces / unusual chars per // `core.quotePath`. The b-side path must survive intact. assert_eq!( parse_diff_git_path("\"a/foo bar\" \"b/foo bar\""), Some("foo bar".to_owned()) ); // Rename with quoted sides. assert_eq!( parse_diff_git_path("\"a/old name\" \"b/new name\""), Some("new name".to_owned()) ); // Lockfile inside a directory whose name has a space. assert_eq!( parse_diff_git_path("\"a/dir with space/Cargo.lock\" \"b/dir with space/Cargo.lock\""), Some("dir with space/Cargo.lock".to_owned()) ); } #[test] fn parse_diff_git_path_handles_escaped_quote_inside_path() { // Git escapes embedded `"` as `\"`. The closing-quote // search must skip these so it doesn't terminate early. // Path is literally `a/has"quote` / `b/has"quote`. let rest = r#""a/has\"quote" "b/has\"quote""#; assert_eq!( parse_diff_git_path(rest), // Bytewise: backslash + quote stay in the result // because we don't unescape (allowlist match is by // file name, never contains escapes). Some(r#"has\"quote"#.to_owned()) ); } #[test] fn collapse_recognises_lockfile_in_quoted_path() { // Path "a/odd dir/Cargo.lock" forces git to use the // quoted form. We must still detect it as a lockfile. let diff = "\ diff --git \"a/odd dir/Cargo.lock\" \"b/odd dir/Cargo.lock\" index 1111..2222 100644 --- \"a/odd dir/Cargo.lock\" +++ \"b/odd dir/Cargo.lock\" @@ -1,1 +1,1 @@ -old +new "; let out = collapse_autogenerated(diff); // Entire per-file block suppressed (lockfile detected). assert!(!out.contains("diff --git"), "headers leaked: {out}"); assert!(!out.contains("old\n"), "lock content leaked: {out}"); assert!(!out.contains("+new"), "lock content leaked: {out}"); // Placeholder uses the parsed b-side path (without quotes). assert!( out.contains("[odd dir/Cargo.lock: contents changed (+1 -1, --full for content)]"), "expected one-line placeholder for quoted-path lockfile, got: {out}" ); } }