mod common;
use assert_cmd::Command;
use common::{apply_cached, git_output, repo_with, revert, select_checked, sys};
use tempfile::TempDir;
#[cfg(not(windows))]
const DEFAULT_CASES: u64 = 200;
#[cfg(windows)]
const DEFAULT_CASES: u64 = 30;
#[cfg(not(windows))]
const DEFAULT_STAGING_CASES: u64 = 40;
#[cfg(windows)]
const DEFAULT_STAGING_CASES: u64 = 8;
fn cases() -> u64 {
std::env::var("HUNKPICK_DIFF_CASES")
.ok()
.and_then(|v| v.parse::<u64>().ok())
.filter(|&n| n > 0)
.unwrap_or(DEFAULT_CASES)
}
fn staging_cases() -> u64 {
(cases() * DEFAULT_STAGING_CASES / DEFAULT_CASES).max(1)
}
struct Rng(u64);
impl Rng {
fn new(seed: u64) -> Self {
Rng(seed.wrapping_mul(0x9E3779B97F4A7C15).wrapping_add(1))
}
fn next(&mut self) -> u64 {
self.0 = self.0.wrapping_add(0x9E3779B97F4A7C15);
let mut z = self.0;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
z ^ (z >> 31)
}
fn below(&mut self, n: usize) -> usize {
(self.next() % n as u64) as usize
}
}
fn random_case(seed: u64) -> (String, String) {
let mut rng = Rng::new(seed);
let n = 8 + rng.below(13);
let base: Vec<String> = (1..=n).map(|i| format!("line {i}")).collect();
let mut target = base.clone();
for _ in 0..1 + rng.below(4) {
if target.is_empty() {
target.push(format!("added {}", rng.below(1000)));
continue;
}
let at = rng.below(target.len());
match rng.below(4) {
0 => target[at] = format!("changed {}", rng.below(1000)),
1 => {
target.remove(at);
}
2 => target.insert(at, format!("inserted {}", rng.below(1000))),
_ => target.push(format!("appended {}", rng.below(1000))),
}
}
(join(&base), join(&target))
}
fn join(lines: &[String]) -> String {
lines.iter().map(|l| format!("{l}\n")).collect()
}
fn case_diff(dir: &TempDir, seed: u64) -> String {
let (base, target) = random_case(seed);
std::fs::write(dir.path().join("f"), &base).unwrap();
sys(dir, &["add", "f"]);
sys(dir, &["commit", "-q", "-m", "case", "--allow-empty"]);
std::fs::write(dir.path().join("f"), &target).unwrap();
let diff = git_output(dir, &["diff", "--", "f"]);
revert(dir);
diff
}
fn sub_hunk_count(diff: &str) -> usize {
let out = Command::cargo_bin("hunkpick")
.unwrap()
.args(["list", "--json"])
.write_stdin(diff.to_string())
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
json.as_array().unwrap()[0]["hunks"]
.as_array()
.unwrap()
.len()
}
#[test]
fn selecting_everything_reproduces_the_target() {
let dir = repo_with(&[]);
for seed in 0..cases() {
let (_, target) = random_case(seed);
let diff = case_diff(&dir, seed);
if diff.is_empty() {
continue; }
let out = select_checked(&dir, &diff, &["*"])
.try_success()
.unwrap_or_else(|e| panic!("seed {seed}: select '*' failed: {e}"))
.get_output()
.stdout
.clone();
apply_to_worktree(&dir, &out, seed);
let got = std::fs::read_to_string(dir.path().join("f")).unwrap();
assert_eq!(got, target, "seed {seed}: applying every sub-hunk");
revert(&dir);
}
}
#[test]
fn every_random_subset_applies() {
let dir = repo_with(&[]);
for seed in 0..cases() {
let diff = case_diff(&dir, seed);
if diff.is_empty() {
continue;
}
let count = sub_hunk_count(&diff);
let mut rng = Rng::new(seed ^ 0xA5A5_A5A5);
let subset: Vec<String> = (1..=count)
.filter(|_| rng.next() % 2 == 0)
.map(|i| i.to_string())
.collect();
if subset.is_empty() {
continue;
}
let args: Vec<&str> = subset.iter().map(String::as_str).collect();
select_checked(&dir, &diff, &args)
.try_success()
.unwrap_or_else(|e| panic!("seed {seed}: subset {subset:?} failed: {e}"));
}
}
#[test]
fn staging_one_sub_hunk_at_a_time_converges_on_the_target() {
let dir = repo_with(&[]);
for seed in 0..staging_cases() {
let (_, target) = random_case(seed);
let diff = case_diff(&dir, seed);
if diff.is_empty() {
continue;
}
std::fs::write(dir.path().join("f"), &target).unwrap();
let mut rounds = 0;
loop {
let diff = git_output(&dir, &["diff", "--", "f"]);
if diff.is_empty() {
break;
}
rounds += 1;
assert!(rounds <= 64, "seed {seed}: staging does not converge");
let out = Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "1"])
.write_stdin(diff)
.assert()
.try_success()
.unwrap_or_else(|e| panic!("seed {seed}: round {rounds}: {e}"))
.get_output()
.stdout
.clone();
apply_cached(&dir, &out);
}
let staged = git_output(&dir, &["show", ":f"]);
assert_eq!(
staged, target,
"seed {seed}: staged content after {rounds} rounds"
);
sys(&dir, &["commit", "-q", "-m", "staged"]);
}
}
#[test]
fn a_selection_is_valid_input_and_a_fixed_point() {
let dir = repo_with(&[]);
for seed in 0..cases() {
let diff = case_diff(&dir, seed);
if diff.is_empty() {
continue;
}
let once = hunkpick_select_all(&diff);
Command::cargo_bin("hunkpick")
.unwrap()
.arg("list")
.write_stdin(once.clone())
.assert()
.try_success()
.unwrap_or_else(|e| panic!("seed {seed}: own output rejected as input: {e}"));
let twice = hunkpick_select_all(&once);
assert_eq!(twice, once, "seed {seed}: selecting everything twice");
}
}
fn hunkpick_select_all(diff: &str) -> String {
let out = Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", "*"])
.write_stdin(diff.to_string())
.assert()
.success()
.get_output()
.stdout
.clone();
String::from_utf8(out).unwrap()
}
fn apply_to_worktree(dir: &TempDir, diff: &[u8], seed: u64) {
common::apply_diff(dir, &["apply"], diff, &format!("seed {seed}: git apply"));
}
fn multi_file_case(dir: &TempDir, seed: u64) -> String {
let mut rng = Rng::new(seed ^ 0x5EED_1234);
let names = ["a.rs", "dir/naïve.txt", "bin.dat"];
let base: Vec<String> = names
.iter()
.map(|_| {
let n = 6 + rng.below(6);
(1..=n).map(|i| format!("line {i}\n")).collect::<String>()
})
.collect();
for (name, content) in names.iter().zip(&base) {
let full = dir.path().join(name);
std::fs::create_dir_all(full.parent().unwrap()).unwrap();
std::fs::write(full, content).unwrap();
}
std::fs::write(dir.path().join("bin.dat"), [0u8, 1, 2, 3, 4]).unwrap();
sys(dir, &["add", "."]);
sys(dir, &["commit", "-q", "-m", "case", "--allow-empty"]);
for (name, content) in names.iter().take(2).zip(&base) {
let edited: String = content
.lines()
.map(|l| {
if rng.next() % 3 == 0 {
format!("changed {}\n", rng.below(1000))
} else {
format!("{l}\n")
}
})
.collect();
std::fs::write(dir.path().join(name), edited).unwrap();
}
std::fs::write(dir.path().join("bin.dat"), [0u8, 9, 9, 9, 9]).unwrap();
let diff = git_output(dir, &["diff", "--binary"]);
revert(dir);
diff
}
#[test]
fn a_multi_file_diff_with_a_binary_and_a_non_ascii_path_round_trips() {
let dir = repo_with(&[]);
for seed in 0..staging_cases() {
let diff = multi_file_case(&dir, seed);
if diff.is_empty() {
continue;
}
let selectors: Vec<String> = listed_paths(&diff)
.into_iter()
.map(|p| format!("{p}:*"))
.collect();
if selectors.is_empty() {
continue;
}
let out = Command::cargo_bin("hunkpick")
.unwrap()
.arg("select")
.args(&selectors)
.write_stdin(diff.clone())
.assert()
.try_success()
.unwrap_or_else(|e| panic!("seed {seed}: selecting every entry failed: {e}"))
.get_output()
.stdout
.clone();
apply_to_worktree(&dir, &out, seed);
revert(&dir);
sys(&dir, &["commit", "-q", "-m", "round", "--allow-empty"]);
}
}
fn listed_paths(diff: &str) -> Vec<String> {
let out = Command::cargo_bin("hunkpick")
.unwrap()
.args(["list", "--json"])
.write_stdin(diff.to_string())
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
json.as_array()
.unwrap()
.iter()
.map(|f| f["path"].as_str().unwrap().to_string())
.collect()
}
fn interior_context_lines(diff: &str) -> Vec<u32> {
let mut out = Vec::new();
let mut new_line = 0u32;
let mut pending: Option<u32> = None;
for line in diff.lines() {
if let Some(rest) = line.strip_prefix("@@ ") {
let plus = rest.split('+').nth(1).unwrap_or("");
let num = plus.split([',', ' ']).next().unwrap_or("0");
new_line = num.parse().unwrap_or(0);
pending = None;
continue;
}
match line.as_bytes().first() {
Some(b' ') => {
if let Some(candidate) = pending.take() {
out.push(candidate);
}
if new_line > 0 {
pending = Some(new_line);
}
new_line += 1;
}
Some(b'+') => {
if let Some(candidate) = pending.take() {
out.push(candidate);
}
new_line += 1;
}
Some(b'-') => {
if let Some(candidate) = pending.take() {
out.push(candidate);
}
}
_ => pending = None,
}
}
out
}
fn first_multi_line_sub_hunk(diff: &str) -> Option<(usize, Vec<usize>)> {
let out = Command::cargo_bin("hunkpick")
.unwrap()
.args(["list", "--json"])
.write_stdin(diff.to_string())
.assert()
.success()
.get_output()
.stdout
.clone();
let json: serde_json::Value = serde_json::from_slice(&out).unwrap();
for hunk in json.as_array()?[0]["hunks"].as_array()? {
let lines: Vec<usize> = hunk["changed_lines"]
.as_array()?
.iter()
.map(|l| l["i"].as_u64().unwrap() as usize)
.collect();
if lines.len() > 1 {
return Some((hunk["index"].as_u64().unwrap() as usize, lines));
}
}
None
}
#[test]
fn line_slices_and_splits_apply_via_git() {
let dir = repo_with(&[]);
let (mut sliced, mut split) = (0u32, 0u32);
for seed in 0..staging_cases() {
let diff = case_diff(&dir, seed);
if diff.is_empty() {
continue;
}
if let Some((index, lines)) = first_multi_line_sub_hunk(&diff) {
let mut rng = Rng::new(seed ^ 0x11AA_22BB);
let mut subset: Vec<String> = lines
.iter()
.filter(|_| rng.next() % 2 == 0)
.map(|i| i.to_string())
.collect();
if subset.is_empty() {
subset.push(lines[0].to_string());
}
let selector = format!("{index}@L{}", subset.join(","));
let out = Command::cargo_bin("hunkpick")
.unwrap()
.args(["select", &selector])
.write_stdin(diff.clone())
.assert()
.try_success()
.unwrap_or_else(|e| panic!("seed {seed}: select {selector} failed: {e}"))
.get_output()
.stdout
.clone();
common::apply_diff(
&dir,
&["apply", "--check"],
&out,
&format!("seed {seed}: git apply --check of {selector}"),
);
sliced += 1;
}
if let Some(&at) = interior_context_lines(&diff).first() {
let out = Command::cargo_bin("hunkpick")
.unwrap()
.args(["split", "1", "--at", &at.to_string()])
.write_stdin(diff.clone())
.assert()
.try_success()
.unwrap_or_else(|e| panic!("seed {seed}: split at {at} failed: {e}"))
.get_output()
.stdout
.clone();
common::apply_diff(
&dir,
&["apply", "--check"],
&out,
&format!("seed {seed}: git apply --check of split at {at}"),
);
split += 1;
}
}
assert!(
sliced > 0 && split > 0,
"the generator stopped producing sliceable/splittable diffs: {sliced} slices, {split} splits"
);
}