hyperhive/hive-forge/src/verbs/diff.rs

431 lines
14 KiB
Rust

//! `diff <pr> [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
//! `[<path>: 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. 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 forgejo_api::structs::RepoDownloadPullDiffOrPatchQuery;
use crate::client::{Client, index};
#[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 (owner, name) = client.owner_repo()?;
let diff = client
.api()
.repo_download_pull_diff_or_patch(
owner,
name,
index(args.number)?,
"diff",
RepoDownloadPullDiffOrPatchQuery::default(),
)
.send()?;
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<CollapseState> = 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<String> {
if let Some(after_open) = rest.strip_prefix('"') {
// Quoted form: `"a/<path>" "b/<path>"`. 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}"
);
}
}