#![allow(
clippy::too_many_lines,
clippy::similar_names,
clippy::items_after_statements,
clippy::manual_let_else,
clippy::doc_markdown,
clippy::manual_split_once,
clippy::needless_splitn
)]
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::{Path, PathBuf};
use std::process::{Command, Output};
fn snapdir_bin() -> PathBuf {
assert_cmd::cargo::cargo_bin("snapdir")
}
fn temp_dir(tag: &str) -> PathBuf {
let mut dir = std::env::temp_dir();
dir.push(format!(
"snapdir-diff-{tag}-{}-{:?}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
fn file_url(dir: &Path) -> String {
format!("file://{}", dir.display())
}
fn run_raw(args: &[&str], cache: &Path, extra_env: &[(&str, &str)]) -> Output {
let mut cmd = Command::new(snapdir_bin());
cmd.args(args)
.env("SNAPDIR_CACHE_DIR", cache)
.env_remove("SNAPDIR_STORE")
.env_remove("SNAPDIR_OBJECTS_STORE");
for (k, v) in extra_env {
cmd.env(k, v);
}
cmd.output().expect("run snapdir")
}
fn run_ok(args: &[&str], cache: &Path, extra_env: &[(&str, &str)]) -> String {
let out = run_raw(args, cache, extra_env);
assert!(
out.status.success(),
"snapdir {args:?} exited {:?}\nstderr: {}",
out.status.code(),
String::from_utf8_lossy(&out.stderr),
);
String::from_utf8(out.stdout)
.expect("stdout is UTF-8")
.trim_end()
.to_owned()
}
fn stderr_of(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).into_owned()
}
fn json_str_field<'a>(fragment: &'a str, key: &str) -> Option<&'a str> {
let needle = format!("\"{key}\":");
let start = fragment.find(&needle)? + needle.len();
let rest = fragment[start..].trim_start();
let stripped = rest.strip_prefix('"')?;
let end = stripped.find('"')?;
Some(&stripped[..end])
}
fn json_array_objects(json: &str) -> Vec<String> {
let trimmed = json.trim();
assert!(
trimmed.starts_with('[') && trimmed.ends_with(']'),
"--json must be a JSON array; got:\n{json}"
);
let inner = &trimmed[1..trimmed.len() - 1];
let mut objs = Vec::new();
let mut depth = 0i32;
let mut in_str = false;
let mut escaped = false;
let mut start = None;
for (i, ch) in inner.char_indices() {
if in_str {
if escaped {
escaped = false;
} else if ch == '\\' {
escaped = true;
} else if ch == '"' {
in_str = false;
}
continue;
}
match ch {
'"' => in_str = true,
'{' => {
if depth == 0 {
start = Some(i);
}
depth += 1;
}
'}' => {
depth -= 1;
if depth == 0 {
let s = start.take().unwrap();
objs.push(inner[s..=i].to_owned());
}
}
_ => {}
}
}
assert_eq!(depth, 0, "unbalanced braces in --json output:\n{json}");
objs
}
fn build_tree(dir: &Path, leaves: &[(&str, &[u8], u32)]) {
for (rel, bytes, mode) in leaves {
let path = dir.join(rel);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&path, bytes).unwrap();
fs::set_permissions(&path, fs::Permissions::from_mode(*mode)).unwrap();
}
set_dir_perms_recursive(dir);
}
fn set_dir_perms_recursive(dir: &Path) {
fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap();
for entry in fs::read_dir(dir).unwrap().flatten() {
if entry.file_type().unwrap().is_dir() {
set_dir_perms_recursive(&entry.path());
}
}
}
fn capture(tag: &str, cache: &Path, leaves: &[(&str, &[u8], u32)]) -> (PathBuf, String, String) {
let src = temp_dir(&format!("{tag}-src"));
let store = temp_dir(&format!("{tag}-store"));
build_tree(&src, leaves);
let src_str = src.to_string_lossy().into_owned();
let store_url = file_url(&store);
let id = run_ok(&["push", "--store", &store_url, &src_str], cache, &[]);
assert_eq!(id.len(), 64, "snapshot id is 64 hex chars");
fs::remove_dir_all(&src).ok();
(store, store_url, id)
}
fn capture_into(tag: &str, cache: &Path, store_url: &str, leaves: &[(&str, &[u8], u32)]) -> String {
let src = temp_dir(&format!("{tag}-src"));
build_tree(&src, leaves);
let src_str = src.to_string_lossy().into_owned();
let id = run_ok(&["push", "--store", store_url, &src_str], cache, &[]);
fs::remove_dir_all(&src).ok();
id
}
fn sabotage_objects_pool(store: &Path) {
let objects = store.join(".objects");
if objects.exists() {
fs::remove_dir_all(&objects).ok();
}
fs::write(&objects, b"NOT-AN-OBJECT-POOL\x00\xff garbage").unwrap();
}
fn parse_porcelain(stdout: &str) -> Vec<(String, String)> {
let mut out: Vec<(String, String)> = stdout
.lines()
.filter(|l| !l.is_empty())
.map(|line| {
let mut it = line.splitn(2, '\t');
let status = it.next().unwrap_or("").to_owned();
let path = it
.next()
.unwrap_or_else(|| panic!("porcelain line {line:?} must be 'X\\t<path>'"))
.to_owned();
assert!(
status == "A" || status == "D" || status == "M",
"status letter must be one of A|D|M, got {status:?} in {line:?}"
);
(status, path)
})
.collect();
out.sort();
out
}
fn assert_porcelain_eq(stdout: &str, expected: &[(&str, &str)]) {
let raw_paths: Vec<&str> = stdout
.lines()
.filter(|l| !l.is_empty())
.map(|l| l.splitn(2, '\t').nth(1).expect("tab-separated path"))
.collect();
let mut sorted_paths = raw_paths.clone();
sorted_paths.sort_unstable();
assert_eq!(
raw_paths, sorted_paths,
"porcelain output MUST be sorted by path; got {raw_paths:?}"
);
let got = parse_porcelain(stdout);
let mut want: Vec<(String, String)> = expected
.iter()
.map(|(s, p)| ((*s).to_owned(), (*p).to_owned()))
.collect();
want.sort();
assert_eq!(
got, want,
"porcelain diff mismatch.\n got: {got:?}\nwant: {want:?}\nraw stdout:\n{stdout}"
);
}
#[test]
fn classifies_added_deleted_modified_and_hides_equal() {
let cache = temp_dir("adm-cache");
let (_from_store, from_url, _from_id) = capture(
"adm-from",
&cache,
&[
("keep.txt", b"same", 0o644),
("gone.txt", b"removed in TO", 0o644),
("changed.txt", b"version one", 0o644),
],
);
let (_to_store, to_url, _to_id) = capture(
"adm-to",
&cache,
&[
("keep.txt", b"same", 0o644),
("changed.txt", b"version TWO is longer", 0o644),
("new.txt", b"brand new", 0o644),
],
);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_porcelain_eq(
&stdout,
&[
("M", "./changed.txt"),
("D", "./gone.txt"),
("A", "./new.txt"),
],
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn all_flag_includes_unchanged_paths() {
let cache = temp_dir("all-cache");
let (_from_store, from_url, _from_id) = capture(
"all-from",
&cache,
&[("keep.txt", b"identical", 0o644), ("drop.txt", b"x", 0o644)],
);
let (_to_store, to_url, _to_id) = capture(
"all-to",
&cache,
&[("keep.txt", b"identical", 0o644), ("add.txt", b"y", 0o644)],
);
let plain = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert!(
!plain.contains("keep.txt"),
"unchanged keep.txt must be HIDDEN without --all; got:\n{plain}"
);
assert!(plain.contains("drop.txt") && plain.contains("add.txt"));
let all = run_ok(
&["diff", "--from", &from_url, "--to", &to_url, "--all"],
&cache,
&[],
);
assert!(
all.contains("keep.txt"),
"--all must INCLUDE the unchanged keep.txt; got:\n{all}"
);
assert!(
all.lines()
.any(|l| l.ends_with("./drop.txt") && l.starts_with('D')),
"drop.txt must still be D under --all; got:\n{all}"
);
assert!(
all.lines()
.any(|l| l.ends_with("./add.txt") && l.starts_with('A')),
"add.txt must still be A under --all; got:\n{all}"
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn diff_reads_manifests_only_ignores_bogus_objects_pool() {
let cache = temp_dir("mo-cache");
let (from_store, from_url, _fid) = capture(
"mo-from",
&cache,
&[
("keep.txt", b"same", 0o644),
("old.txt", b"old only", 0o644),
],
);
let (to_store, to_url, _tid) = capture(
"mo-to",
&cache,
&[
("keep.txt", b"same", 0o644),
("new.txt", b"new only", 0o644),
],
);
sabotage_objects_pool(&from_store);
sabotage_objects_pool(&to_store);
let fresh_cache = temp_dir("mo-freshcache");
let out = run_raw(
&["diff", "--from", &from_url, "--to", &to_url],
&fresh_cache,
&[],
);
assert!(
out.status.success(),
"diff MUST succeed against a manifest store with a bogus/absent .objects \
pool (it reads manifests only); exited {:?}\nstderr: {}",
out.status.code(),
stderr_of(&out),
);
let stdout = String::from_utf8(out.stdout).expect("utf8");
assert_porcelain_eq(&stdout, &[("D", "./old.txt"), ("A", "./new.txt")]);
fs::remove_dir_all(&cache).ok();
fs::remove_dir_all(&fresh_cache).ok();
}
#[test]
fn diff_works_with_absent_objects_directory() {
let cache = temp_dir("ao-cache");
let (from_store, from_url, _fid) = capture("ao-from", &cache, &[("a.txt", b"one", 0o644)]);
let (to_store, to_url, _tid) = capture("ao-to", &cache, &[("a.txt", b"two-changed", 0o644)]);
for s in [&from_store, &to_store] {
let objects = s.join(".objects");
if objects.exists() {
fs::remove_dir_all(&objects).ok();
}
assert!(
s.join(".manifests").exists(),
"the manifest dir must remain so diff has something to read"
);
}
let fresh_cache = temp_dir("ao-freshcache");
let stdout = run_ok(
&["diff", "--from", &from_url, "--to", &to_url],
&fresh_cache,
&[],
);
assert_porcelain_eq(&stdout, &[("M", "./a.txt")]);
fs::remove_dir_all(&cache).ok();
fs::remove_dir_all(&fresh_cache).ok();
}
#[test]
fn exit_code_one_when_differences_present() {
let cache = temp_dir("ec1-cache");
let (_fs, from_url, _fid) = capture("ec1-from", &cache, &[("x.txt", b"a", 0o644)]);
let (_ts, to_url, _tid) = capture("ec1-to", &cache, &[("x.txt", b"b-changed", 0o644)]);
let out = run_raw(
&["diff", "--exit-code", "--from", &from_url, "--to", &to_url],
&cache,
&[],
);
assert_eq!(
out.status.code(),
Some(1),
"--exit-code with a difference must exit 1; stderr: {}",
stderr_of(&out)
);
let stdout = String::from_utf8(out.stdout).expect("utf8");
assert_porcelain_eq(&stdout, &[("M", "./x.txt")]);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn exit_code_zero_when_no_differences() {
let cache = temp_dir("ec0-cache");
let leaves: &[(&str, &[u8], u32)] = &[("x.txt", b"same", 0o644), ("y.txt", b"same2", 0o644)];
let (_fs, from_url, fid) = capture("ec0-from", &cache, leaves);
let (_ts, to_url, tid) = capture("ec0-to", &cache, leaves);
assert_eq!(fid, tid, "identical trees must share the snapshot id");
let out = run_raw(
&["diff", "--exit-code", "--from", &from_url, "--to", &to_url],
&cache,
&[],
);
assert_eq!(
out.status.code(),
Some(0),
"--exit-code with NO differences must exit 0; stderr: {}",
stderr_of(&out)
);
let stdout = String::from_utf8(out.stdout).expect("utf8");
assert!(
stdout.trim().is_empty(),
"no differences -> no porcelain output; got:\n{stdout}"
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn default_exit_zero_even_with_differences() {
let cache = temp_dir("ed-cache");
let (_fs, from_url, _fid) = capture("ed-from", &cache, &[("x.txt", b"a", 0o644)]);
let (_ts, to_url, _tid) = capture("ed-to", &cache, &[("x.txt", b"changed", 0o644)]);
let out = run_raw(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_eq!(
out.status.code(),
Some(0),
"default (no --exit-code) must exit 0 even WITH differences; stderr: {}",
stderr_of(&out)
);
assert!(
String::from_utf8_lossy(&out.stdout).contains("./x.txt"),
"the difference must still be reported on stdout"
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn empty_vs_empty_no_output_exit_zero() {
let cache = temp_dir("ee-cache");
let from_store = temp_dir("ee-from");
let to_store = temp_dir("ee-to");
let from_url = file_url(&from_store);
let to_url = file_url(&to_store);
let out = run_raw(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_eq!(
out.status.code(),
Some(0),
"empty-vs-empty must exit 0; stderr: {}",
stderr_of(&out)
);
assert!(
String::from_utf8_lossy(&out.stdout).trim().is_empty(),
"empty-vs-empty must produce no output"
);
let out2 = run_raw(
&["diff", "--exit-code", "--from", &from_url, "--to", &to_url],
&cache,
&[],
);
assert_eq!(
out2.status.code(),
Some(0),
"empty-vs-empty with --exit-code is still 0 (no differences)"
);
fs::remove_dir_all(&cache).ok();
fs::remove_dir_all(&from_store).ok();
fs::remove_dir_all(&to_store).ok();
}
#[test]
fn identical_manifests_no_output_unless_all() {
let cache = temp_dir("id-cache");
let leaves: &[(&str, &[u8], u32)] =
&[("a.txt", b"alpha", 0o644), ("dir/b.txt", b"bravo", 0o644)];
let (_fs, from_url, fid) = capture("id-from", &cache, leaves);
let (_ts, to_url, tid) = capture("id-to", &cache, leaves);
assert_eq!(fid, tid);
let plain = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert!(
plain.trim().is_empty(),
"identical manifests must produce no output without --all; got:\n{plain}"
);
let all = run_ok(
&["diff", "--from", &from_url, "--to", &to_url, "--all"],
&cache,
&[],
);
assert!(
all.contains("./a.txt") && all.contains("./dir/b.txt"),
"--all must surface the shared (unchanged) paths even when identical; got:\n{all}"
);
for line in all.lines().filter(|l| !l.is_empty()) {
let status = line.splitn(2, '\t').next().unwrap_or("");
assert!(
status != "A" && status != "D" && status != "M",
"identical-side --all must not mark any path A/D/M; got {line:?}"
);
}
fs::remove_dir_all(&cache).ok();
}
#[test]
fn mode_only_change_is_modified() {
let cache = temp_dir("mode-cache");
let (_fs, from_url, fid) = capture("mode-from", &cache, &[("s.sh", b"exec me", 0o644)]);
let (_ts, to_url, tid) = capture("mode-to", &cache, &[("s.sh", b"exec me", 0o755)]);
assert_ne!(
fid, tid,
"a permission change must change the snapshot id (perms are in the merkle)"
);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_porcelain_eq(&stdout, &[("M", "./s.sh")]);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn content_size_change_is_single_modified() {
let cache = temp_dir("size-cache");
let (_fs, from_url, _fid) = capture("size-from", &cache, &[("f.txt", b"tiny", 0o644)]);
let (_ts, to_url, _tid) = capture(
"size-to",
&cache,
&[("f.txt", b"a substantially longer body of text", 0o644)],
);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_porcelain_eq(&stdout, &[("M", "./f.txt")]);
let m_count = stdout.lines().filter(|l| l.starts_with('M')).count();
assert_eq!(m_count, 1, "a changed path is ONE M line, not A+D");
fs::remove_dir_all(&cache).ok();
}
#[test]
fn json_output_is_array_of_status_path_objects() {
let cache = temp_dir("json-cache");
let (_fs, from_url, _fid) = capture(
"json-from",
&cache,
&[
("keep.txt", b"same", 0o644),
("gone.txt", b"x", 0o644),
("chg.txt", b"v1", 0o644),
],
);
let (_ts, to_url, _tid) = capture(
"json-to",
&cache,
&[
("keep.txt", b"same", 0o644),
("add.txt", b"y", 0o644),
("chg.txt", b"v2-longer", 0o644),
],
);
let json = run_ok(
&["diff", "--json", "--from", &from_url, "--to", &to_url],
&cache,
&[],
);
let objs = json_array_objects(&json);
let mut pairs: Vec<(String, String)> = objs
.iter()
.map(|obj| {
let status = json_str_field(obj, "status")
.unwrap_or_else(|| panic!("each entry must have a string `status`; got {obj}"))
.to_owned();
let path = json_str_field(obj, "path")
.unwrap_or_else(|| panic!("each entry must have a string `path`; got {obj}"))
.to_owned();
assert!(
status == "A" || status == "D" || status == "M",
"json status must be A|D|M, got {status:?}"
);
(status, path)
})
.collect();
pairs.sort();
let mut want = vec![
("A".to_owned(), "./add.txt".to_owned()),
("D".to_owned(), "./gone.txt".to_owned()),
("M".to_owned(), "./chg.txt".to_owned()),
];
want.sort();
assert_eq!(
pairs, want,
"json entries must match the change set; got:\n{json}"
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn multi_ref_from_union_disjoint_paths() {
let cache = temp_dir("mru-cache");
let (_f1, from1_url, _f1id) = capture("mru-from1", &cache, &[("only1.txt", b"one", 0o644)]);
let (_f2, from2_url, _f2id) = capture("mru-from2", &cache, &[("only2.txt", b"two", 0o644)]);
let (_ts, to_url, _tid) = capture("mru-to", &cache, &[("other.txt", b"o", 0o644)]);
let stdout = run_ok(
&[
"diff", "--from", &from1_url, "--from", &from2_url, "--to", &to_url,
],
&cache,
&[],
);
assert_porcelain_eq(
&stdout,
&[
("A", "./other.txt"),
("D", "./only1.txt"),
("D", "./only2.txt"),
],
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn multi_ref_same_path_same_content_no_conflict() {
let cache = temp_dir("mrs-cache");
let (_f1, from1_url, _f1id) = capture(
"mrs-from1",
&cache,
&[("dup.txt", b"identical", 0o644), ("a.txt", b"a", 0o644)],
);
let (_f2, from2_url, _f2id) = capture(
"mrs-from2",
&cache,
&[("dup.txt", b"identical", 0o644), ("b.txt", b"b", 0o644)],
);
let (_ts, to_url, _tid) = capture("mrs-to", &cache, &[("dup.txt", b"identical", 0o644)]);
let out = run_raw(
&[
"diff", "--from", &from1_url, "--from", &from2_url, "--to", &to_url,
],
&cache,
&[],
);
assert!(
out.status.success(),
"same-path/same-content union must NOT be a conflict; stderr: {}",
stderr_of(&out)
);
let stdout = String::from_utf8(out.stdout).expect("utf8");
assert_porcelain_eq(&stdout, &[("D", "./a.txt"), ("D", "./b.txt")]);
assert!(
!stdout.contains("dup.txt"),
"an unchanged unioned path must stay hidden; got:\n{stdout}"
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn intra_side_collision_errors_by_default() {
let cache = temp_dir("col-cache");
let (_f1, from1_url, _f1id) = capture(
"col-from1",
&cache,
&[("clash.txt", b"left version", 0o644)],
);
let (_f2, from2_url, _f2id) = capture(
"col-from2",
&cache,
&[("clash.txt", b"RIGHT version", 0o644)],
);
let (_ts, to_url, _tid) = capture("col-to", &cache, &[("z.txt", b"z", 0o644)]);
let out = run_raw(
&[
"diff", "--from", &from1_url, "--from", &from2_url, "--to", &to_url,
],
&cache,
&[],
);
assert!(
!out.status.success(),
"an intra-side collision (same path, differing content) must be a hard \
error by default; got success.\nstdout:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = stderr_of(&out);
let lc = stderr.to_lowercase();
assert!(
lc.contains("conflict") || lc.contains("collision") || lc.contains("clash"),
"the error must explain the collision actionably; got: {stderr}"
);
assert!(
stderr.contains("clash.txt") || stderr.contains("./clash.txt"),
"the error must name the colliding path; got: {stderr}"
);
assert!(
!String::from_utf8_lossy(&out.stdout).contains("clash.txt"),
"a collision must not be silently resolved into normal porcelain output"
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn intra_side_collision_last_wins_selects_last_ref() {
let cache = temp_dir("lw-cache");
let (_f1, from1_url, _f1id) =
capture("lw-from1", &cache, &[("clash.txt", b"LEFT loses", 0o644)]);
let (_f2, from2_url, _f2id) =
capture("lw-from2", &cache, &[("clash.txt", b"RIGHT-WINS", 0o644)]);
let (_ts, to_url, _tid) = capture("lw-to", &cache, &[("clash.txt", b"RIGHT-WINS", 0o644)]);
let stdout = run_ok(
&[
"diff",
"--on-conflict",
"last-wins",
"--from",
&from1_url,
"--from",
&from2_url,
"--to",
&to_url,
],
&cache,
&[],
);
assert!(
stdout.trim().is_empty(),
"last-wins must select the LAST ref's content (RIGHT-WINS), matching TO, \
so clash.txt is equal and hidden; got:\n{stdout}"
);
let (_ts2, to2_url, _tid2) = capture("lw-to2", &cache, &[("clash.txt", b"LEFT loses", 0o644)]);
let stdout2 = run_ok(
&[
"diff",
"--on-conflict",
"last-wins",
"--from",
&from1_url,
"--from",
&from2_url,
"--to",
&to2_url,
],
&cache,
&[],
);
assert_porcelain_eq(&stdout2, &[("M", "./clash.txt")]);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn unicode_and_space_paths_sort_stably_and_round_trip() {
let cache = temp_dir("uni-cache");
let leaves: &[(&str, &[u8], u32)] = &[
("zeta.txt", b"z", 0o644),
("a file with spaces.txt", b"s", 0o644),
("café.txt", b"c", 0o644),
("naïve dir/inner.txt", b"i", 0o644),
("Apple.txt", b"A", 0o644), ];
let (_fs, from_url, _fid) = capture("uni-from", &cache, &[("placeholder.txt", b"p", 0o644)]);
let (_ts, to_url, _tid) = capture("uni-to", &cache, leaves);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
for (rel, _, _) in leaves {
let want = format!("./{rel}");
assert!(
stdout.lines().any(|l| l == format!("A\t{want}")),
"expected an `A\\t{want}` line (byte-exact, ./-prefixed); got:\n{stdout}"
);
}
assert!(stdout.lines().any(|l| l == "D\t./placeholder.txt"));
let mut expected: Vec<(&str, &str)> = vec![("D", "./placeholder.txt")];
let owned: Vec<String> = leaves.iter().map(|(r, _, _)| format!("./{r}")).collect();
for p in &owned {
expected.push(("A", p.as_str()));
}
assert_porcelain_eq(&stdout, &expected);
let json = run_ok(
&["diff", "--json", "--from", &from_url, "--to", &to_url],
&cache,
&[],
);
let paths: Vec<String> = json_array_objects(&json)
.iter()
.map(|o| {
json_str_field(o, "path")
.unwrap_or_else(|| panic!("each json entry needs a `path`; got {o}"))
.to_owned()
})
.collect();
for (rel, _, _) in leaves {
assert!(
paths.contains(&format!("./{rel}")),
"json must carry the exact unicode/space path ./{rel}; got: {paths:?}"
);
}
fs::remove_dir_all(&cache).ok();
}
#[test]
fn swapping_from_to_flips_added_and_deleted() {
let cache = temp_dir("dir-cache");
let (_fs, left_url, _lid) = capture(
"dir-left",
&cache,
&[
("keep.txt", b"k", 0o644),
("leftonly.txt", b"L", 0o644),
("chg.txt", b"v1", 0o644),
],
);
let (_ts, right_url, _rid) = capture(
"dir-right",
&cache,
&[
("keep.txt", b"k", 0o644),
("rightonly.txt", b"R", 0o644),
("chg.txt", b"v2", 0o644),
],
);
let fwd = run_ok(
&["diff", "--from", &left_url, "--to", &right_url],
&cache,
&[],
);
assert_porcelain_eq(
&fwd,
&[
("M", "./chg.txt"),
("D", "./leftonly.txt"),
("A", "./rightonly.txt"),
],
);
let rev = run_ok(
&["diff", "--from", &right_url, "--to", &left_url],
&cache,
&[],
);
assert_porcelain_eq(
&rev,
&[
("M", "./chg.txt"),
("A", "./leftonly.txt"),
("D", "./rightonly.txt"),
],
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn from_id_pins_single_manifest_in_multi_manifest_store() {
let cache = temp_dir("pin-cache");
let from_store = temp_dir("pin-from");
let from_url = file_url(&from_store);
let id_v1 = capture_into(
"pin-v1",
&cache,
&from_url,
&[("f.txt", b"version one", 0o644)],
);
let id_v2 = capture_into(
"pin-v2",
&cache,
&from_url,
&[("f.txt", b"version two!!", 0o644)],
);
assert_ne!(id_v1, id_v2, "the two captures must be distinct manifests");
let (_ts, to_url, to_id) = capture("pin-to", &cache, &[("f.txt", b"version two!!", 0o644)]);
assert_eq!(to_id, id_v2);
let d1 = run_ok(
&["diff", "--from", &from_url, "--id", &id_v1, "--to", &to_url],
&cache,
&[],
);
assert_porcelain_eq(&d1, &[("M", "./f.txt")]);
let d2 = run_ok(
&["diff", "--from", &from_url, "--id", &id_v2, "--to", &to_url],
&cache,
&[],
);
assert!(
d2.trim().is_empty(),
"pinning the matching manifest id must yield no differences; got:\n{d2}"
);
fs::remove_dir_all(&cache).ok();
fs::remove_dir_all(&from_store).ok();
}
fn assert_no_directory_lines(stdout: &str) {
for line in stdout.lines().filter(|l| !l.is_empty()) {
let path = line.splitn(2, '\t').nth(1).unwrap_or("");
assert!(
!path.ends_with('/'),
"diff is file-level: NO directory line may appear, got {line:?} in:\n{stdout}"
);
}
}
#[test]
fn modified_file_in_dir_shows_file_not_dir() {
let cache = temp_dir("dirM-cache");
let (_fs, from_url, _fid) = capture(
"dirM-from",
&cache,
&[
("top.txt", b"top-same", 0o644),
("sub/inner.txt", b"inner v1", 0o644),
],
);
let (_ts, to_url, _tid) = capture(
"dirM-to",
&cache,
&[
("top.txt", b"top-same", 0o644),
("sub/inner.txt", b"inner v2 is longer", 0o644),
],
);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_porcelain_eq(&stdout, &[("M", "./sub/inner.txt")]);
assert_no_directory_lines(&stdout);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn dir_with_only_descendant_change_is_not_reported() {
let cache = temp_dir("dirDesc-cache");
let (_fs, from_url, _fid) = capture(
"dirDesc-from",
&cache,
&[("a/b/leaf.txt", b"leaf one", 0o644)],
);
let (_ts, to_url, _tid) = capture(
"dirDesc-to",
&cache,
&[("a/b/leaf.txt", b"leaf two changed", 0o644)],
);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_porcelain_eq(&stdout, &[("M", "./a/b/leaf.txt")]);
assert_no_directory_lines(&stdout);
assert!(
!stdout.lines().any(|l| {
let p = l.splitn(2, '\t').nth(1).unwrap_or("");
p == "./" || p == "./a/" || p == "./a/b/"
}),
"no ancestor directory line may surface for a descendant-only change; got:\n{stdout}"
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn new_subdir_appears_as_files_only_no_dir_lines() {
let cache = temp_dir("dirNew-cache");
let (_fs, from_url, _fid) = capture("dirNew-from", &cache, &[("root.txt", b"r", 0o644)]);
let (_ts, to_url, _tid) = capture(
"dirNew-to",
&cache,
&[
("root.txt", b"r", 0o644),
("pkg/one.txt", b"1", 0o644),
("pkg/nested/two.txt", b"2", 0o644),
],
);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_porcelain_eq(
&stdout,
&[("A", "./pkg/nested/two.txt"), ("A", "./pkg/one.txt")],
);
assert_no_directory_lines(&stdout);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn removed_subdir_appears_as_files_only_no_dir_lines() {
let cache = temp_dir("dirRm-cache");
let (_fs, from_url, _fid) = capture(
"dirRm-from",
&cache,
&[
("root.txt", b"r", 0o644),
("pkg/one.txt", b"1", 0o644),
("pkg/nested/two.txt", b"2", 0o644),
],
);
let (_ts, to_url, _tid) = capture("dirRm-to", &cache, &[("root.txt", b"r", 0o644)]);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_porcelain_eq(
&stdout,
&[("D", "./pkg/nested/two.txt"), ("D", "./pkg/one.txt")],
);
assert_no_directory_lines(&stdout);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn dir_only_permission_change_is_omitted_file_level() {
let cache = temp_dir("dirPerm-cache");
let from_src = temp_dir("dirPerm-from-src");
let to_src = temp_dir("dirPerm-to-src");
for src in [&from_src, &to_src] {
fs::create_dir_all(src.join("d")).unwrap();
fs::write(src.join("d/f.txt"), b"same").unwrap();
fs::set_permissions(src.join("d/f.txt"), fs::Permissions::from_mode(0o644)).unwrap();
fs::set_permissions(src, fs::Permissions::from_mode(0o755)).unwrap();
}
fs::set_permissions(from_src.join("d"), fs::Permissions::from_mode(0o755)).unwrap();
fs::set_permissions(to_src.join("d"), fs::Permissions::from_mode(0o700)).unwrap();
let from_store = temp_dir("dirPerm-from-store");
let to_store = temp_dir("dirPerm-to-store");
let from_url = file_url(&from_store);
let to_url = file_url(&to_store);
let fid = run_ok(
&["push", "--store", &from_url, &from_src.to_string_lossy()],
&cache,
&[],
);
let tid = run_ok(
&["push", "--store", &to_url, &to_src.to_string_lossy()],
&cache,
&[],
);
assert_ne!(
fid, tid,
"a directory permission change must change the snapshot id (perms are in the merkle)"
);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert!(
stdout.trim().is_empty(),
"a dir-ONLY permission change is not a file-level difference -> no output; got:\n{stdout}"
);
assert_no_directory_lines(&stdout);
fs::remove_dir_all(&cache).ok();
fs::remove_dir_all(&from_src).ok();
fs::remove_dir_all(&to_src).ok();
fs::remove_dir_all(&from_store).ok();
fs::remove_dir_all(&to_store).ok();
}
#[test]
fn file_replaced_by_directory_is_file_level_delete_plus_add() {
let cache = temp_dir("dirType-cache");
let (_fs, from_url, _fid) = capture(
"dirType-from",
&cache,
&[("keep.txt", b"k", 0o644), ("x", b"i am a file", 0o644)],
);
let (_ts, to_url, _tid) = capture(
"dirType-to",
&cache,
&[
("keep.txt", b"k", 0o644),
("x/inner.txt", b"now a dir", 0o644),
],
);
let stdout = run_ok(&["diff", "--from", &from_url, "--to", &to_url], &cache, &[]);
assert_porcelain_eq(&stdout, &[("A", "./x/inner.txt"), ("D", "./x")]);
assert_no_directory_lines(&stdout);
let rev = run_ok(&["diff", "--from", &to_url, "--to", &from_url], &cache, &[]);
assert_porcelain_eq(&rev, &[("A", "./x"), ("D", "./x/inner.txt")]);
assert_no_directory_lines(&rev);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn all_flag_still_drops_directory_entries() {
let cache = temp_dir("dirAll-cache");
let (_fs, from_url, fid) = capture(
"dirAll-from",
&cache,
&[("top.txt", b"t", 0o644), ("sub/inner.txt", b"same", 0o644)],
);
let (_ts, to_url, tid) = capture(
"dirAll-to",
&cache,
&[("top.txt", b"t", 0o644), ("sub/inner.txt", b"same", 0o644)],
);
assert_eq!(fid, tid, "identical trees share the snapshot id");
let all = run_ok(
&["diff", "--from", &from_url, "--to", &to_url, "--all"],
&cache,
&[],
);
assert!(
all.contains("./top.txt") && all.contains("./sub/inner.txt"),
"--all must surface the unchanged FILES; got:\n{all}"
);
assert_no_directory_lines(&all);
assert!(
!all.lines().any(|l| {
let p = l.splitn(2, '\t').nth(1).unwrap_or("");
p == "./" || p == "./sub/"
}),
"--all must not re-introduce the directory entries ./ or ./sub/; got:\n{all}"
);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn intra_side_collision_keys_on_file_not_enclosing_dir() {
let cache = temp_dir("dirCol-cache");
let (_f1, from1_url, _f1id) = capture(
"dirCol-from1",
&cache,
&[("sub/inner.txt", b"left content", 0o644)],
);
let (_f2, from2_url, _f2id) = capture(
"dirCol-from2",
&cache,
&[("sub/inner.txt", b"RIGHT content", 0o644)],
);
let (_ts, to_url, _tid) = capture("dirCol-to", &cache, &[("z.txt", b"z", 0o644)]);
let out = run_raw(
&[
"diff", "--from", &from1_url, "--from", &from2_url, "--to", &to_url,
],
&cache,
&[],
);
assert!(
!out.status.success(),
"differing descendant content across two FROM refs must collide (error); got success.\nstdout:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let stderr = stderr_of(&out);
assert!(
stderr.contains("sub/inner.txt"),
"the collision must name the FILE ./sub/inner.txt, not the dir; got: {stderr}"
);
assert!(
!stderr.contains("\"./sub/\"") && !stderr.contains("\"./\""),
"the collision must NOT key on the enclosing directory ./sub/ or ./; got: {stderr}"
);
let (_ts2, to2_url, _tid2) = capture(
"dirCol-to2",
&cache,
&[("sub/inner.txt", b"RIGHT content", 0o644)],
);
let stdout = run_ok(
&[
"diff",
"--on-conflict",
"last-wins",
"--from",
&from1_url,
"--from",
&from2_url,
"--to",
&to2_url,
],
&cache,
&[],
);
assert!(
stdout.trim().is_empty(),
"last-wins selects the LAST ref's file content, matching TO -> no diff; got:\n{stdout}"
);
assert_no_directory_lines(&stdout);
fs::remove_dir_all(&cache).ok();
}
#[test]
fn json_drops_directory_entries_too() {
let cache = temp_dir("dirJson-cache");
let (_fs, from_url, _fid) = capture("dirJson-from", &cache, &[("d/leaf.txt", b"v1", 0o644)]);
let (_ts, to_url, _tid) = capture("dirJson-to", &cache, &[("d/leaf.txt", b"v2 longer", 0o644)]);
let json = run_ok(
&["diff", "--json", "--from", &from_url, "--to", &to_url],
&cache,
&[],
);
let paths: Vec<String> = json_array_objects(&json)
.iter()
.map(|o| {
json_str_field(o, "path")
.unwrap_or_else(|| panic!("each json entry needs a `path`; got {o}"))
.to_owned()
})
.collect();
assert_eq!(
paths,
vec!["./d/leaf.txt".to_owned()],
"json must carry ONLY the file entry, no directory object; got:\n{json}"
);
for p in &paths {
assert!(
!p.ends_with('/'),
"no json path may be a directory (trailing slash); got {p:?}"
);
}
fs::remove_dir_all(&cache).ok();
}