use std::{
collections::BTreeMap,
io::Write,
path::Path,
process::{Command, Output, Stdio},
};
use proptest::prelude::*;
use serde_json::Value;
use tempfile::TempDir;
use super::{heddle, heddle_output};
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Kind {
Normal,
Exec,
Symlink,
}
#[derive(Clone, Debug)]
struct Entry {
path: String,
body: Vec<u8>,
kind: Kind,
}
fn normal(path: &str, body: &str) -> Entry {
Entry {
path: path.to_string(),
body: body.as_bytes().to_vec(),
kind: Kind::Normal,
}
}
#[cfg(unix)]
fn exec(path: &str, body: &str) -> Entry {
Entry {
path: path.to_string(),
body: body.as_bytes().to_vec(),
kind: Kind::Exec,
}
}
#[cfg(unix)]
fn symlink(path: &str, target: &str) -> Entry {
Entry {
path: path.to_string(),
body: target.as_bytes().to_vec(),
kind: Kind::Symlink,
}
}
#[cfg(unix)]
fn symlink_bytes(path: &str, target: &[u8]) -> Entry {
Entry {
path: path.to_string(),
body: target.to_vec(),
kind: Kind::Symlink,
}
}
fn binary(path: &str, body: &[u8]) -> Entry {
Entry {
path: path.to_string(),
body: body.to_vec(),
kind: Kind::Normal,
}
}
#[cfg(unix)]
fn binary_exec(path: &str, body: &[u8]) -> Entry {
Entry {
path: path.to_string(),
body: body.to_vec(),
kind: Kind::Exec,
}
}
enum Expect {
Present(Entry),
Absent(&'static str),
}
#[cfg(unix)]
fn set_mode(path: &Path, mode: u32) {
use std::os::unix::fs::PermissionsExt;
let mut perms = std::fs::metadata(path).unwrap().permissions();
perms.set_mode(mode);
std::fs::set_permissions(path, perms).unwrap();
}
#[cfg(not(unix))]
fn set_mode(_: &Path, _: u32) {}
fn write_entry(dir: &Path, entry: &Entry) {
let full = dir.join(&entry.path);
if let Some(parent) = full.parent() {
std::fs::create_dir_all(parent).unwrap();
}
match entry.kind {
Kind::Symlink => {
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
let target = std::ffi::OsStr::from_bytes(&entry.body);
let _ = std::fs::remove_file(&full);
std::os::unix::fs::symlink(target, &full).unwrap();
}
#[cfg(not(unix))]
{
let _ = dir;
}
}
Kind::Normal => {
std::fs::write(&full, &entry.body).unwrap();
set_mode(&full, 0o644);
}
Kind::Exec => {
std::fs::write(&full, &entry.body).unwrap();
set_mode(&full, 0o755);
}
}
}
fn assert_present(dir: &Path, entry: &Entry) {
let full = dir.join(&entry.path);
let meta = std::fs::symlink_metadata(&full)
.unwrap_or_else(|err| panic!("expected `{}` to be present: {err}", entry.path));
match entry.kind {
Kind::Symlink => {
assert!(
meta.file_type().is_symlink(),
"`{}` should be a symlink after apply",
entry.path
);
let target = std::fs::read_link(&full).unwrap();
#[cfg(unix)]
{
use std::os::unix::ffi::OsStrExt;
assert_eq!(
target.as_os_str().as_bytes(),
entry.body.as_slice(),
"`{}` symlink target mismatch",
entry.path
);
}
#[cfg(not(unix))]
{
assert_eq!(
target.to_string_lossy().as_bytes(),
entry.body.as_slice(),
"`{}` symlink target mismatch",
entry.path
);
}
}
Kind::Exec => {
assert_eq!(
std::fs::read(&full).unwrap(),
entry.body,
"`{}` content mismatch",
entry.path
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert!(
meta.permissions().mode() & 0o111 != 0,
"`{}` should keep its executable bit after apply",
entry.path
);
}
}
Kind::Normal => {
assert_eq!(
std::fs::read(&full).unwrap(),
entry.body,
"`{}` content mismatch",
entry.path
);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert!(
meta.permissions().mode() & 0o111 == 0,
"`{}` should not be executable after apply",
entry.path
);
}
}
}
}
fn git(dir: &Path, args: &[&str]) {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.unwrap_or_else(|err| panic!("git {args:?} should run: {err}"));
assert!(status.success(), "git {args:?} should succeed");
}
fn git_init(dir: &Path) {
git(dir, &["init", "-q"]);
git(dir, &["config", "user.name", "Heddle Test"]);
git(dir, &["config", "user.email", "heddle@example.com"]);
git(dir, &["checkout", "-q", "-b", "main"]);
}
fn pipe_git(dir: &Path, args: &[&str], patch: &str) -> Output {
let mut child = Command::new("git")
.args(args)
.current_dir(dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("git should spawn");
child
.stdin
.as_mut()
.unwrap()
.write_all(patch.as_bytes())
.unwrap();
child.wait_with_output().expect("git should finish")
}
fn apply_oracle(pre: &[Entry], patch: &str, expect: &[Expect]) {
let g = TempDir::new().unwrap();
git_init(g.path());
for entry in pre {
write_entry(g.path(), entry);
}
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git(g.path(), &["apply", "--check"], patch);
assert!(
check.status.success(),
"git apply --check rejected the patch;\nstderr={}\npatch=\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
let applied = pipe_git(g.path(), &["apply"], patch);
assert!(
applied.status.success(),
"git apply failed;\nstderr={}\npatch=\n{patch}",
String::from_utf8_lossy(&applied.stderr)
);
for exp in expect {
match exp {
Expect::Present(entry) => assert_present(g.path(), entry),
Expect::Absent(path) => assert!(
!g.path().join(path).exists(),
"`{path}` should be gone after apply;\npatch=\n{patch}"
),
}
}
}
fn apply_refusal_oracle(pre: &[Entry], patch: &str) {
let g = TempDir::new().unwrap();
git_init(g.path());
for entry in pre {
write_entry(g.path(), entry);
}
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git(g.path(), &["apply", "--check"], patch);
assert!(
!check.status.success(),
"git apply --check accepted a patch carrying a binary content change; \
it must refuse rather than leave stale binary content (false round-trip);\npatch=\n{patch}"
);
}
fn json_patch_field(cwd: &Path) -> Option<String> {
json_diff_patch_field(cwd, &[])
}
fn json_diff_patch_field(cwd: &Path, extra: &[&str]) -> Option<String> {
let mut args = vec!["--output", "json", "diff"];
args.extend_from_slice(extra);
let out = heddle_output(&args, Some(cwd)).expect("heddle json diff");
assert!(
out.status.success(),
"heddle --output json diff should succeed; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let value: Value = serde_json::from_slice(&out.stdout).expect("diff output should be JSON");
value
.get("patch")
.and_then(Value::as_str)
.map(ToString::to_string)
}
fn json_diff_value(cwd: &Path, base: &[&str], mode: &[&str]) -> Value {
let mut args = vec!["--output", "json", "diff"];
args.extend_from_slice(base);
args.extend_from_slice(mode);
let out = heddle_output(&args, Some(cwd)).expect("heddle json diff");
assert!(
out.status.success(),
"heddle --output json diff {base:?} {mode:?} should succeed; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
serde_json::from_slice(&out.stdout).expect("diff output should be JSON")
}
fn change_signature(value: &Value) -> Vec<(String, String, String)> {
let entries: Vec<&Value> = match value.get("changes") {
Some(Value::Array(arr)) => arr.iter().collect(),
Some(Value::Object(map)) => ["modified", "added", "deleted"]
.iter()
.filter_map(|key| map.get(*key))
.filter_map(Value::as_array)
.flatten()
.collect(),
_ => Vec::new(),
};
let mut sig: Vec<(String, String, String)> = entries
.iter()
.map(|change| {
let field = |key| {
change
.get(key)
.and_then(Value::as_str)
.unwrap_or("")
.to_string()
};
(field("kind"), field("path"), field("old_path"))
})
.collect();
sig.sort();
sig
}
fn assert_modes_consistent(cwd: &Path, base: &[&str], patch_stdout: &str) {
let modes: [&[&str]; 4] = [&[], &["--stat"], &["--name-only"], &["--patch"]];
let reference = change_signature(&json_diff_value(cwd, base, &[]));
for mode in modes {
let value = json_diff_value(cwd, base, mode);
assert_eq!(
value.get("patch").and_then(Value::as_str),
Some(patch_stdout),
"`--output json diff {base:?} {mode:?}` must carry the same `.patch` as `--patch` stdout"
);
assert_eq!(
change_signature(&value),
reference,
"`--output json diff {base:?} {mode:?}` rename/type detection diverged from the default render"
);
}
}
fn native_cell(pre: &[Entry], mutate: impl Fn(&Path), expect: &[Expect]) {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in pre {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
mutate(h.path());
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
!patch.trim().is_empty(),
"native cell produced an empty patch (no change detected?)"
);
assert_modes_consistent(h.path(), &[], &patch);
apply_oracle(pre, &patch, expect);
}
fn state_cell(pre: &[Entry], mutate: impl Fn(&Path), expect: &[Expect]) {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in pre {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
mutate(h.path());
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
let patch = heddle(&["diff", "HEAD~1", "HEAD", "--patch"], Some(h.path())).unwrap();
assert!(
!patch.trim().is_empty(),
"state cell produced an empty patch (no change detected?)"
);
assert_modes_consistent(h.path(), &["HEAD~1", "HEAD"], &patch);
apply_oracle(pre, &patch, expect);
}
#[test]
fn add_nonempty_round_trips() {
native_cell(
&[normal("anchor.txt", "anchor\n")],
|dir| write_entry(dir, &normal("new.txt", "alpha\nbeta\n")),
&[Expect::Present(normal("new.txt", "alpha\nbeta\n"))],
);
}
#[test]
fn add_empty_round_trips() {
native_cell(
&[normal("anchor.txt", "anchor\n")],
|dir| write_entry(dir, &normal("empty.txt", "")),
&[Expect::Present(normal("empty.txt", ""))],
);
}
#[test]
fn add_no_trailing_newline_round_trips() {
native_cell(
&[normal("anchor.txt", "anchor\n")],
|dir| write_entry(dir, &normal("noeol.txt", "single line no eol")),
&[Expect::Present(normal("noeol.txt", "single line no eol"))],
);
}
#[cfg(unix)]
#[test]
fn add_executable_round_trips() {
native_cell(
&[normal("anchor.txt", "anchor\n")],
|dir| write_entry(dir, &exec("run.sh", "#!/bin/sh\necho hi\n")),
&[Expect::Present(exec("run.sh", "#!/bin/sh\necho hi\n"))],
);
}
#[cfg(unix)]
#[test]
fn add_symlink_round_trips() {
native_cell(
&[normal("anchor.txt", "anchor\n")],
|dir| write_entry(dir, &symlink("linky", "some/target/path")),
&[Expect::Present(symlink("linky", "some/target/path"))],
);
}
#[test]
fn delete_nonempty_round_trips() {
native_cell(
&[
normal("doomed.txt", "gamma\ndelta\n"),
normal("keep.txt", "keep\n"),
],
|dir| std::fs::remove_file(dir.join("doomed.txt")).unwrap(),
&[
Expect::Absent("doomed.txt"),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[test]
fn delete_empty_round_trips() {
native_cell(
&[normal("willdie.txt", ""), normal("keep.txt", "keep\n")],
|dir| std::fs::remove_file(dir.join("willdie.txt")).unwrap(),
&[
Expect::Absent("willdie.txt"),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[test]
fn delete_nested_round_trips() {
native_cell(
&[
normal("src/nested/file.txt", "alpha\nbeta\n"),
normal("keep.txt", "keep\n"),
],
|dir| std::fs::remove_file(dir.join("src/nested/file.txt")).unwrap(),
&[
Expect::Absent("src/nested/file.txt"),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[test]
fn modify_nonempty_round_trips() {
native_cell(
&[normal("f.txt", "l1\nl2\nl3\nl4\nl5\n")],
|dir| write_entry(dir, &normal("f.txt", "l1\nL2\nl3\nl4\nl5\n")),
&[Expect::Present(normal("f.txt", "l1\nL2\nl3\nl4\nl5\n"))],
);
}
#[test]
fn modify_old_side_lacks_newline_round_trips() {
native_cell(
&[normal("f.txt", "hello")],
|dir| write_entry(dir, &normal("f.txt", "hello\nmore\n")),
&[Expect::Present(normal("f.txt", "hello\nmore\n"))],
);
}
#[test]
fn modify_new_side_lacks_newline_round_trips() {
native_cell(
&[normal("f.txt", "hello\nmore\n")],
|dir| write_entry(dir, &normal("f.txt", "hello")),
&[Expect::Present(normal("f.txt", "hello"))],
);
}
#[test]
fn modify_newline_only_addition_round_trips() {
native_cell(
&[normal("f.txt", "hello")],
|dir| write_entry(dir, &normal("f.txt", "hello\n")),
&[Expect::Present(normal("f.txt", "hello\n"))],
);
}
#[test]
fn modify_newline_only_removal_round_trips() {
native_cell(
&[normal("f.txt", "hello\n")],
|dir| write_entry(dir, &normal("f.txt", "hello")),
&[Expect::Present(normal("f.txt", "hello"))],
);
}
#[cfg(unix)]
#[test]
fn chmod_add_exec_bit_round_trips() {
let body = "#!/bin/sh\necho hi\n";
native_cell(
&[normal("run.sh", body)],
|dir| set_mode(&dir.join("run.sh"), 0o755),
&[Expect::Present(exec("run.sh", body))],
);
}
#[cfg(unix)]
#[test]
fn chmod_remove_exec_bit_round_trips() {
let body = "#!/bin/sh\necho hi\n";
native_cell(
&[exec("run.sh", body)],
|dir| set_mode(&dir.join("run.sh"), 0o644),
&[Expect::Present(normal("run.sh", body))],
);
}
#[cfg(unix)]
#[test]
fn chmod_only_emits_header_only_patch() {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("run.sh", "#!/bin/sh\necho hi\n"));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
set_mode(&h.path().join("run.sh"), 0o755);
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("diff --git a/run.sh b/run.sh"),
"chmod patch must carry the `diff --git` header:\n{patch}"
);
assert!(
patch.contains("old mode 100644") && patch.contains("new mode 100755"),
"chmod patch must carry `old mode`/`new mode` headers:\n{patch}"
);
assert!(
!patch.contains("@@"),
"mode-only modify is header-only — no hunk body:\n{patch}"
);
}
fn capturable_quoting_paths() -> Vec<&'static str> {
vec![
"quo\"te.txt",
" leading.txt",
"trailing .txt",
"café_ünïcode.txt",
"dir with space/child.txt",
]
}
fn worktree_only_quoting_paths() -> Vec<&'static str> {
vec!["tab\tname.txt", "new\nline.txt", "back\\slash.txt"]
}
#[test]
fn special_char_path_add_round_trips() {
let mut paths = capturable_quoting_paths();
paths.extend(worktree_only_quoting_paths());
for path in paths {
native_cell(
&[normal("anchor.txt", "anchor\n")],
|dir| write_entry(dir, &normal(path, "alpha\nbeta\n")),
&[Expect::Present(normal(path, "alpha\nbeta\n"))],
);
}
}
#[test]
fn special_char_path_modify_round_trips() {
for path in capturable_quoting_paths() {
native_cell(
&[normal(path, "l1\nl2\nl3\n")],
|dir| write_entry(dir, &normal(path, "l1\nCHANGED\nl3\n")),
&[Expect::Present(normal(path, "l1\nCHANGED\nl3\n"))],
);
}
}
#[test]
fn special_char_path_delete_round_trips() {
for path in capturable_quoting_paths() {
native_cell(
&[
normal(path, "doomed\ncontent\n"),
normal("keep.txt", "keep\n"),
],
move |dir| std::fs::remove_file(dir.join(path)).unwrap(),
&[
Expect::Absent(path),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
}
#[test]
fn special_char_path_rename_round_trips() {
let body = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
native_cell(
&[normal("fro\"m.txt", body)],
|dir| {
std::fs::remove_file(dir.join("fro\"m.txt")).unwrap();
write_entry(dir, &normal("tö nÿ.txt", body));
},
&[
Expect::Absent("fro\"m.txt"),
Expect::Present(normal("tö nÿ.txt", body)),
],
);
}
#[test]
fn plain_git_special_char_path_modify_round_trips() {
for path in worktree_only_quoting_paths() {
plain_git_cell(
&[normal(path, "a\nb\nc\n")],
false,
move |dir| write_entry(dir, &normal(path, "a\nB\nc\n")),
&[Expect::Present(normal(path, "a\nB\nc\n"))],
);
}
}
#[test]
fn plain_git_special_char_path_delete_round_trips() {
for path in worktree_only_quoting_paths() {
plain_git_cell(
&[normal(path, "x\ny\n"), normal("keep.txt", "keep\n")],
true,
move |dir| std::fs::remove_file(dir.join(path)).unwrap(),
&[
Expect::Absent(path),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
}
#[test]
fn pure_rename_round_trips() {
let body = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
native_cell(
&[normal("from.txt", body)],
|dir| {
std::fs::remove_file(dir.join("from.txt")).unwrap();
write_entry(dir, &normal("to.txt", body));
},
&[
Expect::Absent("from.txt"),
Expect::Present(normal("to.txt", body)),
],
);
}
#[test]
fn rename_with_edit_round_trips() {
let before = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n";
let after = "l1\nl2\nCHANGED\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n";
native_cell(
&[normal("source.txt", before)],
|dir| {
std::fs::remove_file(dir.join("source.txt")).unwrap();
write_entry(dir, &normal("target.txt", after));
},
&[
Expect::Absent("source.txt"),
Expect::Present(normal("target.txt", after)),
],
);
}
#[test]
fn pure_rename_populates_json_patch_field() {
let body = "a\nb\nc\nd\ne\n";
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("from.txt", body));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::remove_file(h.path().join("from.txt")).unwrap();
write_entry(h.path(), &normal("to.txt", body));
let json_patch = json_patch_field(h.path()).expect("pure rename JSON must carry `.patch`");
assert!(
json_patch.contains("rename from from.txt") && json_patch.contains("rename to to.txt"),
"JSON `.patch` must carry the rename headers:\n{json_patch}"
);
apply_oracle(
&[normal("from.txt", body)],
&json_patch,
&[
Expect::Absent("from.txt"),
Expect::Present(normal("to.txt", body)),
],
);
}
#[cfg(unix)]
#[test]
fn rename_with_chmod_round_trips() {
let body = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
native_cell(
&[normal("old.sh", body)],
|dir| {
std::fs::remove_file(dir.join("old.sh")).unwrap();
write_entry(dir, &exec("new.sh", body));
},
&[
Expect::Absent("old.sh"),
Expect::Present(exec("new.sh", body)),
],
);
}
#[cfg(unix)]
#[test]
fn rename_with_chmod_emits_mode_headers() {
let body = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("old.sh", body));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::remove_file(h.path().join("old.sh")).unwrap();
write_entry(h.path(), &exec("new.sh", body));
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("rename from old.sh") && patch.contains("rename to new.sh"),
"rename+chmod must still emit the rename headers:\n{patch}"
);
assert!(
patch.contains("old mode 100644") && patch.contains("new mode 100755"),
"rename+chmod must carry `old mode`/`new mode`:\n{patch}"
);
let old_mode_idx = patch.find("old mode").unwrap();
let sim_idx = patch.find("similarity index").unwrap();
assert!(
old_mode_idx < sim_idx,
"`old mode` must precede `similarity index` (git order):\n{patch}"
);
}
#[cfg(unix)]
#[test]
fn rename_with_edit_and_chmod_round_trips() {
let before = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n";
let after = "l1\nl2\nCHANGED\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n";
native_cell(
&[normal("src.sh", before)],
|dir| {
std::fs::remove_file(dir.join("src.sh")).unwrap();
write_entry(dir, &exec("dst.sh", after));
},
&[
Expect::Absent("src.sh"),
Expect::Present(exec("dst.sh", after)),
],
);
}
#[cfg(unix)]
const NON_UTF8_TARGET: &[u8] = b"dest/\xff\xfe/link-target";
#[cfg(unix)]
#[test]
fn native_non_utf8_symlink_rename_round_trips() {
native_cell(
&[
symlink_bytes("from-link", NON_UTF8_TARGET),
normal("anchor.txt", "anchor\n"),
],
|dir| {
std::fs::remove_file(dir.join("from-link")).unwrap();
write_entry(dir, &symlink_bytes("to-link", NON_UTF8_TARGET));
},
&[
Expect::Absent("from-link"),
Expect::Present(symlink_bytes("to-link", NON_UTF8_TARGET)),
Expect::Present(normal("anchor.txt", "anchor\n")),
],
);
}
#[cfg(unix)]
#[test]
fn state_non_utf8_symlink_rename_round_trips() {
state_cell(
&[
symlink_bytes("from-link", NON_UTF8_TARGET),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("from-link")).unwrap();
write_entry(dir, &symlink_bytes("to-link", NON_UTF8_TARGET));
},
&[
Expect::Absent("from-link"),
Expect::Present(symlink_bytes("to-link", NON_UTF8_TARGET)),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
fn pipe_git_bytes(dir: &Path, args: &[&str], patch: &[u8]) -> Output {
let mut child = Command::new("git")
.args(args)
.current_dir(dir)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("git should spawn");
child.stdin.as_mut().unwrap().write_all(patch).unwrap();
child.wait_with_output().expect("git should finish")
}
fn apply_oracle_bytes(pre: &[Entry], patch: &[u8], expect: &[Expect]) {
let g = TempDir::new().unwrap();
git_init(g.path());
for entry in pre {
write_entry(g.path(), entry);
}
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git_bytes(g.path(), &["apply", "--check"], patch);
assert!(
check.status.success(),
"git apply --check rejected the patch;\nstderr={}\npatch=\n{}",
String::from_utf8_lossy(&check.stderr),
String::from_utf8_lossy(patch),
);
let applied = pipe_git_bytes(g.path(), &["apply"], patch);
assert!(
applied.status.success(),
"git apply failed;\nstderr={}\npatch=\n{}",
String::from_utf8_lossy(&applied.stderr),
String::from_utf8_lossy(patch),
);
for exp in expect {
match exp {
Expect::Present(entry) => assert_present(g.path(), entry),
Expect::Absent(path) => assert!(
!g.path().join(path).exists(),
"`{path}` should be gone after apply",
),
}
}
}
fn patch_bytes(args: &[&str], cwd: &Path) -> Vec<u8> {
let out = heddle_output(args, Some(cwd)).expect("heddle diff --patch");
assert!(
out.status.success(),
"heddle {args:?} should succeed; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
assert!(
!out.stdout.is_empty(),
"cell produced an empty patch (no change detected?)"
);
out.stdout
}
fn native_cell_bytes(pre: &[Entry], mutate: impl Fn(&Path), expect: &[Expect]) {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in pre {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
mutate(h.path());
let patch = patch_bytes(&["diff", "--patch"], h.path());
assert_modes_consistent(h.path(), &[], &String::from_utf8_lossy(&patch));
apply_oracle_bytes(pre, &patch, expect);
}
fn state_cell_bytes(pre: &[Entry], mutate: impl Fn(&Path), expect: &[Expect]) {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in pre {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
mutate(h.path());
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
let patch = patch_bytes(&["diff", "HEAD~1", "HEAD", "--patch"], h.path());
assert_modes_consistent(
h.path(),
&["HEAD~1", "HEAD"],
&String::from_utf8_lossy(&patch),
);
apply_oracle_bytes(pre, &patch, expect);
}
fn plain_git_cell_bytes(pre: &[Entry], stage: bool, mutate: impl Fn(&Path), expect: &[Expect]) {
let h = TempDir::new().unwrap();
git_init(h.path());
for entry in pre {
write_entry(h.path(), entry);
}
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "seed"]);
mutate(h.path());
if stage {
git(h.path(), &["add", "-A"]);
}
let patch = patch_bytes(&["diff", "--patch"], h.path());
assert_modes_consistent(h.path(), &[], &String::from_utf8_lossy(&patch));
apply_oracle_bytes(pre, &patch, expect);
}
#[cfg(unix)]
#[test]
fn native_non_utf8_symlink_add_round_trips() {
native_cell_bytes(
&[normal("anchor.txt", "anchor\n")],
|dir| write_entry(dir, &symlink_bytes("linky", NON_UTF8_TARGET)),
&[Expect::Present(symlink_bytes("linky", NON_UTF8_TARGET))],
);
}
#[cfg(unix)]
#[test]
fn state_non_utf8_symlink_add_round_trips() {
state_cell_bytes(
&[normal("keep.txt", "keep\n")],
|dir| write_entry(dir, &symlink_bytes("linky", NON_UTF8_TARGET)),
&[Expect::Present(symlink_bytes("linky", NON_UTF8_TARGET))],
);
}
#[cfg(unix)]
#[test]
fn plain_git_non_utf8_symlink_add_round_trips() {
plain_git_cell_bytes(
&[normal("anchor.txt", "anchor\n")],
true,
|dir| write_entry(dir, &symlink_bytes("linky", NON_UTF8_TARGET)),
&[Expect::Present(symlink_bytes("linky", NON_UTF8_TARGET))],
);
}
#[cfg(unix)]
#[test]
fn native_non_utf8_symlink_delete_round_trips() {
native_cell_bytes(
&[
symlink_bytes("doomed", NON_UTF8_TARGET),
normal("keep.txt", "keep\n"),
],
|dir| std::fs::remove_file(dir.join("doomed")).unwrap(),
&[
Expect::Absent("doomed"),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn state_non_utf8_symlink_delete_round_trips() {
state_cell_bytes(
&[
symlink_bytes("doomed", NON_UTF8_TARGET),
normal("keep.txt", "keep\n"),
],
|dir| std::fs::remove_file(dir.join("doomed")).unwrap(),
&[
Expect::Absent("doomed"),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn plain_git_non_utf8_symlink_delete_round_trips() {
plain_git_cell_bytes(
&[
symlink_bytes("doomed", NON_UTF8_TARGET),
normal("keep.txt", "keep\n"),
],
true,
|dir| std::fs::remove_file(dir.join("doomed")).unwrap(),
&[
Expect::Absent("doomed"),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
const NON_UTF8_TARGET_ALT: &[u8] = b"other/\xfe\xff/elsewhere";
#[cfg(unix)]
#[test]
fn native_non_utf8_symlink_edit_round_trips() {
native_cell_bytes(
&[symlink_bytes("linky", NON_UTF8_TARGET)],
|dir| write_entry(dir, &symlink_bytes("linky", NON_UTF8_TARGET_ALT)),
&[Expect::Present(symlink_bytes("linky", NON_UTF8_TARGET_ALT))],
);
}
#[cfg(unix)]
#[test]
fn state_non_utf8_symlink_edit_round_trips() {
state_cell_bytes(
&[symlink_bytes("linky", NON_UTF8_TARGET)],
|dir| write_entry(dir, &symlink_bytes("linky", NON_UTF8_TARGET_ALT)),
&[Expect::Present(symlink_bytes("linky", NON_UTF8_TARGET_ALT))],
);
}
#[cfg(unix)]
#[test]
fn plain_git_non_utf8_symlink_edit_round_trips() {
plain_git_cell_bytes(
&[symlink_bytes("linky", NON_UTF8_TARGET)],
false,
|dir| write_entry(dir, &symlink_bytes("linky", NON_UTF8_TARGET_ALT)),
&[Expect::Present(symlink_bytes("linky", NON_UTF8_TARGET_ALT))],
);
}
#[cfg(unix)]
#[test]
fn native_non_utf8_symlink_edit_to_utf8_round_trips() {
native_cell_bytes(
&[symlink_bytes("linky", NON_UTF8_TARGET)],
|dir| write_entry(dir, &symlink("linky", "plain/utf8/target")),
&[Expect::Present(symlink("linky", "plain/utf8/target"))],
);
}
#[cfg(unix)]
#[test]
fn native_regular_to_symlink_rename_candidate_stays_split() {
let shared = "shared payload\n";
native_cell(
&[normal("mover.txt", shared), normal("anchor.txt", shared)],
|dir| {
std::fs::remove_file(dir.join("mover.txt")).unwrap();
write_entry(dir, &symlink("linked", "anchor.txt"));
},
&[
Expect::Absent("mover.txt"),
Expect::Present(symlink("linked", "anchor.txt")),
Expect::Present(normal("anchor.txt", shared)),
],
);
}
#[cfg(unix)]
#[test]
fn state_regular_to_symlink_rename_candidate_stays_split() {
state_cell(
&[
normal("mover.txt", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("mover.txt")).unwrap();
write_entry(dir, &symlink("linked", "dest/dir/file"));
},
&[
Expect::Absent("mover.txt"),
Expect::Present(symlink("linked", "dest/dir/file")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn state_symlink_to_regular_rename_candidate_stays_split() {
state_cell(
&[
symlink("mover", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("mover")).unwrap();
write_entry(dir, &normal("landed.txt", "dest/dir/file"));
},
&[
Expect::Absent("mover"),
Expect::Present(normal("landed.txt", "dest/dir/file")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn state_regular_to_exec_rename_candidate_collapses() {
let body = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("old.sh", body));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::remove_file(h.path().join("old.sh")).unwrap();
write_entry(h.path(), &exec("new.sh", body));
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
let patch = heddle(&["diff", "HEAD~1", "HEAD", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("rename from old.sh") && patch.contains("rename to new.sh"),
"regular→exec move must still collapse into a rename:\n{patch}"
);
assert!(
patch.contains("old mode 100644") && patch.contains("new mode 100755"),
"regular→exec rename must carry the `old mode`/`new mode` pair:\n{patch}"
);
apply_oracle(
&[normal("old.sh", body)],
&patch,
&[
Expect::Absent("old.sh"),
Expect::Present(exec("new.sh", body)),
],
);
}
#[cfg(unix)]
#[test]
fn cross_type_rename_candidate_renders_as_split_not_rename() {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("mover.txt", "dest/dir/file"));
write_entry(h.path(), &normal("keep.txt", "keep\n"));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::remove_file(h.path().join("mover.txt")).unwrap();
write_entry(h.path(), &symlink("linked", "dest/dir/file"));
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
let patch = heddle(&["diff", "HEAD~1", "HEAD", "--patch"], Some(h.path())).unwrap();
assert!(
!patch.contains("rename from"),
"cross-type move must not collapse into a rename:\n{patch}"
);
assert!(
patch.contains("deleted file mode 100644") && patch.contains("new file mode 120000"),
"cross-type move must render as delete(100644) + add(120000):\n{patch}"
);
apply_oracle(
&[
normal("mover.txt", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
&patch,
&[
Expect::Absent("mover.txt"),
Expect::Present(symlink("linked", "dest/dir/file")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn plain_git_regular_to_symlink_rename_candidate_stays_split() {
plain_git_cell(
&[
normal("mover.txt", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
true,
|dir| {
std::fs::remove_file(dir.join("mover.txt")).unwrap();
write_entry(dir, &symlink("linked", "dest/dir/file"));
},
&[
Expect::Absent("mover.txt"),
Expect::Present(symlink("linked", "dest/dir/file")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn native_symlink_to_symlink_different_target_round_trips() {
native_cell(
&[
symlink("mover", "dest/dir/file"),
normal("anchor.txt", "dest/dir/file"),
],
|dir| {
std::fs::remove_file(dir.join("mover")).unwrap();
write_entry(dir, &symlink("moved", "anchor.txt"));
},
&[
Expect::Absent("mover"),
Expect::Present(symlink("moved", "anchor.txt")),
Expect::Present(normal("anchor.txt", "dest/dir/file")),
],
);
}
#[cfg(unix)]
#[test]
fn state_symlink_to_symlink_different_target_round_trips() {
state_cell(
&[
symlink("mover", "dest/dir/file"),
normal("anchor.txt", "dest/dir/file"),
],
|dir| {
std::fs::remove_file(dir.join("mover")).unwrap();
write_entry(dir, &symlink("moved", "anchor.txt"));
},
&[
Expect::Absent("mover"),
Expect::Present(symlink("moved", "anchor.txt")),
Expect::Present(normal("anchor.txt", "dest/dir/file")),
],
);
}
#[cfg(unix)]
#[test]
fn native_symlink_to_symlink_same_target_round_trips() {
native_cell(
&[symlink("mover", "shared/target/path")],
|dir| {
std::fs::remove_file(dir.join("mover")).unwrap();
write_entry(dir, &symlink("moved", "shared/target/path"));
},
&[
Expect::Absent("mover"),
Expect::Present(symlink("moved", "shared/target/path")),
],
);
}
#[cfg(unix)]
#[test]
fn state_symlink_to_symlink_same_target_round_trips() {
state_cell(
&[
symlink("mover", "shared/target/path"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("mover")).unwrap();
write_entry(dir, &symlink("moved", "shared/target/path"));
},
&[
Expect::Absent("mover"),
Expect::Present(symlink("moved", "shared/target/path")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn native_symlink_to_regular_rename_candidate_stays_split() {
native_cell(
&[
symlink("mover", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("mover")).unwrap();
write_entry(dir, &normal("landed.txt", "dest/dir/file"));
},
&[
Expect::Absent("mover"),
Expect::Present(normal("landed.txt", "dest/dir/file")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
struct StatusRenders {
default: String,
stat: String,
name_only: String,
}
fn status_renders(pre: &[Entry], mutate: impl Fn(&Path)) -> StatusRenders {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in pre {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
mutate(h.path());
StatusRenders {
default: heddle(&["diff"], Some(h.path())).unwrap(),
stat: heddle(&["diff", "--stat"], Some(h.path())).unwrap(),
name_only: heddle(&["diff", "--name-only"], Some(h.path())).unwrap(),
}
}
fn state_status_renders(pre: &[Entry], mutate: impl Fn(&Path)) -> StatusRenders {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in pre {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
mutate(h.path());
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
StatusRenders {
default: heddle(&["diff", "HEAD~1", "HEAD"], Some(h.path())).unwrap(),
stat: heddle(&["diff", "HEAD~1", "HEAD", "--stat"], Some(h.path())).unwrap(),
name_only: heddle(&["diff", "HEAD~1", "HEAD", "--name-only"], Some(h.path())).unwrap(),
}
}
fn assert_split_not_rename(renders: &StatusRenders, deleted: &str, added: &str) {
assert!(
!renders.default.contains("rename from"),
"default render must keep the cross-type move split, not a rename:\n{}",
renders.default
);
assert!(
!renders.stat.contains("renamed") && !renders.stat.contains(" -> "),
"--stat must keep the cross-type move split, not a rename:\n{}",
renders.stat
);
assert!(
renders.name_only.lines().any(|line| line == deleted)
&& renders.name_only.lines().any(|line| line == added),
"--name-only must list both `{deleted}` (deleted) and `{added}` (added), \
not collapse to one renamed path:\n{}",
renders.name_only
);
}
#[cfg(unix)]
#[test]
fn status_regular_to_symlink_rename_candidate_stays_split() {
let shared = "shared payload\n";
let renders = status_renders(
&[normal("mover.txt", shared), normal("anchor.txt", shared)],
|dir| {
std::fs::remove_file(dir.join("mover.txt")).unwrap();
write_entry(dir, &symlink("linked", "anchor.txt"));
},
);
assert_split_not_rename(&renders, "mover.txt", "linked");
}
#[cfg(unix)]
#[test]
fn state_status_regular_to_symlink_rename_candidate_stays_split() {
let renders = state_status_renders(
&[
normal("mover.txt", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("mover.txt")).unwrap();
write_entry(dir, &symlink("linked", "dest/dir/file"));
},
);
assert_split_not_rename(&renders, "mover.txt", "linked");
}
#[cfg(unix)]
#[test]
fn status_symlink_to_regular_rename_candidate_stays_split() {
let renders = status_renders(
&[
symlink("mover_link", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("mover_link")).unwrap();
write_entry(dir, &normal("newreg.txt", "dest/dir/file"));
},
);
assert_split_not_rename(&renders, "mover_link", "newreg.txt");
}
#[cfg(unix)]
#[test]
fn state_status_symlink_to_regular_rename_candidate_stays_split() {
let renders = state_status_renders(
&[
symlink("mover_link", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("mover_link")).unwrap();
write_entry(dir, &normal("newreg.txt", "dest/dir/file"));
},
);
assert_split_not_rename(&renders, "mover_link", "newreg.txt");
}
#[cfg(unix)]
#[test]
fn status_regular_to_exec_move_still_collapses_to_rename() {
let body = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
let renders = status_renders(&[normal("old.sh", body)], |dir| {
std::fs::remove_file(dir.join("old.sh")).unwrap();
write_entry(dir, &exec("new.sh", body));
});
assert!(
renders.stat.contains("renamed") && renders.stat.contains("old.sh -> new.sh"),
"--stat must show the regular→exec move as a rename:\n{}",
renders.stat
);
assert!(
renders.default.contains("rename from old.sh")
&& renders.default.contains("rename to new.sh"),
"default render must show the regular→exec move as a rename:\n{}",
renders.default
);
assert!(
renders.name_only.lines().any(|line| line == "new.sh")
&& !renders.name_only.lines().any(|line| line == "old.sh"),
"--name-only must list only the renamed-to path for a regular→exec move:\n{}",
renders.name_only
);
let patch = {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("old.sh", body));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::remove_file(h.path().join("old.sh")).unwrap();
write_entry(h.path(), &exec("new.sh", body));
heddle(&["diff", "--patch"], Some(h.path())).unwrap()
};
assert!(
patch.contains("old mode 100644") && patch.contains("new mode 100755"),
"rename+chmod patch must still carry the mode headers:\n{patch}"
);
apply_oracle(
&[normal("old.sh", body)],
&patch,
&[
Expect::Absent("old.sh"),
Expect::Present(exec("new.sh", body)),
],
);
}
#[cfg(unix)]
#[test]
fn status_same_path_regular_to_symlink_splits_not_modified() {
let renders = status_renders(
&[
normal("swap", "shared payload\n"),
normal("anchor.txt", "shared payload\n"),
],
|dir| {
std::fs::remove_file(dir.join("swap")).unwrap();
write_entry(dir, &symlink("swap", "anchor.txt"));
},
);
assert!(
renders.stat.contains("deleted")
&& renders.stat.contains("added")
&& !renders.stat.contains("modified")
&& !renders.stat.contains("renamed"),
"--stat must split a same-path type change into delete + add:\n{}",
renders.stat
);
assert_eq!(
renders
.name_only
.lines()
.filter(|line| *line == "swap")
.count(),
2,
"--name-only must list the split path twice (delete + add):\n{}",
renders.name_only
);
}
#[cfg(unix)]
fn git_overlay_status_renders(pre: &[Entry], mutate: impl Fn(&Path)) -> (StatusRenders, String) {
let h = TempDir::new().unwrap();
git_init(h.path());
for entry in pre {
write_entry(h.path(), entry);
}
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "seed"]);
heddle(&["adopt"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("unrelated_advance.txt", "advance\n"));
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "advance"]);
mutate(h.path());
let renders = StatusRenders {
default: heddle(&["diff"], Some(h.path())).unwrap(),
stat: heddle(&["diff", "--stat"], Some(h.path())).unwrap(),
name_only: heddle(&["diff", "--name-only"], Some(h.path())).unwrap(),
};
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
(renders, patch)
}
#[cfg(unix)]
#[test]
fn git_overlay_status_symlink_to_regular_rename_candidate_stays_split() {
let (renders, patch) = git_overlay_status_renders(
&[
symlink("mover_link", "dest/dir/file"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("mover_link")).unwrap();
write_entry(dir, &normal("newreg.txt", "dest/dir/file"));
},
);
assert!(
!patch.contains("rename from"),
"git-overlay --patch must keep the symlink→regular move split:\n{patch}"
);
assert_split_not_rename(&renders, "mover_link", "newreg.txt");
}
#[test]
fn file_to_dir_type_change_round_trips() {
native_cell(
&[normal("conf", "old config\n"), normal("keep.txt", "keep\n")],
|dir| {
std::fs::remove_file(dir.join("conf")).unwrap();
write_entry(dir, &normal("conf/nested.txt", "nested\nvalue\n"));
},
&[
Expect::Present(normal("conf/nested.txt", "nested\nvalue\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[test]
fn dir_to_file_type_change_round_trips() {
native_cell(
&[
normal("data/item.txt", "x\ny\n"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("data/item.txt")).unwrap();
std::fs::remove_dir(dir.join("data")).unwrap();
write_entry(dir, &normal("data", "now a file\n"));
},
&[
Expect::Present(normal("data", "now a file\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn regular_to_symlink_type_change_round_trips() {
native_cell(
&[
normal("node", "real contents\n"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("node")).unwrap();
write_entry(dir, &symlink("node", "some/target/path"));
},
&[
Expect::Present(symlink("node", "some/target/path")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn symlink_to_regular_type_change_round_trips() {
native_cell(
&[
symlink("node", "some/target/path"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("node")).unwrap();
write_entry(dir, &normal("node", "now real contents\n"));
},
&[
Expect::Present(normal("node", "now real contents\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn regular_to_symlink_pointing_at_dir_type_change_round_trips() {
native_cell(
&[
normal("node", "real contents\n"),
normal("realdir/keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("node")).unwrap();
write_entry(dir, &symlink("node", "realdir"));
},
&[
Expect::Present(symlink("node", "realdir")),
Expect::Present(normal("realdir/keep.txt", "keep\n")),
],
);
}
#[test]
fn state_file_to_dir_type_change_round_trips() {
state_cell(
&[normal("conf", "old config\n"), normal("keep.txt", "keep\n")],
|dir| {
std::fs::remove_file(dir.join("conf")).unwrap();
write_entry(dir, &normal("conf/nested.txt", "nested\nvalue\n"));
},
&[
Expect::Present(normal("conf/nested.txt", "nested\nvalue\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[test]
fn state_dir_to_file_type_change_round_trips() {
state_cell(
&[
normal("data/item.txt", "x\ny\n"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("data/item.txt")).unwrap();
std::fs::remove_dir(dir.join("data")).unwrap();
write_entry(dir, &normal("data", "now a file\n"));
},
&[
Expect::Present(normal("data", "now a file\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn state_regular_to_symlink_type_change_round_trips() {
state_cell(
&[
normal("node", "real contents\n"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("node")).unwrap();
write_entry(dir, &symlink("node", "some/target/path"));
},
&[
Expect::Present(symlink("node", "some/target/path")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn state_symlink_to_regular_type_change_round_trips() {
state_cell(
&[
symlink("node", "some/target/path"),
normal("keep.txt", "keep\n"),
],
|dir| {
std::fs::remove_file(dir.join("node")).unwrap();
write_entry(dir, &normal("node", "now real contents\n"));
},
&[
Expect::Present(normal("node", "now real contents\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[test]
fn binary_modify_emits_marker_and_is_refused() {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("data.bin", "text\n"));
write_entry(h.path(), &normal("notes.txt", "keep\n"));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::write(h.path().join("data.bin"), [0u8, 1, 2, 0, 255]).unwrap();
write_entry(h.path(), &normal("notes.txt", "edited\n"));
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("notes.txt") && patch.contains("+edited"),
"the text edit must still render:\n{patch}"
);
assert!(
patch.contains("Binary files a/data.bin and b/data.bin differ"),
"binary modify must emit git's `Binary files … differ` marker:\n{patch}"
);
assert!(
patch.contains("index 0000000..0000000"),
"binary marker needs the placeholder index line to force refusal:\n{patch}"
);
assert!(
!patch.contains("--- a/data.bin"),
"binary file must not be rendered as a text hunk:\n{patch}"
);
let json_patch = json_patch_field(h.path());
assert_eq!(
json_patch.as_deref(),
Some(patch.as_str()),
"JSON `.patch` must equal the `--patch` stdout for the binary case too"
);
apply_refusal_oracle(
&[normal("data.bin", "text\n"), normal("notes.txt", "keep\n")],
&patch,
);
}
#[cfg(unix)]
#[test]
fn binary_modify_with_chmod_is_refused() {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("data.bin", "text\n"));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::write(h.path().join("data.bin"), [0u8, 9, 8, 0, 7]).unwrap();
set_mode(&h.path().join("data.bin"), 0o755);
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("old mode 100644") && patch.contains("new mode 100755"),
"binary+chmod must carry the mode headers:\n{patch}"
);
assert!(
patch.contains("Binary files a/data.bin and b/data.bin differ")
&& patch.contains("index 0000000..0000000"),
"binary+chmod must still emit the binary marker + index, not a bare chmod:\n{patch}"
);
apply_refusal_oracle(&[normal("data.bin", "text\n")], &patch);
}
#[cfg(unix)]
#[test]
fn binary_pure_chmod_round_trips() {
let bytes: &[u8] = &[0u8, 1, 2, 0, 255, 0, 42];
native_cell(
&[binary("data.bin", bytes)],
|dir| set_mode(&dir.join("data.bin"), 0o755),
&[Expect::Present(binary_exec("data.bin", bytes))],
);
}
fn plain_git_cell(pre: &[Entry], stage: bool, mutate: impl Fn(&Path), expect: &[Expect]) {
let h = TempDir::new().unwrap();
git_init(h.path());
for entry in pre {
write_entry(h.path(), entry);
}
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "seed"]);
mutate(h.path());
if stage {
git(h.path(), &["add", "-A"]);
}
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
!patch.trim().is_empty(),
"plain-Git cell produced an empty patch"
);
assert_modes_consistent(h.path(), &[], &patch);
apply_oracle(pre, &patch, expect);
}
#[test]
fn plain_git_modify_round_trips() {
plain_git_cell(
&[normal("f.txt", "a\nb\nc\n")],
false,
|dir| write_entry(dir, &normal("f.txt", "a\nB\nc\n")),
&[Expect::Present(normal("f.txt", "a\nB\nc\n"))],
);
}
#[test]
fn plain_git_add_round_trips() {
plain_git_cell(
&[normal("anchor.txt", "anchor\n")],
true,
|dir| write_entry(dir, &normal("new.txt", "alpha\nbeta\n")),
&[Expect::Present(normal("new.txt", "alpha\nbeta\n"))],
);
}
#[test]
fn plain_git_delete_round_trips() {
plain_git_cell(
&[normal("doomed.txt", "x\ny\n"), normal("keep.txt", "keep\n")],
true,
|dir| std::fs::remove_file(dir.join("doomed.txt")).unwrap(),
&[
Expect::Absent("doomed.txt"),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn plain_git_chmod_round_trips() {
let body = "#!/bin/sh\necho hi\n";
plain_git_cell(
&[normal("run.sh", body)],
true,
|dir| set_mode(&dir.join("run.sh"), 0o755),
&[Expect::Present(exec("run.sh", body))],
);
}
#[cfg(unix)]
#[test]
fn plain_git_binary_pure_chmod_round_trips() {
let bytes: &[u8] = &[0u8, 1, 2, 0, 255, 0, 42];
plain_git_cell(
&[binary("data.bin", bytes)],
true,
|dir| set_mode(&dir.join("data.bin"), 0o755),
&[Expect::Present(binary_exec("data.bin", bytes))],
);
}
#[cfg(unix)]
#[test]
fn plain_git_binary_pure_chmod_emits_mode_only_patch() {
let h = TempDir::new().unwrap();
git_init(h.path());
write_entry(h.path(), &binary("data.bin", &[0u8, 1, 2, 0, 255]));
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "seed"]);
set_mode(&h.path().join("data.bin"), 0o755);
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("old mode 100644") && patch.contains("new mode 100755"),
"plain-Git binary chmod must carry `old mode`/`new mode`:\n{patch}"
);
assert!(
!patch.contains("Binary files") && !patch.contains("@@"),
"pure binary chmod is header-only — no binary marker, no hunk:\n{patch}"
);
}
#[cfg(unix)]
#[test]
fn plain_git_regular_to_symlink_type_change_round_trips() {
plain_git_cell(
&[
normal("node", "real contents\n"),
normal("keep.txt", "keep\n"),
],
false,
|dir| {
std::fs::remove_file(dir.join("node")).unwrap();
write_entry(dir, &symlink("node", "some/target/path"));
},
&[
Expect::Present(symlink("node", "some/target/path")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn plain_git_symlink_to_regular_type_change_round_trips() {
plain_git_cell(
&[
symlink("node", "some/target/path"),
normal("keep.txt", "keep\n"),
],
false,
|dir| {
std::fs::remove_file(dir.join("node")).unwrap();
write_entry(dir, &normal("node", "now real contents\n"));
},
&[
Expect::Present(normal("node", "now real contents\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[cfg(unix)]
#[test]
fn plain_git_regular_to_symlink_pointing_at_dir_type_change_round_trips() {
plain_git_cell(
&[
normal("node", "real contents\n"),
normal("realdir/keep.txt", "keep\n"),
],
false,
|dir| {
std::fs::remove_file(dir.join("node")).unwrap();
write_entry(dir, &symlink("node", "realdir"));
},
&[
Expect::Present(symlink("node", "realdir")),
Expect::Present(normal("realdir/keep.txt", "keep\n")),
],
);
}
#[test]
fn plain_git_file_to_dir_type_change_round_trips() {
plain_git_cell(
&[normal("conf", "old config\n"), normal("keep.txt", "keep\n")],
false,
|dir| {
std::fs::remove_file(dir.join("conf")).unwrap();
write_entry(dir, &normal("conf/nested.txt", "nested\nvalue\n"));
},
&[
Expect::Present(normal("conf/nested.txt", "nested\nvalue\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[test]
fn plain_git_dir_to_file_type_change_round_trips() {
plain_git_cell(
&[
normal("data/item.txt", "x\ny\n"),
normal("keep.txt", "keep\n"),
],
false,
|dir| {
std::fs::remove_file(dir.join("data/item.txt")).unwrap();
std::fs::remove_dir(dir.join("data")).unwrap();
write_entry(dir, &normal("data", "now a file\n"));
},
&[
Expect::Present(normal("data", "now a file\n")),
Expect::Present(normal("keep.txt", "keep\n")),
],
);
}
#[test]
fn plain_git_unborn_head_add_round_trips() {
let h = TempDir::new().unwrap();
git_init(h.path());
write_entry(h.path(), &normal("first.txt", "alpha\nbeta\n"));
git(h.path(), &["add", "first.txt"]);
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("new file mode 100644") && patch.contains("--- /dev/null"),
"unborn-HEAD add must carry the new-file header:\n{patch}"
);
apply_oracle(
&[normal("anchor.txt", "anchor\n")],
&patch,
&[Expect::Present(normal("first.txt", "alpha\nbeta\n"))],
);
}
#[test]
fn plain_git_rm_cached_then_edit_coalesces() {
let h = TempDir::new().unwrap();
git_init(h.path());
write_entry(h.path(), &normal("f.txt", "v1\nshared\ntail\n"));
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "seed"]);
git(h.path(), &["rm", "--cached", "-q", "f.txt"]);
write_entry(h.path(), &normal("f.txt", "v2\nshared\ntail\n"));
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("--- a/f.txt") && patch.contains("+++ b/f.txt"),
"same-path delete+add must coalesce into a single modify:\n{patch}"
);
assert!(
!patch.contains("/dev/null"),
"coalesced modify must not emit add/delete `/dev/null` headers:\n{patch}"
);
let json_patch = json_patch_field(h.path());
assert_eq!(
json_patch.as_deref(),
Some(patch.as_str()),
"plain-Git JSON `.patch` must equal `--patch` stdout"
);
apply_oracle(
&[normal("f.txt", "v1\nshared\ntail\n")],
&patch,
&[Expect::Present(normal("f.txt", "v2\nshared\ntail\n"))],
);
}
const DECORATION_PRE: &str = "mod m {}\n#[test]\nfn existing() {}\n";
const DECORATION_POST: &str =
"mod m {}\nfn h() {}\n#[test]\nfn added() {}\n#[test]\nfn existing() {}\n";
#[test]
fn native_added_decoration_before_identical_line_round_trips() {
native_cell(
&[normal("tests.rs", DECORATION_PRE)],
|dir| write_entry(dir, &normal("tests.rs", DECORATION_POST)),
&[Expect::Present(normal("tests.rs", DECORATION_POST))],
);
}
#[test]
fn state_added_decoration_before_identical_line_round_trips() {
state_cell(
&[normal("tests.rs", DECORATION_PRE)],
|dir| write_entry(dir, &normal("tests.rs", DECORATION_POST)),
&[Expect::Present(normal("tests.rs", DECORATION_POST))],
);
}
#[test]
fn plain_git_added_decoration_before_identical_line_round_trips() {
plain_git_cell(
&[normal("tests.rs", DECORATION_PRE)],
false,
|dir| write_entry(dir, &normal("tests.rs", DECORATION_POST)),
&[Expect::Present(normal("tests.rs", DECORATION_POST))],
);
}
#[test]
fn trust_visible_rename_with_edit_keeps_hunk() {
let baseline = "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n";
let edited = "l1\nl2\nCHANGED\nl4\nl5\nl6\nl7\nl8\nl9\nl10\n";
let h = TempDir::new().unwrap();
git_init(h.path());
write_entry(h.path(), &normal("source.txt", baseline));
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "seed"]);
heddle(&["adopt"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("source.txt", &format!("{baseline}l11\n")));
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "advance"]);
git(h.path(), &["rm", "-q", "source.txt"]);
write_entry(h.path(), &normal("target.txt", edited));
git(h.path(), &["add", "-A"]);
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("rename from source.txt") && patch.contains("rename to target.txt"),
"trust-visible rename must emit the rename headers:\n{patch}"
);
assert!(
patch.contains("-l3") && patch.contains("+CHANGED"),
"trust-visible rename+edit must keep its edit hunk:\n{patch}"
);
let json_patch = json_patch_field(h.path());
assert_eq!(
json_patch.as_deref(),
Some(patch.as_str()),
"trust-visible JSON `.patch` must equal `--patch` stdout"
);
apply_oracle(
&[normal("source.txt", baseline)],
&patch,
&[
Expect::Absent("source.txt"),
Expect::Present(normal("target.txt", edited)),
],
);
}
#[test]
fn state_to_state_add_round_trips() {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("base.txt", "base\n"));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("fresh.txt", "fresh\n"));
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
let patch = heddle(&["diff", "HEAD~1", "HEAD", "--patch"], Some(h.path())).unwrap();
assert!(
patch.contains("new file mode 100644") && patch.contains("+fresh"),
"state-to-state add must carry the new-file header + body:\n{patch}"
);
apply_oracle(
&[normal("base.txt", "base\n")],
&patch,
&[Expect::Present(normal("fresh.txt", "fresh\n"))],
);
}
#[test]
fn merge_with_diff_json_carries_patch() {
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("base.txt", "base\n"));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
heddle(&["thread", "create", "feature"], Some(h.path())).unwrap();
heddle(&["thread", "switch", "feature"], Some(h.path())).unwrap();
write_entry(h.path(), &normal("base.txt", "base\nfeature\n"));
write_entry(h.path(), &normal("new.txt", "new\n"));
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
heddle(&["thread", "switch", "main"], Some(h.path())).unwrap();
let out = heddle_output(
&[
"--output",
"json",
"merge",
"feature",
"--preview",
"--with-diff",
],
Some(h.path()),
)
.expect("merge --with-diff should run");
assert!(
out.status.success(),
"merge --with-diff --output json should succeed; stderr={}",
String::from_utf8_lossy(&out.stderr)
);
let parsed: Value = serde_json::from_slice(&out.stdout).expect("merge output should be JSON");
let patch = parsed["diff"]["patch"].as_str().unwrap_or_else(|| {
panic!("merge preview `.diff.patch` must be populated, not null: {parsed}")
});
assert!(
patch.contains("+feature") && patch.contains("new.txt"),
"embedded patch must carry the incoming hunks:\n{patch}"
);
apply_oracle(
&[normal("base.txt", "base\n")],
patch,
&[
Expect::Present(normal("base.txt", "base\nfeature\n")),
Expect::Present(normal("new.txt", "new\n")),
],
);
}
const NAME_POOL: &[&str] = &[
"a.txt",
"b.txt",
"sub/c.txt",
"d.txt",
"g h.txt",
"i\"j.txt",
"mün\u{f6}.txt",
];
fn content_strategy() -> impl Strategy<Value = String> {
(proptest::collection::vec("[a-z]{1,6}", 0..5), any::<bool>()).prop_map(|(lines, trailing)| {
let mut joined = lines.join("\n");
if !joined.is_empty() && trailing {
joined.push('\n');
}
joined
})
}
fn tree_strategy() -> impl Strategy<Value = BTreeMap<String, String>> {
proptest::collection::btree_map(
proptest::sample::select(NAME_POOL).prop_map(|name| name.to_string()),
content_strategy(),
1..=4,
)
}
proptest! {
#![proptest_config(ProptestConfig { cases: 24, ..ProptestConfig::default() })]
#[test]
fn diff_patch_round_trips_random_tree(
pre in tree_strategy(),
post in tree_strategy(),
) {
prop_assume!(pre != post);
let pre_entries: Vec<Entry> =
pre.iter().map(|(p, c)| normal(p, c)).collect();
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in &pre_entries {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
for name in pre.keys() {
if !post.contains_key(name) {
std::fs::remove_file(h.path().join(name)).ok();
}
}
for (name, content) in &post {
write_entry(h.path(), &normal(name, content));
}
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
prop_assert!(
!patch.trim().is_empty(),
"non-equal trees must produce a patch; pre={pre:?} post={post:?}"
);
assert_modes_consistent(h.path(), &[], &patch);
let g = TempDir::new().unwrap();
git_init(g.path());
for entry in &pre_entries {
write_entry(g.path(), entry);
}
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git(g.path(), &["apply", "--check"], &patch);
prop_assert!(
check.status.success(),
"git apply --check failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
let applied = pipe_git(g.path(), &["apply"], &patch);
prop_assert!(
applied.status.success(),
"git apply failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&applied.stderr)
);
for (name, content) in &post {
let got = std::fs::read(g.path().join(name)).unwrap();
prop_assert_eq!(
&got,
&content.as_bytes().to_vec(),
"content mismatch for {} after apply", name
);
}
for name in pre.keys() {
if !post.contains_key(name) {
prop_assert!(
!g.path().join(name).exists(),
"deleted path {} still present after apply", name
);
}
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 16, ..ProptestConfig::default() })]
#[test]
fn type_change_round_trips(
file_to_dir in any::<bool>(),
body_a in "[a-z]{1,8}\n",
body_b in "[a-z]{1,8}\n",
) {
let (pre, post_files): (Vec<Entry>, Vec<Entry>) = if file_to_dir {
(
vec![normal("node", &body_a), normal("anchor.txt", "anchor\n")],
vec![normal("node/leaf.txt", &body_b)],
)
} else {
(
vec![normal("node/leaf.txt", &body_a), normal("anchor.txt", "anchor\n")],
vec![normal("node", &body_b)],
)
};
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in &pre {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
if file_to_dir {
std::fs::remove_file(h.path().join("node")).unwrap();
} else {
std::fs::remove_file(h.path().join("node/leaf.txt")).unwrap();
std::fs::remove_dir(h.path().join("node")).unwrap();
}
for entry in &post_files {
write_entry(h.path(), entry);
}
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
prop_assert!(
!patch.trim().is_empty(),
"type change must produce a patch; file_to_dir={file_to_dir}"
);
let json_patch = json_patch_field(h.path());
prop_assert_eq!(json_patch.as_deref(), Some(patch.as_str()));
let g = TempDir::new().unwrap();
git_init(g.path());
for entry in &pre {
write_entry(g.path(), entry);
}
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git(g.path(), &["apply", "--check"], &patch);
prop_assert!(
check.status.success(),
"git apply --check failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
let applied = pipe_git(g.path(), &["apply"], &patch);
prop_assert!(
applied.status.success(),
"git apply failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&applied.stderr)
);
for entry in &post_files {
let got = std::fs::read(g.path().join(&entry.path)).unwrap();
prop_assert_eq!(
&got, &entry.body,
"content mismatch for {} after apply", entry.path
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 16, ..ProptestConfig::default() })]
#[cfg(unix)]
#[test]
fn symlink_type_change_round_trips(
file_to_link in any::<bool>(),
body in "[a-z]{1,12}\n",
target in "[a-z][a-z/]{0,18}[a-z]",
) {
let (pre, post): (Entry, Entry) = if file_to_link {
(normal("node", &body), symlink("node", &target))
} else {
(symlink("node", &target), normal("node", &body))
};
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &pre);
write_entry(h.path(), &normal("anchor.txt", "anchor\n"));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::remove_file(h.path().join("node")).unwrap();
write_entry(h.path(), &post);
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
prop_assert!(
!patch.trim().is_empty(),
"type change must produce a patch; file_to_link={file_to_link}"
);
let json_patch = json_patch_field(h.path());
prop_assert_eq!(json_patch.as_deref(), Some(patch.as_str()));
let g = TempDir::new().unwrap();
git_init(g.path());
write_entry(g.path(), &pre);
write_entry(g.path(), &normal("anchor.txt", "anchor\n"));
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git(g.path(), &["apply", "--check"], &patch);
prop_assert!(
check.status.success(),
"git apply --check failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
let applied = pipe_git(g.path(), &["apply"], &patch);
prop_assert!(
applied.status.success(),
"git apply failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&applied.stderr)
);
let meta = std::fs::symlink_metadata(g.path().join("node")).unwrap();
if file_to_link {
prop_assert!(
meta.file_type().is_symlink(),
"node should be a symlink after apply"
);
let link = std::fs::read_link(g.path().join("node")).unwrap();
let link = link.to_string_lossy();
prop_assert_eq!(link.as_bytes(), target.as_bytes());
} else {
prop_assert!(
!meta.file_type().is_symlink(),
"node should be a regular file after apply"
);
prop_assert_eq!(
std::fs::read(g.path().join("node")).unwrap(),
body.as_bytes().to_vec()
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 16, ..ProptestConfig::default() })]
#[cfg(unix)]
#[test]
fn plain_git_symlink_type_change_round_trips(
file_to_link in any::<bool>(),
body in "[a-z]{1,12}\n",
target in "[a-z][a-z/]{0,18}[a-z]",
) {
let (pre, post): (Entry, Entry) = if file_to_link {
(normal("node", &body), symlink("node", &target))
} else {
(symlink("node", &target), normal("node", &body))
};
let h = TempDir::new().unwrap();
git_init(h.path());
write_entry(h.path(), &pre);
write_entry(h.path(), &normal("anchor.txt", "anchor\n"));
git(h.path(), &["add", "-A"]);
git(h.path(), &["commit", "-q", "-m", "seed"]);
std::fs::remove_file(h.path().join("node")).unwrap();
write_entry(h.path(), &post);
let patch = heddle(&["diff", "--patch"], Some(h.path())).unwrap();
prop_assert!(
!patch.trim().is_empty(),
"plain-Git type change must produce a patch; file_to_link={file_to_link}"
);
let json_patch = json_patch_field(h.path());
prop_assert_eq!(json_patch.as_deref(), Some(patch.as_str()));
let g = TempDir::new().unwrap();
git_init(g.path());
write_entry(g.path(), &pre);
write_entry(g.path(), &normal("anchor.txt", "anchor\n"));
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git(g.path(), &["apply", "--check"], &patch);
prop_assert!(
check.status.success(),
"git apply --check failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
let applied = pipe_git(g.path(), &["apply"], &patch);
prop_assert!(
applied.status.success(),
"git apply failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&applied.stderr)
);
let meta = std::fs::symlink_metadata(g.path().join("node")).unwrap();
if file_to_link {
prop_assert!(
meta.file_type().is_symlink(),
"node should be a symlink after apply"
);
let link = std::fs::read_link(g.path().join("node")).unwrap();
let link = link.to_string_lossy();
prop_assert_eq!(link.as_bytes(), target.as_bytes());
} else {
prop_assert!(
!meta.file_type().is_symlink(),
"node should be a regular file after apply"
);
prop_assert_eq!(
std::fs::read(g.path().join("node")).unwrap(),
body.as_bytes().to_vec()
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 16, ..ProptestConfig::default() })]
#[cfg(unix)]
#[test]
fn cross_type_rename_candidate_stays_split(
regular_deleted in any::<bool>(),
shared in "[a-z][a-z/]{0,18}[a-z]",
) {
let (pre, post): (Entry, Entry) = if regular_deleted {
(normal("mover", &shared), symlink("landed", &shared))
} else {
(symlink("mover", &shared), normal("landed", &shared))
};
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
write_entry(h.path(), &pre);
write_entry(h.path(), &normal("anchor.txt", "anchor\n"));
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
std::fs::remove_file(h.path().join("mover")).unwrap();
write_entry(h.path(), &post);
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
let patch = heddle(&["diff", "HEAD~1", "HEAD", "--patch"], Some(h.path())).unwrap();
prop_assert!(!patch.trim().is_empty());
prop_assert!(
!patch.contains("rename from"),
"cross-type move collapsed into a rename:\n{patch}"
);
let json_patch = json_diff_patch_field(h.path(), &["HEAD~1", "HEAD"]);
prop_assert_eq!(json_patch.as_deref(), Some(patch.as_str()));
let g = TempDir::new().unwrap();
git_init(g.path());
write_entry(g.path(), &pre);
write_entry(g.path(), &normal("anchor.txt", "anchor\n"));
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git(g.path(), &["apply", "--check"], &patch);
prop_assert!(
check.status.success(),
"git apply --check failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
let applied = pipe_git(g.path(), &["apply"], &patch);
prop_assert!(
applied.status.success(),
"git apply failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&applied.stderr)
);
prop_assert!(
!g.path().join("mover").exists(),
"the moved-from path should be gone after apply"
);
let meta = std::fs::symlink_metadata(g.path().join("landed")).unwrap();
if regular_deleted {
prop_assert!(
meta.file_type().is_symlink(),
"landed should be a symlink after apply"
);
let link = std::fs::read_link(g.path().join("landed")).unwrap();
let link = link.to_string_lossy();
prop_assert_eq!(link.as_bytes(), shared.as_bytes());
} else {
prop_assert!(
!meta.file_type().is_symlink(),
"landed should be a regular file after apply"
);
prop_assert_eq!(
std::fs::read(g.path().join("landed")).unwrap(),
shared.as_bytes().to_vec()
);
}
}
}
proptest! {
#![proptest_config(ProptestConfig { cases: 24, ..ProptestConfig::default() })]
#[test]
fn state_diff_round_trips_random_tree(
pre in tree_strategy(),
post in tree_strategy(),
) {
prop_assume!(pre != post);
let pre_entries: Vec<Entry> =
pre.iter().map(|(p, c)| normal(p, c)).collect();
let h = TempDir::new().unwrap();
heddle(&["init"], Some(h.path())).unwrap();
for entry in &pre_entries {
write_entry(h.path(), entry);
}
heddle(&["capture", "-m", "v1"], Some(h.path())).unwrap();
for name in pre.keys() {
if !post.contains_key(name) {
std::fs::remove_file(h.path().join(name)).ok();
}
}
for (name, content) in &post {
write_entry(h.path(), &normal(name, content));
}
heddle(&["capture", "-m", "v2"], Some(h.path())).unwrap();
let patch = heddle(&["diff", "HEAD~1", "HEAD", "--patch"], Some(h.path())).unwrap();
prop_assert!(
!patch.trim().is_empty(),
"non-equal trees must produce a patch; pre={pre:?} post={post:?}"
);
let json_patch = json_diff_patch_field(h.path(), &["HEAD~1", "HEAD"]);
prop_assert_eq!(json_patch.as_deref(), Some(patch.as_str()));
let g = TempDir::new().unwrap();
git_init(g.path());
for entry in &pre_entries {
write_entry(g.path(), entry);
}
git(g.path(), &["add", "-A"]);
git(g.path(), &["commit", "-q", "-m", "seed"]);
let check = pipe_git(g.path(), &["apply", "--check"], &patch);
prop_assert!(
check.status.success(),
"git apply --check failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&check.stderr)
);
let applied = pipe_git(g.path(), &["apply"], &patch);
prop_assert!(
applied.status.success(),
"git apply failed: {}\npatch=\n{patch}",
String::from_utf8_lossy(&applied.stderr)
);
for (name, content) in &post {
let got = std::fs::read(g.path().join(name)).unwrap();
prop_assert_eq!(
&got,
&content.as_bytes().to_vec(),
"content mismatch for {} after apply", name
);
}
for name in pre.keys() {
if !post.contains_key(name) {
prop_assert!(
!g.path().join(name).exists(),
"deleted path {} still present after apply", name
);
}
}
}
}