#![allow(clippy::unwrap_used)]
use std::fs;
use std::path::Path;
use std::process::Command;
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
fn run_in(cwd: &Path, args: &[&str]) -> std::process::Output {
let xdg = tempfile::tempdir().expect("xdg tempdir");
let out = Command::new(mkit_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg.path())
.output()
.expect("spawn mkit");
drop(xdg);
out
}
fn init_repo(td: &Path) {
assert!(run_in(td, &["init"]).status.success());
assert!(run_in(td, &["keygen"]).status.success());
}
fn make_commit(td: &Path, file: &str, body: &[u8], msg: &str) {
fs::write(td.join(file), body).unwrap();
assert!(run_in(td, &["add", file]).status.success());
let out = run_in(td, &["commit", "-m", msg]);
assert!(out.status.success(), "commit failed: {out:?}");
}
fn head_hash(td: &Path) -> String {
ref_hash(td, "main")
}
fn ref_hash(td: &Path, branch: &str) -> String {
fs::read_to_string(td.join(".mkit/refs/heads").join(branch))
.unwrap()
.trim()
.to_string()
}
#[test]
fn clone_errors_on_missing_url() {
let td = tempfile::tempdir().unwrap();
let out = run_in(td.path(), &["clone"]);
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.to_lowercase().contains("usage"),
"expected usage diagnostic on stderr, got: {stderr}"
);
}
#[test]
fn clone_from_file_url_roundtrips() {
let alice = tempfile::tempdir().unwrap();
init_repo(alice.path());
make_commit(alice.path(), "a.txt", b"hi from alice\n", "first");
let bare = tempfile::tempdir().unwrap();
let url = format!("mkit+file://{}", bare.path().display());
assert!(
run_in(alice.path(), &["remote", "add", &url])
.status
.success()
);
let out = run_in(alice.path(), &["push"]);
assert!(out.status.success(), "push failed: {out:?}");
let parent = tempfile::tempdir().unwrap();
let out = Command::new(mkit_bin())
.args(["clone", &url, "bob"])
.current_dir(parent.path())
.output()
.expect("spawn");
assert!(out.status.success(), "clone failed: {out:?}");
let bob = parent.path().join("bob");
assert!(bob.join(".mkit/refs/heads/main").is_file());
assert_eq!(
fs::read_to_string(alice.path().join(".mkit/refs/heads/main"))
.unwrap()
.trim(),
fs::read_to_string(bob.join(".mkit/refs/heads/main"))
.unwrap()
.trim(),
);
}
#[test]
fn merge_errors_on_missing_branch() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let out = run_in(td.path(), &["merge", "nope"]);
assert!(!out.status.success());
}
#[test]
fn merge_fast_forwards_when_current_is_ancestor() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"1\n", "c1");
let c1 = head_hash(td.path());
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
make_commit(td.path(), "a.txt", b"2\n", "c2");
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
let out = run_in(td.path(), &["merge", "feature"]);
assert!(out.status.success(), "merge failed: {out:?}");
let stderr = String::from_utf8(out.stderr).unwrap();
let lower = stderr.to_lowercase();
assert!(
lower.contains("fast-forward") || lower.contains("up to date"),
"unexpected merge output: {stderr}"
);
assert_ne!(head_hash(td.path()), c1);
}
#[test]
fn merge_preserves_ignored_untracked_files() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"1\n", "c1");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
make_commit(td.path(), "a.txt", b"2\n", "c2");
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
fs::write(td.path().join(".mkitignore"), "local.txt\n").unwrap();
fs::write(td.path().join("local.txt"), b"local only\n").unwrap();
let out = run_in(td.path(), &["merge", "feature"]);
assert!(out.status.success(), "merge failed: {out:?}");
assert_eq!(
fs::read(td.path().join("local.txt")).unwrap(),
b"local only\n"
);
assert_eq!(
fs::read_to_string(td.path().join(".mkitignore")).unwrap(),
"local.txt\n"
);
}
#[test]
fn cherry_pick_errors_on_bad_hash() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let out = run_in(td.path(), &["cherry-pick", "not-a-hash"]);
assert!(!out.status.success());
}
#[test]
fn cherry_pick_restores_worktree_and_advances_ref() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "base.txt", b"base\n", "base");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
make_commit(td.path(), "picked.txt", b"picked\n", "picked");
let picked = ref_hash(td.path(), "feature");
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
let main_before = head_hash(td.path());
let out = run_in(td.path(), &["cherry-pick", &picked]);
assert!(out.status.success(), "cherry-pick failed: {out:?}");
assert_eq!(fs::read(td.path().join("picked.txt")).unwrap(), b"picked\n");
assert_ne!(head_hash(td.path()), main_before);
}
#[test]
fn rebase_errors_when_no_rebase_in_progress() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let out = run_in(td.path(), &["rebase", "--continue"]);
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("no rebase in progress"));
}
#[test]
fn rebase_onto_same_head_is_noop() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"1\n", "c1");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
let out = run_in(td.path(), &["rebase", "feature"]);
assert!(out.status.success(), "rebase failed: {out:?}");
let stderr = String::from_utf8(out.stderr).unwrap();
let lower = stderr.to_lowercase();
assert!(
lower.contains("rebased") || lower.contains("up to date"),
"unexpected rebase output: {stderr}"
);
}
#[test]
fn rebase_abort_restores_original_branch_ref_and_worktree() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"base\n", "base");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
make_commit(td.path(), "a.txt", b"feature\n", "feature change");
let feature_before = ref_hash(td.path(), "feature");
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
make_commit(td.path(), "a.txt", b"main\n", "main change");
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
let rebase = run_in(td.path(), &["rebase", "main"]);
assert!(!rebase.status.success(), "rebase should pause on conflict");
assert!(td.path().join(".mkit/rebase-apply").exists());
let abort = run_in(td.path(), &["rebase", "--abort"]);
assert!(abort.status.success(), "abort failed: {abort:?}");
assert_eq!(ref_hash(td.path(), "feature"), feature_before);
assert_eq!(fs::read(td.path().join("a.txt")).unwrap(), b"feature\n");
assert!(!td.path().join(".mkit/rebase-apply").exists());
}
#[test]
fn bisect_errors_on_unknown_subcommand() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let out = run_in(td.path(), &["bisect", "wat"]);
assert!(!out.status.success());
}
#[test]
fn bisect_start_creates_state_file() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"1\n", "c1");
let out = run_in(td.path(), &["bisect", "start"]);
assert!(out.status.success(), "bisect start failed: {out:?}");
assert!(td.path().join(".mkit/bisect").is_file());
assert!(run_in(td.path(), &["bisect", "reset"]).status.success());
}
#[test]
fn bisect_run_converges_to_first_bad_commit() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "marker.txt", b"ok\n", "c1");
let c1 = head_hash(td.path());
make_commit(td.path(), "marker.txt", b"ok\n", "c2");
make_commit(td.path(), "marker.txt", b"BAD\n", "c3");
let c3 = head_hash(td.path());
make_commit(td.path(), "marker.txt", b"BAD\n", "c4");
make_commit(td.path(), "marker.txt", b"BAD\n", "c5");
let c5 = head_hash(td.path());
assert!(run_in(td.path(), &["bisect", "start"]).status.success());
assert!(run_in(td.path(), &["bisect", "good", &c1]).status.success());
assert!(run_in(td.path(), &["bisect", "bad", &c5]).status.success());
let out = run_in(
td.path(),
&["bisect", "run", "sh", "-c", "! grep -q BAD marker.txt"],
);
assert!(out.status.success(), "bisect run failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(
stdout.trim(),
&c3[..12],
"bisect run must converge to the first bad commit c3: {stdout:?}"
);
let _ = run_in(td.path(), &["bisect", "reset"]);
}
#[test]
fn bisect_run_skips_untestable_candidate_and_still_converges() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "marker.txt", b"ok\n", "c1");
let c1 = head_hash(td.path());
make_commit(td.path(), "marker.txt", b"ok\n", "c2");
make_commit(td.path(), "marker.txt", b"BAD\n", "c3");
let c3 = head_hash(td.path());
make_commit(td.path(), "marker.txt", b"SKIP\n", "c4");
make_commit(td.path(), "marker.txt", b"BAD\n", "c5");
let c5 = head_hash(td.path());
assert!(run_in(td.path(), &["bisect", "start"]).status.success());
assert!(run_in(td.path(), &["bisect", "good", &c1]).status.success());
assert!(run_in(td.path(), &["bisect", "bad", &c5]).status.success());
let script = "grep -q SKIP marker.txt && exit 125; grep -q BAD marker.txt && exit 1; exit 0";
let out = run_in(td.path(), &["bisect", "run", "sh", "-c", script]);
assert!(out.status.success(), "bisect run failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(
stdout.trim(),
&c3[..12],
"bisect run must bypass the c4 skip and converge to c3: {stdout:?}"
);
let _ = run_in(td.path(), &["bisect", "reset"]);
}
#[test]
fn bisect_run_survives_test_command_dirtying_a_tracked_file() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "marker.txt", b"ok\n", "c1");
let c1 = head_hash(td.path());
make_commit(td.path(), "marker.txt", b"ok\n", "c2");
make_commit(td.path(), "marker.txt", b"BAD\n", "c3");
let c3 = head_hash(td.path());
make_commit(td.path(), "marker.txt", b"BAD\n", "c4");
make_commit(td.path(), "tracked.txt", b"v0\n", "c5-track");
let c5 = head_hash(td.path());
assert!(run_in(td.path(), &["bisect", "start"]).status.success());
assert!(run_in(td.path(), &["bisect", "good", &c1]).status.success());
assert!(run_in(td.path(), &["bisect", "bad", &c5]).status.success());
let script = "echo scribble >> tracked.txt; ! grep -q BAD marker.txt";
let out = run_in(td.path(), &["bisect", "run", "sh", "-c", script]);
assert!(
out.status.success(),
"bisect run must survive a tracked-file-dirtying command: {out:?}"
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert_eq!(stdout.trim(), &c3[..12], "converges to c3: {stdout:?}");
let _ = run_in(td.path(), &["bisect", "reset"]);
}
#[test]
fn bisect_run_reports_ambiguity_when_all_candidates_skipped() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "marker.txt", b"ok\n", "c1");
let c1 = head_hash(td.path());
for c in ["c2", "c3", "c4"] {
make_commit(td.path(), "marker.txt", b"BAD\n", c);
}
let c4 = head_hash(td.path());
assert!(run_in(td.path(), &["bisect", "start"]).status.success());
assert!(run_in(td.path(), &["bisect", "good", &c1]).status.success());
assert!(run_in(td.path(), &["bisect", "bad", &c4]).status.success());
let out = run_in(td.path(), &["bisect", "run", "sh", "-c", "exit 125"]);
assert!(
!out.status.success(),
"all-skipped run must exit non-zero: {out:?}"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("only skipped commits left"),
"must report ambiguity like git: {stderr}"
);
let _ = run_in(td.path(), &["bisect", "reset"]);
}
#[test]
fn stash_list_on_empty_repo_prints_none_marker() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let out = run_in(td.path(), &["stash", "list"]);
assert!(out.status.success(), "stash list failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.is_empty(),
"empty stash list must produce empty stdout: {stdout:?}"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.is_empty(),
"empty stash list must be silent (git-shaped): {stderr:?}"
);
}
#[test]
fn stash_show_on_empty_stash_errors_out_of_range() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let out = run_in(td.path(), &["stash", "show"]);
assert_eq!(
out.status.code(),
Some(1),
"empty-stash `stash show` must exit GENERAL_ERROR: {out:?}"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("stash index 0 is out of range"),
"expected the out-of-range diagnostic, got: {stderr}"
);
}
#[test]
fn blame_errors_on_missing_file() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "real.txt", b"x\n", "r1");
let out = run_in(td.path(), &["blame", "nope.txt"]);
assert!(!out.status.success());
}
#[test]
fn blame_on_single_commit_attributes_every_line_to_it() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"one\ntwo\nthree\n", "first");
let out = run_in(td.path(), &["blame", "f.txt"]);
assert!(out.status.success(), "blame failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 3, "expected 3 blame lines, got {stdout:?}");
assert!(lines[0].ends_with("\tone"));
assert!(lines[1].ends_with("\ttwo"));
assert!(lines[2].ends_with("\tthree"));
let first_short: &str = lines[0].split('\t').next().unwrap();
assert_eq!(first_short.len(), 12);
assert!(first_short.chars().all(|c| c.is_ascii_hexdigit()));
}
#[test]
fn blame_l_range_slices_lines_and_keeps_numbering() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\nb\nc\nd\ne\n", "first");
let out = run_in(td.path(), &["blame", "-L", "2,4", "f.txt"]);
assert!(out.status.success(), "blame -L failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 3, "expected lines 2..=4, got {stdout:?}");
assert!(lines[0].contains("\t2\t") && lines[0].ends_with("\tb"));
assert!(lines[1].contains("\t3\t") && lines[1].ends_with("\tc"));
assert!(lines[2].contains("\t4\t") && lines[2].ends_with("\td"));
}
#[test]
fn blame_l_plus_offset_counts_lines() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\nb\nc\n", "first");
let out = run_in(td.path(), &["blame", "-L", "2,+1", "f.txt"]);
assert!(out.status.success(), "blame -L +n failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 1, "expected just line 2, got {stdout:?}");
assert!(lines[0].contains("\t2\t") && lines[0].ends_with("\tb"));
}
#[test]
fn blame_l_minus_offset_counts_lines_ending_at_start() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\nb\nc\nd\ne\n", "first");
let out = run_in(td.path(), &["blame", "-L", "4,-2", "f.txt"]);
assert!(out.status.success(), "blame -L -n failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 2, "expected lines 3,4, got {stdout:?}");
assert!(lines[0].contains("\t3\t") && lines[0].ends_with("\tc"));
assert!(lines[1].contains("\t4\t") && lines[1].ends_with("\td"));
}
#[test]
fn blame_l_start_past_eof_is_usage_error() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\nb\n", "first");
let out = run_in(td.path(), &["blame", "-L", "9,10", "f.txt"]);
assert!(!out.status.success(), "expected failure on out-of-range -L");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("file f.txt has only 2 lines"),
"expected git-faithful line-count diagnostic, got: {stderr}"
);
}
#[test]
fn blame_l_zero_line_number_errors_without_panicking() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\nb\nc\n", "first");
let out = run_in(td.path(), &["blame", "-L", ",0", "f.txt"]);
assert!(!out.status.success(), "expected failure on -L ,0");
assert_ne!(
out.status.code(),
Some(101),
"must not panic; got a 101 exit"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("-L invalid line number: 0"),
"expected git-exact zero-line diagnostic, got: {stderr}"
);
let neg = run_in(td.path(), &["blame", "-L", "-3,5", "f.txt"]);
assert!(!neg.status.success());
assert!(
String::from_utf8(neg.stderr)
.unwrap()
.contains("-L invalid line number: -3"),
"negative start should yield the git-exact diagnostic"
);
}
#[test]
fn blame_at_explicit_revision_uses_that_commit() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\nb\nc\n", "first");
let first = head_hash(td.path());
make_commit(td.path(), "f.txt", b"a\nMOD\nc\n", "second");
let head = run_in(td.path(), &["blame", "f.txt"]);
let head_out = String::from_utf8(head.stdout).unwrap();
assert!(head_out.lines().nth(1).unwrap().ends_with("\tMOD"));
let out = run_in(td.path(), &["blame", &first, "f.txt"]);
assert!(out.status.success(), "blame <rev> failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 3);
assert!(
lines[1].ends_with("\tb"),
"expected pre-MOD content: {stdout:?}"
);
let short = &first[..12];
assert!(
lines.iter().all(|l| l.starts_with(short)),
"every line should be attributed to the first commit: {stdout:?}"
);
}
#[test]
fn blame_unknown_revision_errors() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\n", "first");
let out = run_in(td.path(), &["blame", "no-such-rev", "f.txt"]);
assert!(
!out.status.success(),
"expected failure on unknown revision"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("unknown revision"),
"expected unknown-revision diagnostic, got: {stderr}"
);
}
#[test]
fn blame_w_ignores_whitespace_only_change() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"foo(a, b)\n", "first");
let first = head_hash(td.path());
make_commit(td.path(), "f.txt", b"foo(a,b)\n", "reformat");
let second = head_hash(td.path());
let plain = run_in(td.path(), &["blame", "f.txt"]);
let plain_out = String::from_utf8(plain.stdout).unwrap();
assert!(
plain_out.starts_with(&second[..12]),
"default blame should attribute to the reformat commit: {plain_out:?}"
);
let out = run_in(td.path(), &["blame", "-w", "f.txt"]);
assert!(out.status.success(), "blame -w failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.starts_with(&first[..12]),
"-w should keep the original commit: {stdout:?}"
);
assert!(
stdout.trim_end().ends_with("\tfoo(a,b)"),
"-w output should still show current bytes: {stdout:?}"
);
}
#[test]
fn blame_m_attributes_within_file_move() {
let long = "let quick_brown_fox_total = 1;";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(
td.path(),
"f.txt",
format!("{long}\nB\nC\n").as_bytes(),
"first",
);
let first = head_hash(td.path());
make_commit(
td.path(),
"f.txt",
format!("B\nC\n{long}\n").as_bytes(),
"shuffle",
);
let second = head_hash(td.path());
let plain = run_in(td.path(), &["blame", "f.txt"]);
let plain_out = String::from_utf8(plain.stdout).unwrap();
let plain_line = plain_out.lines().find(|l| l.ends_with(long)).unwrap();
assert!(
plain_line.starts_with(&second[..12]),
"default: moved line is new: {plain_out:?}"
);
let out = run_in(td.path(), &["blame", "-M", "f.txt"]);
assert!(out.status.success(), "blame -M failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let line = stdout.lines().find(|l| l.ends_with(long)).unwrap();
assert!(
line.starts_with(&first[..12]),
"-M credits the moved line to its origin: {stdout:?}"
);
}
#[test]
fn blame_m_merge_credits_move_from_second_parent() {
let long = "let quick_brown_fox_total = 1;";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"HEAD\nX\nY\n", "base");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
make_commit(td.path(), "f.txt", b"MAIN\nX\nY\n", "p1edit");
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
make_commit(
td.path(),
"f.txt",
format!("{long}\nX\nY\n").as_bytes(),
"feature writes long",
);
let feature = ref_hash(td.path(), "feature");
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
let merge = run_in(td.path(), &["merge", "feature"]);
assert!(!merge.status.success(), "merge should conflict: {merge:?}");
fs::write(td.path().join("f.txt"), format!("X\nY\n{long}\n")).unwrap();
assert!(run_in(td.path(), &["add", "f.txt"]).status.success());
let cont = run_in(td.path(), &["merge", "--continue"]);
assert!(cont.status.success(), "merge --continue failed: {cont:?}");
let out = run_in(td.path(), &["blame", "-M", "f.txt"]);
assert!(out.status.success(), "blame -M failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let line = stdout.lines().find(|l| l.ends_with(long)).unwrap();
assert!(
line.starts_with(&feature[..12]),
"-M credits the move to the 2nd-parent (feature) origin, not the merge: {stdout:?}"
);
}
#[test]
fn blame_c_merge_conflict_edit_keeps_copy_tie_on_merge() {
let b1 = "fn handler_alpha() { compute(); }";
let b2 = "fn handler_bravo() { compute(); }";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"TOP\n", "base");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
fs::write(td.path().join("f.txt"), b"MAIN\n").unwrap();
fs::write(td.path().join("s1.txt"), format!("{b1}\n{b2}\n")).unwrap();
assert!(
run_in(td.path(), &["add", "f.txt", "s1.txt"])
.status
.success()
);
assert!(
run_in(td.path(), &["commit", "-m", "main edit + s1"])
.status
.success()
);
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
fs::write(td.path().join("f.txt"), b"FEAT\n").unwrap();
fs::write(td.path().join("s2.txt"), format!("{b1}\n{b2}\n")).unwrap();
assert!(
run_in(td.path(), &["add", "f.txt", "s2.txt"])
.status
.success()
);
assert!(
run_in(td.path(), &["commit", "-m", "feature edit + s2"])
.status
.success()
);
let feature = ref_hash(td.path(), "feature");
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
let merge = run_in(td.path(), &["merge", "feature"]);
assert!(!merge.status.success(), "merge should conflict: {merge:?}");
fs::write(td.path().join("f.txt"), format!("MAIN\n{b1}\n{b2}\n")).unwrap();
assert!(run_in(td.path(), &["add", "f.txt"]).status.success());
let cont = run_in(td.path(), &["merge", "--continue"]);
assert!(cont.status.success(), "merge --continue failed: {cont:?}");
let merge_hash = head_hash(td.path());
let out = run_in(td.path(), &["blame", "-C", "-C", "f.txt"]);
assert!(out.status.success(), "blame -C -C failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines[1].starts_with(&merge_hash[..12]) && lines[2].starts_with(&merge_hash[..12]),
"both parents keep their porigins (f.txt conflicted), so the \
unchanged sources are invisible and the block stays on the merge \
(git parity): {stdout:?}"
);
assert!(
lines.iter().all(|l| !l.starts_with(&feature[..12])),
"the second parent's unchanged s2.txt must not be credited: {stdout:?}"
);
}
#[test]
fn blame_ignore_rev_merge_falls_through_to_second_parent() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"TOP\nMID\nBOT\n", "base");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
make_commit(td.path(), "f.txt", b"TOP\nBOT\n", "p1 deletes mid");
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
make_commit(
td.path(),
"f.txt",
b"TOP\nREAL_CONTENT_OF_B_LINE\nBOT\n",
"feature rewrites mid",
);
let feature = ref_hash(td.path(), "feature");
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
let merge = run_in(td.path(), &["merge", "feature"]);
assert!(!merge.status.success(), "merge should conflict: {merge:?}");
fs::write(
td.path().join("f.txt"),
b"TOP\n REAL_CONTENT_OF_B_LINE X\nBOT\n",
)
.unwrap();
assert!(run_in(td.path(), &["add", "f.txt"]).status.success());
let cont = run_in(td.path(), &["merge", "--continue"]);
assert!(cont.status.success(), "merge --continue failed: {cont:?}");
let merge_hash = head_hash(td.path());
let out = run_in(td.path(), &["blame", "--ignore-rev", &merge_hash, "f.txt"]);
assert!(out.status.success(), "blame --ignore-rev failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines[1].starts_with(&feature[..12]),
"ignored merge falls through across to the 2nd parent (feature): {stdout:?}"
);
assert!(
lines.iter().all(|l| !l.starts_with(&merge_hash[..12])),
"no line is credited to the ignored merge: {stdout:?}"
);
}
#[test]
fn blame_ignore_rev_falls_through_to_prior_commit() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"alpha\nbeta\ngamma\n", "first");
let first = head_hash(td.path());
make_commit(td.path(), "f.txt", b"alpha\n beta \ngamma\n", "reformat");
let reformat = head_hash(td.path());
let plain = run_in(td.path(), &["blame", "f.txt"]);
let plain_out = String::from_utf8(plain.stdout).unwrap();
assert!(
plain_out
.lines()
.nth(1)
.unwrap()
.starts_with(&reformat[..12])
);
let out = run_in(td.path(), &["blame", "--ignore-rev", &reformat, "f.txt"]);
assert!(out.status.success(), "blame --ignore-rev failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines[1].starts_with(&first[..12]),
"ignored reformat falls through to the first commit: {stdout:?}"
);
assert!(
lines[1].ends_with("\t beta "),
"output still shows the reformatted bytes: {stdout:?}"
);
assert!(
lines.iter().all(|l| !l.starts_with(&reformat[..12])),
"no line is credited to the ignored commit: {stdout:?}"
);
}
#[test]
fn blame_c_attributes_copy_from_other_file() {
let b1 = "fn handler_alpha() { compute(); }";
let b2 = "fn handler_bravo() { compute(); }";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(
td.path(),
"a.txt",
format!("{b1}\n{b2}\nzzz\n").as_bytes(),
"first",
);
let first = head_hash(td.path());
fs::write(td.path().join("a.txt"), b"zzz\n").unwrap();
fs::write(td.path().join("b.txt"), format!("{b1}\n{b2}\n")).unwrap();
assert!(
run_in(td.path(), &["add", "a.txt", "b.txt"])
.status
.success()
);
assert!(
run_in(td.path(), &["commit", "-m", "split"])
.status
.success()
);
let second = head_hash(td.path());
let plain = run_in(td.path(), &["blame", "b.txt"]);
let plain_out = String::from_utf8(plain.stdout).unwrap();
assert!(
plain_out.lines().all(|l| l.starts_with(&second[..12])),
"default: copied block is new: {plain_out:?}"
);
let out = run_in(td.path(), &["blame", "-C", "b.txt"]);
assert!(out.status.success(), "blame -C failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.lines().all(|l| l.starts_with(&first[..12])),
"-C credits the copied block to its origin commit: {stdout:?}"
);
}
#[test]
fn blame_ignore_revs_file_skips_listed_commits() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"alpha\nbeta\n", "first");
let first = head_hash(td.path());
make_commit(td.path(), "f.txt", b"alpha\n beta \n", "reformat");
let reformat = head_hash(td.path());
let revs = format!("# noise commits\n\n{reformat} # the reformat\n");
fs::write(td.path().join("revs.txt"), revs).unwrap();
let out = run_in(
td.path(),
&["blame", "--ignore-revs-file", "revs.txt", "f.txt"],
);
assert!(
out.status.success(),
"blame --ignore-revs-file failed: {out:?}"
);
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.lines().nth(1).unwrap().starts_with(&first[..12]),
"listed reformat is skipped; line 2 falls through: {stdout:?}"
);
}
#[test]
fn blame_c_c_widens_to_unchanged_source_file() {
let b1 = "fn handler_alpha() { compute(); }";
let b2 = "fn handler_bravo() { compute(); }";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(
td.path(),
"src.txt",
format!("{b1}\n{b2}\n").as_bytes(),
"first",
);
let first = head_hash(td.path());
fs::write(td.path().join("dst.txt"), format!("{b1}\n{b2}\n")).unwrap();
assert!(run_in(td.path(), &["add", "dst.txt"]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "copy"])
.status
.success()
);
let second = head_hash(td.path());
let l1 = run_in(td.path(), &["blame", "-C", "dst.txt"]);
let l1_out = String::from_utf8(l1.stdout).unwrap();
assert!(
l1_out.lines().all(|l| l.starts_with(&second[..12])),
"-C level 1 misses the unchanged source: {l1_out:?}"
);
let l2 = run_in(td.path(), &["blame", "-C", "-C", "dst.txt"]);
assert!(l2.status.success(), "blame -C -C failed: {l2:?}");
let l2_out = String::from_utf8(l2.stdout).unwrap();
assert!(
l2_out.lines().all(|l| l.starts_with(&first[..12])),
"-C -C credits the copied block to its origin: {l2_out:?}"
);
}
#[test]
fn blame_c_inline_threshold_flips_copy_attribution() {
let block = "abcdefghijklmnopqrstuvwxyzabcdefghijklmn"; assert_eq!(block.chars().filter(|c| c.is_alphanumeric()).count(), 40);
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(
td.path(),
"a.txt",
format!("{block}\nkeeper\n").as_bytes(),
"first",
);
let first = head_hash(td.path());
fs::write(td.path().join("a.txt"), b"keeper\n").unwrap();
fs::write(td.path().join("b.txt"), format!("{block}\n")).unwrap();
assert!(
run_in(td.path(), &["add", "a.txt", "b.txt"])
.status
.success()
);
assert!(
run_in(td.path(), &["commit", "-m", "split"])
.status
.success()
);
let second = head_hash(td.path());
let above = run_in(td.path(), &["blame", "-C41", "b.txt"]);
assert!(above.status.success(), "blame -C41 failed: {above:?}");
let above_out = String::from_utf8(above.stdout).unwrap();
assert!(
above_out.lines().all(|l| l.starts_with(&second[..12])),
"-C41 (> 40-char block) misses the copy: {above_out:?}"
);
let at = run_in(td.path(), &["blame", "-C40", "b.txt"]);
assert!(at.status.success(), "blame -C40 failed: {at:?}");
let at_out = String::from_utf8(at.stdout).unwrap();
assert!(
at_out.lines().all(|l| l.starts_with(&first[..12])),
"-C40 (== block) credits the origin commit: {at_out:?}"
);
let pct = run_in(td.path(), &["blame", "-C40%", "b.txt"]);
assert!(pct.status.success(), "blame -C40% failed: {pct:?}");
let pct_out = String::from_utf8(pct.stdout).unwrap();
assert_eq!(
pct_out, at_out,
"-C40% is treated as the same char-count threshold as -C40"
);
let bad = run_in(td.path(), &["blame", "-Cxyz", "b.txt"]);
assert!(!bad.status.success(), "-Cxyz must be rejected");
}
#[test]
fn blame_c_inline_threshold_composes_with_repeat() {
let b1 = "fn handler_alpha() { compute(); }";
let b2 = "fn handler_bravo() { compute(); }";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(
td.path(),
"src.txt",
format!("{b1}\n{b2}\n").as_bytes(),
"first",
);
let first = head_hash(td.path());
fs::write(td.path().join("dst.txt"), format!("{b1}\n{b2}\n")).unwrap();
assert!(run_in(td.path(), &["add", "dst.txt"]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "copy"])
.status
.success()
);
let second = head_hash(td.path());
let l1 = run_in(td.path(), &["blame", "-C20", "dst.txt"]);
let l1_out = String::from_utf8(l1.stdout).unwrap();
assert!(
l1_out.lines().all(|l| l.starts_with(&second[..12])),
"-C20 (level 1) misses the unchanged source: {l1_out:?}"
);
let l2 = run_in(td.path(), &["blame", "-C20", "-C", "dst.txt"]);
assert!(l2.status.success(), "blame -C20 -C failed: {l2:?}");
let l2_out = String::from_utf8(l2.stdout).unwrap();
assert!(
l2_out.lines().all(|l| l.starts_with(&first[..12])),
"-C20 -C reaches level 2 and credits the origin: {l2_out:?}"
);
}
#[test]
fn blame_c_c_c_searches_whole_parent_tree_for_unmodified_source() {
let b1 = "fn compute_alpha_beta_gamma() { let x = 1234567; }";
let b2 = "fn compute_delta_epsilon_ze() { let y = 7654321; }";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
fs::write(td.path().join("main.txt"), b"header line one here\n").unwrap();
fs::write(td.path().join("src.txt"), format!("{b1}\n{b2}\n")).unwrap();
assert!(run_in(td.path(), &["add", "-A"]).status.success());
assert!(run_in(td.path(), &["commit", "-m", "c1"]).status.success());
let c1 = head_hash(td.path());
fs::write(
td.path().join("main.txt"),
format!("header line one here\n{b1}\n{b2}\n"),
)
.unwrap();
assert!(run_in(td.path(), &["add", "main.txt"]).status.success());
assert!(run_in(td.path(), &["commit", "-m", "c2"]).status.success());
let c2 = head_hash(td.path());
let l2 = run_in(td.path(), &["blame", "-C", "-C", "main.txt"]);
assert!(l2.status.success(), "blame -C -C failed: {l2:?}");
let l2_out = String::from_utf8(l2.stdout).unwrap();
assert!(
l2_out.lines().nth(1).unwrap().starts_with(&c2[..12]),
"-C -C leaves the appended block on the append commit c2: {l2_out:?}"
);
let l3 = run_in(td.path(), &["blame", "-C", "-C", "-C", "main.txt"]);
assert!(l3.status.success(), "blame -C -C -C failed: {l3:?}");
let l3_out = String::from_utf8(l3.stdout).unwrap();
assert!(
l3_out.lines().nth(1).unwrap().starts_with(&c1[..12]),
"-C -C -C credits the unmodified source's origin c1 ({}), not c2 ({}): {l3_out:?}",
&c1[..12],
&c2[..12]
);
}
#[test]
fn blame_stacked_short_flags_reach_clap() {
let b1 = "fn handler_alpha() { compute(); }";
let b2 = "fn handler_bravo() { compute(); }";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(
td.path(),
"src.txt",
format!("{b1}\n{b2}\n").as_bytes(),
"c1",
);
let first = head_hash(td.path());
fs::write(td.path().join("dst.txt"), format!("{b1}\n{b2}\n")).unwrap();
assert!(run_in(td.path(), &["add", "dst.txt"]).status.success());
assert!(run_in(td.path(), &["commit", "-m", "c2"]).status.success());
let cc = run_in(td.path(), &["blame", "-CC", "dst.txt"]);
assert!(cc.status.success(), "blame -CC failed: {cc:?}");
let cc_out = String::from_utf8(cc.stdout).unwrap();
assert!(
cc_out.lines().all(|l| l.starts_with(&first[..12])),
"-CC is level 2 and credits the origin: {cc_out:?}"
);
assert!(
run_in(td.path(), &["blame", "-Mw", "dst.txt"])
.status
.success(),
"-Mw (= -M -w) must be accepted"
);
let bad = run_in(td.path(), &["blame", "-Cxyz", "dst.txt"]);
assert_eq!(
bad.status.code(),
Some(64),
"a bad -C value is a clap USAGE error (64), not DATAERR: {bad:?}"
);
}
#[test]
fn blame_c_copy_tiebreak_prefers_older_ancestor_source() {
let b1 = "fn compute_alpha_beta_gamma() { let x = 1234567; }";
let b2 = "fn compute_delta_epsilon_ze() { let y = 7654321; }";
let block = format!("{b1}\n{b2}\n");
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "src_z.txt", block.as_bytes(), "c1");
let c1 = head_hash(td.path());
make_commit(td.path(), "src_a.txt", block.as_bytes(), "c2");
let c2 = head_hash(td.path());
make_commit(td.path(), "dst.txt", block.as_bytes(), "c3");
let out = run_in(td.path(), &["blame", "-C", "-C", "dst.txt"]);
assert!(out.status.success(), "blame -C -C failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.lines().all(|l| l.starts_with(&c1[..12])),
"tie-break must credit the older ancestor source (c1/src_z), not \
the newer src_a ({}): {stdout:?}",
&c2[..12]
);
}
#[test]
fn blame_c_copy_tiebreak_older_source_wins_regardless_of_path_order() {
let b1 = "fn compute_alpha_beta_gamma() { let x = 1234567; }";
let b2 = "fn compute_delta_epsilon_ze() { let y = 7654321; }";
let block = format!("{b1}\n{b2}\n");
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "src_a.txt", block.as_bytes(), "c1");
let c1 = head_hash(td.path());
make_commit(td.path(), "src_z.txt", block.as_bytes(), "c2");
let c2 = head_hash(td.path());
make_commit(td.path(), "dst.txt", block.as_bytes(), "c3");
let out = run_in(td.path(), &["blame", "-C", "-C", "dst.txt"]);
assert!(out.status.success(), "blame -C -C failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.lines().all(|l| l.starts_with(&c1[..12])),
"tie-break must credit the older ancestor source (c1/src_a), not \
the newer src_z ({}): {stdout:?}",
&c2[..12]
);
}
#[test]
fn blame_porcelain_matches_git_field_block_with_boundary() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"alpha\nbravo\ncharlie\n", "c1 initial");
make_commit(
td.path(),
"f.txt",
b"alpha\nBRAVO2\ncharlie\ndelta\n",
"c2 edit+add",
);
let out = run_in(td.path(), &["blame", "--porcelain", "f.txt"]);
assert!(out.status.success(), "blame --porcelain failed: {out:?}");
let s = String::from_utf8(out.stdout).unwrap();
let rows: Vec<&str> = s.lines().collect();
let h0: Vec<&str> = rows[0].split(' ').collect();
assert_eq!(h0.len(), 4, "header shape: {:?}", rows[0]);
assert_eq!(h0[0].len(), 64, "mkit uses 64-hex ids");
assert_eq!((h0[1], h0[2]), ("1", "1"), "orig+final line numbers");
assert!(rows[1].starts_with("author ed25519:"), "{:?}", rows[1]);
assert_eq!(rows[2], "author-mail <>");
assert!(rows[3].starts_with("author-time "));
assert_eq!(rows[4], "author-tz +0000");
assert!(rows[5].starts_with("committer ed25519:"));
assert_eq!(rows[6], "committer-mail <>");
assert!(rows[7].starts_with("committer-time "));
assert_eq!(rows[8], "committer-tz +0000");
assert_eq!(rows[9], "summary c1 initial");
assert_eq!(rows[10], "boundary");
assert_eq!(rows[11], "filename f.txt");
assert_eq!(rows[12], "\talpha");
assert_eq!(
s.lines().filter(|l| l.starts_with("author ")).count(),
2,
"grouped porcelain emits metadata once per commit"
);
assert_eq!(s.lines().filter(|l| *l == "boundary").count(), 1);
let content: Vec<&str> = s.lines().filter_map(|l| l.strip_prefix('\t')).collect();
assert_eq!(content, ["alpha", "BRAVO2", "charlie", "delta"]);
}
#[test]
fn blame_line_porcelain_repeats_header_per_line() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"alpha\nbravo\ncharlie\n", "c1");
make_commit(td.path(), "f.txt", b"alpha\nBRAVO2\ncharlie\ndelta\n", "c2");
let out = run_in(td.path(), &["blame", "--line-porcelain", "f.txt"]);
assert!(
out.status.success(),
"blame --line-porcelain failed: {out:?}"
);
let s = String::from_utf8(out.stdout).unwrap();
let content = s.lines().filter(|l| l.starts_with('\t')).count();
let authors = s.lines().filter(|l| l.starts_with("author ")).count();
assert_eq!(content, 4, "four content lines");
assert_eq!(
authors, content,
"line-porcelain repeats the header for every line"
);
}
#[test]
fn blame_porcelain_emits_copy_source_filename() {
let b1 = "fn handler_alpha_beta_gamma() { compute_the_thing(); }";
let b2 = "fn second_helper_delta_epsilon() { do_more(); }";
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", format!("{b1}\n{b2}\n").as_bytes(), "c1");
fs::write(td.path().join("a.txt"), b"leftover\n").unwrap();
fs::write(td.path().join("b.txt"), format!("{b1}\n{b2}\n")).unwrap();
assert!(
run_in(td.path(), &["add", "a.txt", "b.txt"])
.status
.success()
);
assert!(
run_in(td.path(), &["commit", "-m", "c2 split"])
.status
.success()
);
let out = run_in(td.path(), &["blame", "-C", "--porcelain", "b.txt"]);
assert!(out.status.success(), "blame -C --porcelain failed: {out:?}");
let s = String::from_utf8(out.stdout).unwrap();
assert!(
s.lines().any(|l| l == "filename a.txt"),
"copied block's porcelain filename is the source a.txt: {s}"
);
let first = s.lines().next().unwrap();
let h: Vec<&str> = first.split(' ').collect();
assert_eq!(h[3], "2", "the two copied lines form one group: {first:?}");
}
#[test]
fn blame_ignore_rev_unknown_errors_like_git() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\n", "first");
let out = run_in(
td.path(),
&["blame", "--ignore-rev", "no-such-rev", "f.txt"],
);
assert!(
!out.status.success(),
"expected failure on unknown ignore-rev"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("cannot find revision no-such-rev to ignore"),
"expected git-faithful ignore-rev diagnostic, got: {stderr}"
);
}
#[test]
fn blame_ignore_revs_file_errors_are_git_faithful() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\n", "first");
let missing = run_in(
td.path(),
&["blame", "--ignore-revs-file", "nope.txt", "f.txt"],
);
assert!(!missing.status.success());
assert!(
String::from_utf8(missing.stderr)
.unwrap()
.contains("could not open object name list: nope.txt"),
"expected git-faithful missing-file diagnostic"
);
fs::write(td.path().join("bad.txt"), "zzznothex\n").unwrap();
let bad = run_in(
td.path(),
&["blame", "--ignore-revs-file", "bad.txt", "f.txt"],
);
assert!(!bad.status.success());
assert!(
String::from_utf8(bad.stderr)
.unwrap()
.contains("invalid object name: zzznothex"),
"expected git-faithful invalid-object-name diagnostic"
);
}
#[test]
fn blame_ignore_rev_precise_requires_ignore_rev() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\n", "first");
let out = run_in(td.path(), &["blame", "--ignore-rev-precise", "f.txt"]);
assert!(
!out.status.success(),
"expected --ignore-rev-precise without an ignore set to be rejected"
);
assert!(
String::from_utf8(out.stderr)
.unwrap()
.contains("--ignore-rev-precise requires --ignore-rev or --ignore-revs-file"),
"expected the documented usage error"
);
}
#[test]
fn blame_ignore_rev_precise_diverges_from_positional_default() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"keep\ntail\n", "c0");
make_commit(td.path(), "f.txt", b"keep\nXXX\ntail\n", "c1");
make_commit(td.path(), "f.txt", b"keep\nXXX\nYYY\ntail\n", "c2");
make_commit(td.path(), "f.txt", b"keep\nXXX\nYYY\nZZZ\ntail\n", "c3");
make_commit(
td.path(),
"f.txt",
b"keep\n ZZZ\n YYY\n XXX\ntail\n",
"noise",
);
let noise = head_hash(td.path());
let positional = run_in(td.path(), &["blame", "-w", "--ignore-rev", &noise, "f.txt"]);
assert!(
positional.status.success(),
"positional blame failed: {positional:?}"
);
let positional_out = String::from_utf8(positional.stdout).unwrap();
let positional_lines: Vec<&str> = positional_out.lines().collect();
assert!(
positional_lines[2].starts_with(&noise[..12]),
"positional: YYY has no in-hunk counterpart, stays on the noise commit: {positional_lines:?}"
);
assert!(
positional_lines[3].starts_with(&noise[..12]),
"positional: XXX has no in-hunk counterpart, stays on the noise commit: {positional_lines:?}"
);
let precise = run_in(
td.path(),
&[
"blame",
"-w",
"--ignore-rev",
&noise,
"--ignore-rev-precise",
"f.txt",
],
);
assert!(
precise.status.success(),
"precise blame failed: {precise:?}"
);
let precise_out = String::from_utf8(precise.stdout).unwrap();
let precise_lines: Vec<&str> = precise_out.lines().collect();
assert!(
!precise_lines[2].starts_with(&noise[..12]),
"precise: YYY is reattributed off the noise commit: {precise_lines:?}"
);
assert!(
!precise_lines[3].starts_with(&noise[..12]),
"precise: XXX is reattributed off the noise commit: {precise_lines:?}"
);
assert_ne!(
positional_out, precise_out,
"precise mode must produce a genuinely different (divergent) attribution here"
);
}
#[test]
fn blame_reverse_attributes_lines_to_last_surviving_commit() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"keep\ndoomed\nalso\n", "c1");
let c1 = head_hash(td.path());
make_commit(td.path(), "f.txt", b"keep\ndoomed\nalso\nextra\n", "c2");
let c2 = head_hash(td.path());
make_commit(
td.path(),
"f.txt",
b"keep\nalso\nextra\n",
"c3_removes_doomed",
);
make_commit(td.path(), "f.txt", b"keep\nalso\nextra2\n", "c4");
let c4 = head_hash(td.path());
let out = run_in(
td.path(),
&["blame", "--reverse", &format!("{c1}..{c4}"), "f.txt"],
);
assert!(out.status.success(), "blame --reverse failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert_eq!(lines.len(), 3, "blames the start (c1) version: {stdout:?}");
assert!(lines[0].starts_with(&c4[..12]) && lines[0].ends_with("\tkeep"));
assert!(
lines[1].starts_with(&c2[..12]) && lines[1].ends_with("\tdoomed"),
"doomed last existed in c2: {stdout:?}"
);
assert!(lines[2].starts_with(&c4[..12]) && lines[2].ends_with("\talso"));
}
#[test]
fn blame_reverse_open_end_defaults_to_head() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\nb\n", "c1");
let c1 = head_hash(td.path());
make_commit(td.path(), "f.txt", b"a\nB2\n", "c2");
let c2 = head_hash(td.path());
let out = run_in(
td.path(),
&["blame", "--reverse", &format!("{c1}.."), "f.txt"],
);
assert!(out.status.success(), "open-end reverse failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines[0].starts_with(&c2[..12]),
"a survives to HEAD: {stdout:?}"
);
assert!(
lines[1].starts_with(&c1[..12]),
"b last existed in c1: {stdout:?}"
);
}
#[test]
fn blame_reverse_requires_a_range() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\n", "c1");
let c1 = head_hash(td.path());
let none = run_in(td.path(), &["blame", "--reverse", "f.txt"]);
assert!(!none.status.success());
assert!(
String::from_utf8(none.stderr)
.unwrap()
.contains("requires a <start>..<end>"),
"expected a clear missing-range error"
);
let bare = run_in(td.path(), &["blame", "--reverse", &c1, "f.txt"]);
assert!(!bare.status.success());
assert!(
String::from_utf8(bare.stderr)
.unwrap()
.contains("<start>..<end>"),
"expected a clear bare-revision error"
);
let open = run_in(
td.path(),
&["blame", "--reverse", &format!("..{c1}"), "f.txt"],
);
assert!(!open.status.success());
assert!(
String::from_utf8(open.stderr)
.unwrap()
.contains("explicit <start>"),
"expected a clear open-start error"
);
}
#[test]
fn blame_reverse_rejects_malformed_and_empty_ranges() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\n", "c1");
let c1 = head_hash(td.path());
make_commit(td.path(), "f.txt", b"a\nb\n", "c2");
let c2 = head_hash(td.path());
let triple = run_in(
td.path(),
&["blame", "--reverse", &format!("{c1}...{c2}"), "f.txt"],
);
assert!(!triple.status.success());
assert!(
String::from_utf8(triple.stderr)
.unwrap()
.contains("single <start>..<end>"),
"expected a clear triple-dot error"
);
let extra = run_in(
td.path(),
&["blame", "--reverse", &format!("{c1}..{c2}..{c1}"), "f.txt"],
);
assert!(!extra.status.success());
assert!(
String::from_utf8(extra.stderr)
.unwrap()
.contains("single <start>..<end>"),
"expected a clear extra-dot error"
);
let no_file = run_in(td.path(), &["blame", "--reverse", &format!("{c1}..{c2}")]);
assert!(!no_file.status.success());
assert!(
String::from_utf8(no_file.stderr)
.unwrap()
.contains("missing <file>"),
"expected a missing-file hint, not a bogus range error"
);
let empty = run_in(
td.path(),
&["blame", "--reverse", &format!("{c1}..{c1}"), "f.txt"],
);
assert!(!empty.status.success());
assert!(
String::from_utf8(empty.stderr)
.unwrap()
.contains("empty revision range"),
"expected an empty-range error"
);
}
#[test]
fn blame_reverse_rejects_detection_flags() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"a\n", "c1");
let c1 = head_hash(td.path());
let range = format!("{c1}..");
for flag in [vec!["-M"], vec!["-C"], vec!["--ignore-rev", &c1]] {
let mut args = vec!["blame", "--reverse", &range, "f.txt"];
args.extend(flag.iter().copied());
let out = run_in(td.path(), &args);
assert!(
!out.status.success(),
"expected --reverse + {flag:?} to be rejected"
);
assert!(
String::from_utf8(out.stderr)
.unwrap()
.contains("--reverse cannot be combined"),
"expected a clear combination error for {flag:?}"
);
}
}
#[test]
fn blame_merge_aware_vs_first_parent() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "f.txt", b"base1\nbase2\n", "base");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
make_commit(td.path(), "f.txt", b"base1\nbase2\nfeature-line\n", "feat");
let feat = ref_hash(td.path(), "feature"); assert!(run_in(td.path(), &["checkout", "main"]).status.success());
make_commit(td.path(), "f.txt", b"main-line\nbase1\nbase2\n", "main");
let merge_out = run_in(td.path(), &["merge", "feature"]);
assert!(merge_out.status.success(), "merge failed: {merge_out:?}");
let merge = head_hash(td.path());
let def = run_in(td.path(), &["blame", "f.txt"]);
assert!(def.status.success(), "blame failed: {def:?}");
let dout = String::from_utf8(def.stdout).unwrap();
let dlines: Vec<&str> = dout.lines().collect();
assert_eq!(dlines.len(), 4, "merged file has 4 lines: {dout:?}");
assert!(
dlines[3].starts_with(&feat[..12]) && dlines[3].ends_with("\tfeature-line"),
"default credits the feature line to the feature commit: {dout:?}"
);
assert!(
dlines.iter().all(|l| !l.starts_with(&merge[..12])),
"no line is credited to the merge under merge-aware blame: {dout:?}"
);
let fp = run_in(td.path(), &["blame", "--first-parent", "f.txt"]);
assert!(fp.status.success(), "blame --first-parent failed: {fp:?}");
let fout = String::from_utf8(fp.stdout).unwrap();
let flines: Vec<&str> = fout.lines().collect();
assert!(
flines[3].starts_with(&merge[..12]),
"--first-parent credits the feature line to the merge: {fout:?}"
);
}
#[test]
fn serve_errors_on_missing_path() {
let td = tempfile::tempdir().unwrap();
let out = run_in(td.path(), &["serve"]);
assert!(!out.status.success());
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.to_lowercase().contains("usage"),
"expected usage diagnostic on stderr, got: {stderr}"
);
}
#[test]
fn serve_rejects_bad_handshake_and_exits() {
use std::io::Write;
use std::process::Stdio;
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let mut child = Command::new(mkit_bin())
.args(["serve", td.path().to_str().unwrap()])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.expect("spawn serve");
let frame = [0x01, 0, 0, 0, 0];
child.stdin.as_mut().unwrap().write_all(&frame).unwrap();
drop(child.stdin.take());
let out = child.wait_with_output().expect("wait serve");
assert_eq!(
out.status.code(),
Some(76),
"serve must exit PROTOCOL_ERROR (76) on a bad handshake: {out:?}"
);
}
#[test]
fn sparse_checkout_set_without_patterns_errors() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
let out = run_in(td.path(), &["sparse-checkout", "set"]);
assert!(!out.status.success());
}
#[test]
fn sparse_checkout_roundtrips_patterns() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"x\n", "c1");
assert!(
run_in(td.path(), &["sparse-checkout", "set", "a.txt"])
.status
.success()
);
let out = run_in(td.path(), &["sparse-checkout", "list"]);
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("a.txt"));
assert!(
run_in(td.path(), &["sparse-checkout", "disable"])
.status
.success()
);
}
#[test]
fn sparse_checkout_set_refuses_dirty_tracked_file_inside_sparse_set() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"v1\n", "c1");
fs::write(td.path().join("a.txt"), b"local edit\n").unwrap();
let out = run_in(td.path(), &["sparse-checkout", "set", "a.txt"]);
assert!(!out.status.success(), "sparse set should fail: {out:?}");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("restore would overwrite local changes"));
assert_eq!(fs::read(td.path().join("a.txt")).unwrap(), b"local edit\n");
assert!(!td.path().join(".mkit/sparse-checkout").exists());
}
#[test]
fn sparse_checkout_set_allows_dirty_tracked_file_outside_sparse_set() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
fs::write(td.path().join("a.txt"), b"a\n").unwrap();
fs::write(td.path().join("b.txt"), b"b\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(run_in(td.path(), &["commit", "-m", "c1"]).status.success());
fs::write(td.path().join("b.txt"), b"local b\n").unwrap();
let out = run_in(td.path(), &["sparse-checkout", "set", "a.txt"]);
assert!(out.status.success(), "sparse set failed: {out:?}");
assert_eq!(fs::read(td.path().join("b.txt")).unwrap(), b"local b\n");
assert_eq!(
fs::read_to_string(td.path().join(".mkit/sparse-checkout")).unwrap(),
"a.txt\n"
);
}
#[test]
fn sparse_checkout_disable_refuses_untracked_file_that_full_restore_would_remove() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"a\n", "c1");
assert!(
run_in(td.path(), &["sparse-checkout", "set", "a.txt"])
.status
.success()
);
fs::write(td.path().join("notes.txt"), b"local notes\n").unwrap();
let out = run_in(td.path(), &["sparse-checkout", "disable"]);
assert!(!out.status.success(), "sparse disable should fail: {out:?}");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("restore would remove untracked path"));
assert_eq!(
fs::read(td.path().join("notes.txt")).unwrap(),
b"local notes\n"
);
assert_eq!(
fs::read_to_string(td.path().join(".mkit/sparse-checkout")).unwrap(),
"a.txt\n"
);
}
#[test]
fn diff_head_tilde_one_shows_second_commit_change() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"one\n", "c1");
make_commit(td.path(), "b.txt", b"two\n", "c2");
let out = run_in(td.path(), &["diff", "HEAD~1"]);
assert!(out.status.success(), "diff HEAD~1 failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("b.txt"),
"expected b.txt in diff HEAD~1 output, got: {stdout:?}"
);
}
#[test]
fn diff_branch_ref_resolves_and_diffs() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"one\n", "c1");
assert!(run_in(td.path(), &["branch", "base"]).status.success());
make_commit(td.path(), "b.txt", b"two\n", "c2");
let out = run_in(td.path(), &["diff", "base"]);
assert!(out.status.success(), "diff base failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("b.txt"),
"expected b.txt in diff base output, got: {stdout:?}"
);
}
#[test]
fn diff_bad_revision_errors_not_silent_empty() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"one\n", "c1");
let bogus = "ab".repeat(32);
let out = run_in(td.path(), &["diff", &bogus]);
assert!(!out.status.success(), "bad revision should fail: {out:?}");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.to_lowercase().contains("revision"),
"expected a revision diagnostic, got: {stderr}"
);
}
#[test]
fn diff_staged_with_revision_is_usage_error() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"one\n", "c1");
let out = run_in(td.path(), &["diff", "--staged", "HEAD"]);
assert!(!out.status.success(), "--staged HEAD should fail: {out:?}");
}
#[test]
fn branch_create_collision_is_rejected() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"one\n", "c1");
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
let out = run_in(td.path(), &["branch", "feature"]);
assert!(
!out.status.success(),
"duplicate branch should fail: {out:?}"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("already exists"),
"expected collision diagnostic, got: {stderr}"
);
}
#[test]
fn branch_delete_current_is_rejected() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"one\n", "c1");
let out = run_in(td.path(), &["branch", "-d", "main"]);
assert!(
!out.status.success(),
"deleting current branch should fail: {out:?}"
);
}
#[test]
fn tag_create_collision_is_rejected() {
let td = tempfile::tempdir().unwrap();
init_repo(td.path());
make_commit(td.path(), "a.txt", b"one\n", "c1");
assert!(run_in(td.path(), &["tag", "v1"]).status.success());
let out = run_in(td.path(), &["tag", "v1"]);
assert!(!out.status.success(), "duplicate tag should fail: {out:?}");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("already exists"),
"expected collision diagnostic, got: {stderr}"
);
}