mod common;
use assert_cmd::Command;
use predicates::prelude::*;
const TWO_CHANGES: &str = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,5 +1,5 @@
a
-b
+B
c
-d
+D
e
";
#[test]
fn select_emits_chosen_subhunk_only() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1"])
.write_stdin(TWO_CHANGES)
.assert()
.success()
.stdout(predicate::str::contains("+B"))
.stdout(predicate::str::contains("+D").not());
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "2"])
.write_stdin(TWO_CHANGES)
.assert()
.success()
.stdout(predicate::str::contains("+D"))
.stdout(predicate::str::contains("+B").not());
}
#[test]
fn select_range() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1-2"])
.write_stdin(TWO_CHANGES)
.assert()
.success()
.stdout(predicate::str::contains("+B"))
.stdout(predicate::str::contains("+D"));
}
#[test]
fn list_human_shows_indices() {
Command::cargo_bin("hunkpick")
.unwrap()
.arg("list")
.write_stdin(TWO_CHANGES)
.assert()
.success()
.stdout(predicate::str::contains("[1]"))
.stdout(predicate::str::contains("[2]"))
.stdout(predicate::str::contains("f"));
}
#[test]
fn list_json_is_valid() {
let output = Command::cargo_bin("hunkpick")
.unwrap()
.args(["list", "--json"])
.write_stdin(TWO_CHANGES)
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value =
serde_json::from_slice(&output).expect("stdout must be valid JSON");
let files = json.as_array().expect("top-level must be an array");
assert_eq!(files.len(), 1, "expected one file entry");
assert_eq!(files[0]["path"], "f");
let hunks = files[0]["hunks"]
.as_array()
.expect("hunks must be an array");
assert_eq!(hunks.len(), 2, "expected two sub-hunks for file f");
assert_eq!(hunks[0]["index"], 1);
assert_eq!(hunks[1]["index"], 2);
}
#[test]
fn split_replaces_hunk_with_pieces() {
let stdout = Command::cargo_bin("hunkpick")
.unwrap()
.args(["split", "1", "--at", "3"])
.write_stdin(TWO_CHANGES)
.assert()
.success()
.get_output()
.stdout
.clone();
let text = std::str::from_utf8(&stdout).unwrap();
let hunk_lines: Vec<&str> = text.lines().filter(|l| l.starts_with("@@")).collect();
assert_eq!(
hunk_lines.len(),
2,
"expected 2 @@ hunk header lines, got: {text}"
);
}
#[test]
fn bad_selector_exits_2() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "nope:x"])
.write_stdin(TWO_CHANGES)
.assert()
.failure()
.code(2);
}
#[test]
fn empty_selection_exits_2() {
Command::cargo_bin("hunkpick")
.unwrap()
.arg("select")
.write_stdin(TWO_CHANGES)
.assert()
.failure()
.code(2);
}
#[test]
fn out_of_range_index_exits_2() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "9"])
.write_stdin(TWO_CHANGES)
.assert()
.failure()
.code(2);
}
#[test]
fn dash_c_requires_git_flag() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1", "-C", "."])
.write_stdin(TWO_CHANGES)
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("--verify-result-diff-git"));
}
#[test]
fn no_verify_internal_flag_accepted() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1", "--no-verify-result-diff-internal"])
.write_stdin(TWO_CHANGES)
.assert()
.success()
.stdout(predicate::str::contains("+B"));
}
const NEW_FILE_DIFF: &str = "\
diff --git a/new.txt b/new.txt
new file mode 100644
--- /dev/null
+++ b/new.txt
@@ -0,0 +1,4 @@
+l1
+l2
+l3
+l4
";
#[test]
fn select_changed_lines_first_part() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1@L1,2"])
.write_stdin(NEW_FILE_DIFF)
.assert()
.success()
.stdout(predicate::str::contains("+l1"))
.stdout(predicate::str::contains("+l2"))
.stdout(predicate::str::contains("+l3").not())
.stdout(predicate::str::contains("+l4").not());
}
#[test]
fn select_changed_lines_out_of_range_is_usage_error() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1@L1-99"])
.write_stdin(NEW_FILE_DIFF)
.assert()
.failure()
.stderr(predicate::str::contains("out of range"));
}
#[test]
fn removed_lo_hi_range_form_is_friendly_usage_error() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1@1-2"])
.write_stdin(NEW_FILE_DIFF)
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("@lo-hi"))
.stderr(predicate::str::contains("@L"));
}
#[test]
fn changed_lines_split_new_file_first_part_stages_only_those_lines() {
let dir = common::repo_with(&[]); std::fs::write(dir.path().join("new.txt"), "l1\nl2\nl3\nl4\n").unwrap();
common::sys(&dir, &["add", "-N", "new.txt"]); let diff = common::git_output(&dir, &["diff"]);
let part1 = Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1@L1,2"])
.write_stdin(diff.clone())
.assert()
.success()
.get_output()
.stdout
.clone();
common::apply_cached(&dir, &part1);
let staged = common::diff_staged(&dir);
assert!(
staged.contains("+l1") && staged.contains("+l2"),
"staged: {staged}"
);
assert!(!staged.contains("+l3"), "l3 must not be staged: {staged}");
}
#[test]
fn select_whole_and_lineset_of_same_subhunk_exits_2() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "--no-verify-result-diff-internal", "1", "1@L1,2"])
.write_stdin(NEW_FILE_DIFF)
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("sub-hunk 1"));
}
#[test]
fn inconsistent_input_header_is_a_usage_error() {
let diff = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,3 +1,3 @@
a
-b
+B
";
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1"])
.write_stdin(diff)
.assert()
.failure()
.code(2)
.stderr(predicate::str::contains("sub-hunk 1"))
.stderr(predicate::str::contains("hunk_index").not())
.stderr(predicate::str::contains("CountMismatch").not());
}
#[test]
fn huge_line_numbers_do_not_overflow() {
let diff = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -4294967295,2 +4294967295,2 @@
a
-b
+B
";
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1"])
.write_stdin(diff)
.assert()
.success()
.stdout(predicate::str::contains(
"@@ -4294967295,2 +4294967295,2 @@",
));
}
#[test]
fn closed_downstream_pipe_is_not_an_error() {
use std::io::{BufRead, BufReader, Write};
use std::process::{Command as Sys, Stdio};
let mut diff = String::from("diff --git a/f b/f\n--- a/f\n+++ b/f\n");
for i in 0..4000 {
diff.push_str(&format!(
"@@ -{n},1 +{n},1 @@\n-a{i}\n+b{i}\n",
n = i * 10 + 1
));
}
let mut child = Sys::new(assert_cmd::cargo::cargo_bin("hunkpick"))
.arg("list")
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.unwrap();
let mut stdin = child.stdin.take().unwrap();
let writer = std::thread::spawn(move || {
let _ = stdin.write_all(diff.as_bytes());
});
let stdout = child.stdout.take().unwrap();
let mut reader = BufReader::new(stdout);
let mut line = String::new();
reader.read_line(&mut line).unwrap();
drop(reader);
let out = child.wait_with_output().unwrap();
let _ = writer.join();
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
assert!(
out.status.success(),
"exit status {:?}, stderr: {stderr}",
out.status.code()
);
assert!(
!stderr.contains("Broken pipe"),
"no I/O diagnostic expected: {stderr}"
);
}
#[test]
fn many_change_runs_split_without_quadratic_blowup() {
const RUNS: usize = 20_000;
let mut diff = format!(
"diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1,{n} +1,{n} @@\n",
n = RUNS * 2
);
for i in 0..RUNS {
diff.push_str(&format!(" ctx{i}\n-old{i}\n+new{i}\n"));
}
let started = std::time::Instant::now();
let stdout = Command::cargo_bin("hunkpick")
.unwrap()
.args(["list", "--json"])
.write_stdin(diff)
.assert()
.success()
.get_output()
.stdout
.clone();
let elapsed = started.elapsed();
let text = String::from_utf8(stdout).unwrap();
assert_eq!(
text.matches("\"index\"").count(),
RUNS,
"one sub-hunk per change run"
);
assert!(
elapsed < std::time::Duration::from_secs(60),
"listing {RUNS} change runs took {elapsed:?}; the split is no longer linear"
);
}
#[test]
fn split_recomputes_new_side_anchors_like_select() {
let diff = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -17,5 +18,5 @@
a
-b
+B
c
-d
+D
e
";
Command::cargo_bin("hunkpick")
.unwrap()
.args(["split", "1", "--at", "20"])
.write_stdin(diff)
.assert()
.success()
.stdout(predicate::str::contains("@@ -17,3 +17,3 @@"))
.stdout(predicate::str::contains("+18,").not());
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1"])
.write_stdin(diff)
.assert()
.success()
.stdout(predicate::str::contains("@@ -17,3 +17,3 @@"));
}
#[test]
fn split_keeps_trailing_lines_after_the_last_hunk() {
let diff = "\
diff --git a/f b/f
--- a/f
+++ b/f
@@ -1,5 +1,5 @@
a
-b
+B
c
-d
+D
e
--
2.53.0
";
let out = common::run_ok_text(&["split", "1", "--at", "3"], diff);
assert_eq!(out.matches("@@ -").count(), 2, "two pieces expected: {out}");
let last_hunk = out.rfind("@@ -").expect("hunk header in output");
let signature = out.find("\n-- \n").expect("signature in output");
assert!(
signature > last_hunk,
"the signature must stay after the last piece: {out}"
);
let dir = common::repo_with(&[("f", "a\nb\nc\nd\ne\n")]);
common::apply_cached(&dir, out.as_bytes());
}
#[test]
fn emitting_trailing_lines_stays_linear_in_the_number_of_hunks() {
fn diff_with_separators(hunks: usize) -> String {
let mut d = String::from("diff --git a/f b/f\n--- a/f\n+++ b/f\n");
for i in 0..hunks {
let base = i * 4 + 1;
d.push_str(&format!(
"@@ -{base},3 +{base},3 @@\n ctx{i}\n-old{i}\n+new{i}\n\n"
));
}
d
}
fn split_duration(hunks: usize) -> std::time::Duration {
let diff = diff_with_separators(hunks);
let started = std::time::Instant::now();
let out = common::run_ok_text(&["split", "1", "--at", "1"], &diff);
let elapsed = started.elapsed();
assert_eq!(
out.matches("@@ -").count(),
hunks,
"every hunk must survive the split"
);
elapsed
}
const SMALL: usize = 8_000;
let small = split_duration(SMALL);
let large = split_duration(SMALL * 4);
let ratio = large.as_secs_f64() / small.as_secs_f64().max(f64::MIN_POSITIVE);
assert!(
ratio < 8.0,
"four times the hunks took {ratio:.1}x the time ({small:?} -> {large:?}); \
linear emission costs about 4x, quadratic about 16x"
);
}
#[test]
fn a_selection_that_drops_the_last_line_still_ends_with_a_newline() {
let diff = concat!(
"diff --git a/f b/f\n",
"--- a/f\n",
"+++ b/f\n",
"@@ -1,5 +1,5 @@\n",
" a\n",
"-b\n",
"+B\n",
" c\n",
" d\n",
"-e\n",
"+E", );
let out = common::run_ok_text(&["select", "1"], diff);
assert!(
out.ends_with('\n'),
"the selected sub-hunk ends on a line the input terminated: {out:?}"
);
let out = common::run_ok_text(&["select", "2"], diff);
assert!(
!out.ends_with('\n'),
"the last sub-hunk does end on the unterminated line: {out:?}"
);
}
#[test]
fn a_hunk_header_with_junk_is_a_parse_error() {
for header in ["@@ -1,3,9 +1,3 @@", "@@ -1,3 +1,3 junk @@ sect"] {
let diff = format!("diff --git a/f b/f\n--- a/f\n+++ b/f\n{header}\n a\n-b\n+B\n c\n",);
let out = Command::cargo_bin("hunkpick")
.unwrap()
.arg("list")
.write_stdin(diff)
.assert()
.code(2);
out.stderr(predicate::str::contains("hunk header"));
}
}
#[test]
fn json_listing_ends_with_a_newline() {
let diff = concat!(
"diff --git a/f b/f\n",
"--- a/f\n",
"+++ b/f\n",
"@@ -1 +1 @@\n",
"-old\n",
"+new\n",
);
let out = common::run_ok_text(&["list", "--json"], diff);
assert!(
out.ends_with("]\n"),
"json listing tail: {:?}",
&out[out.len().saturating_sub(8)..]
);
}
#[test]
#[cfg(unix)]
fn a_missing_git_binary_is_not_a_verification_failure() {
let diff = concat!(
"diff --git a/f b/f\n",
"--- a/f\n",
"+++ b/f\n",
"@@ -1 +1 @@\n",
"-old\n",
"+new\n",
);
let assert = Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1", "--verify-result-diff-git"])
.env("PATH", "/nonexistent")
.write_stdin(diff)
.assert()
.code(74);
assert.stderr(predicate::str::contains("git"));
}
#[test]
fn a_utf16_diff_is_diagnosed_as_an_encoding_problem() {
let text = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-old\n+new\n";
let mut utf16 = vec![0xFF, 0xFE];
for unit in text.encode_utf16() {
utf16.extend_from_slice(&unit.to_le_bytes());
}
let assert = Command::cargo_bin("hunkpick")
.unwrap()
.arg("list")
.write_stdin(utf16)
.assert()
.code(2);
assert.stderr(predicate::str::contains("UTF-16"));
}
#[test]
fn a_piped_diff_produces_no_terminal_hint() {
let diff = "diff --git a/f b/f\n--- a/f\n+++ b/f\n@@ -1 +1 @@\n-a\n+b\n";
Command::cargo_bin("hunkpick")
.unwrap()
.arg("list")
.write_stdin(diff)
.assert()
.success()
.stderr(predicate::str::is_empty());
}
#[test]
#[cfg(target_os = "linux")]
fn a_terminal_on_stdin_gets_a_hint() {
let exe = assert_cmd::cargo::cargo_bin("hunkpick");
let out = std::process::Command::new("script")
.args(["-qec", &format!("{} list", exe.display()), "/dev/null"])
.stdin(std::process::Stdio::null())
.output()
.expect("util-linux script provides the pty");
let seen = String::from_utf8_lossy(&out.stdout);
assert!(
seen.contains("reading a diff from the terminal"),
"the hint must reach the user before the read blocks: {seen:?}"
);
}