mod common;
use assert_cmd::Command;
use predicates::prelude::*;
const TWO_CHANGE_DIFF: &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_CHANGE_DIFF)
.assert()
.success()
.stdout(predicate::str::contains("+B"))
.stdout(predicate::str::contains("+D").not());
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "2"])
.write_stdin(TWO_CHANGE_DIFF)
.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_CHANGE_DIFF)
.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_CHANGE_DIFF)
.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_CHANGE_DIFF)
.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_CHANGE_DIFF)
.assert()
.success()
.get_output()
.stdout
.clone();
let text = std::str::from_utf8(&stdout).unwrap();
let at_count = text.matches("@@").count();
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: {at_count}"
);
}
#[test]
fn bad_selector_exits_2() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "nope:x"])
.write_stdin(TWO_CHANGE_DIFF)
.assert()
.failure()
.code(2);
}
#[test]
fn empty_selection_exits_2() {
Command::cargo_bin("hunkpick")
.unwrap()
.arg("select")
.write_stdin(TWO_CHANGE_DIFF)
.assert()
.failure()
.code(2);
}
#[test]
fn out_of_range_index_exits_2() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "9"])
.write_stdin(TWO_CHANGE_DIFF)
.assert()
.failure()
.code(2);
}
#[test]
fn dash_c_requires_git_flag() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1", "-C", "."])
.write_stdin(TWO_CHANGE_DIFF)
.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_CHANGE_DIFF)
.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_added_line_range_first_part() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1@1-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_added_line_range_open_end() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1@3-"])
.write_stdin(NEW_FILE_DIFF)
.assert()
.success()
.stdout(predicate::str::contains("+l3"))
.stdout(predicate::str::contains("+l4"))
.stdout(predicate::str::contains("+l2").not());
}
#[test]
fn select_range_out_of_range_is_usage_error() {
Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1@1-99"])
.write_stdin(NEW_FILE_DIFF)
.assert()
.failure()
.stderr(predicate::str::contains("out of range"));
}
#[test]
fn range_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 = {
let out = std::process::Command::new("git")
.args(["diff"])
.current_dir(dir.path())
.output()
.unwrap();
String::from_utf8(out.stdout).unwrap()
};
let part1 = Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1@1-2"])
.write_stdin(diff.clone())
.assert()
.success()
.get_output()
.stdout
.clone();
let mut apply = std::process::Command::new("git")
.args(["apply", "--cached"])
.current_dir(dir.path())
.stdin(std::process::Stdio::piped())
.spawn()
.unwrap();
use std::io::Write;
apply.stdin.take().unwrap().write_all(&part1).unwrap();
assert!(apply.wait().unwrap().success(), "first apply failed");
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}");
}