use std::fs;
use std::process::Command;
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
fn run_in(cwd: &std::path::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_with_commit(files: &[(&str, &[u8])]) -> tempfile::TempDir {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
for (name, content) in files {
let full = td.path().join(name);
if let Some(parent) = full.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(&full, content).unwrap();
}
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "initial"])
.status
.success(),
"commit failed"
);
td
}
fn porcelain(cwd: &std::path::Path) -> String {
let out = run_in(cwd, &["status", "--porcelain"]);
assert!(out.status.success(), "status failed: {out:?}");
String::from_utf8(out.stdout).unwrap()
}
#[test]
fn rm_removes_worktree_file_and_stages_deletion() {
let td = init_with_commit(&[("a.txt", b"hello")]);
let out = run_in(td.path(), &["rm", "a.txt"]);
assert!(out.status.success(), "rm failed: {out:?}");
assert!(
!td.path().join("a.txt").exists(),
"rm should delete the worktree file"
);
let p = porcelain(td.path());
assert!(
p.lines().any(|l| l == "D a.txt"),
"expected staged deletion `D `, got: {p:?}"
);
}
#[test]
fn rm_cached_keeps_worktree_file() {
let td = init_with_commit(&[("a.txt", b"hello")]);
let out = run_in(td.path(), &["rm", "--cached", "a.txt"]);
assert!(out.status.success(), "rm --cached failed: {out:?}");
assert!(
td.path().join("a.txt").exists(),
"rm --cached must keep the worktree file"
);
}
#[test]
fn rm_cached_keeps_dirty_worktree_file_untouched() {
let td = init_with_commit(&[("a.txt", b"clean")]);
fs::write(td.path().join("a.txt"), b"locally modified").unwrap();
let out = run_in(td.path(), &["rm", "--cached", "a.txt"]);
assert!(out.status.success(), "rm --cached failed: {out:?}");
assert!(
td.path().join("a.txt").exists(),
"rm --cached must keep the dirty worktree file"
);
assert_eq!(
fs::read(td.path().join("a.txt")).unwrap(),
b"locally modified",
"rm --cached must not alter the worktree file's content"
);
}
#[test]
fn rm_refuses_dirty_file_without_force() {
let td = init_with_commit(&[("a.txt", b"clean")]);
fs::write(td.path().join("a.txt"), b"locally modified").unwrap();
let out = run_in(td.path(), &["rm", "a.txt"]);
assert!(
!out.status.success(),
"rm of a modified file must fail without --force"
);
assert!(
td.path().join("a.txt").exists(),
"dirty file must NOT be destroyed without --force"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("local modifications"),
"expected dirty-guard message, got: {stderr}"
);
}
#[test]
fn rm_force_discards_dirty_file() {
let td = init_with_commit(&[("a.txt", b"clean")]);
fs::write(td.path().join("a.txt"), b"locally modified").unwrap();
let out = run_in(td.path(), &["rm", "--force", "a.txt"]);
assert!(out.status.success(), "rm --force failed: {out:?}");
assert!(
!td.path().join("a.txt").exists(),
"rm --force should delete the modified file"
);
}
#[test]
fn rm_directory_requires_recursive() {
let td = init_with_commit(&[("dir/x.txt", b"x"), ("dir/y.txt", b"y")]);
let out = run_in(td.path(), &["rm", "dir"]);
assert!(
!out.status.success(),
"removing a directory without -r must fail"
);
assert!(td.path().join("dir/x.txt").exists());
}
#[test]
fn rm_recursive_removes_directory_tree() {
let td = init_with_commit(&[("dir/x.txt", b"x"), ("dir/y.txt", b"y"), ("keep.txt", b"k")]);
let out = run_in(td.path(), &["rm", "-r", "dir"]);
assert!(out.status.success(), "rm -r failed: {out:?}");
assert!(!td.path().join("dir/x.txt").exists());
assert!(!td.path().join("dir/y.txt").exists());
assert!(
!td.path().join("dir").exists(),
"empty dir should be pruned"
);
assert!(td.path().join("keep.txt").exists());
}
#[test]
fn rm_multiple_pathspecs() {
let td = init_with_commit(&[("a.txt", b"a"), ("b.txt", b"b"), ("c.txt", b"c")]);
let out = run_in(td.path(), &["rm", "a.txt", "b.txt"]);
assert!(out.status.success(), "multi rm failed: {out:?}");
assert!(!td.path().join("a.txt").exists());
assert!(!td.path().join("b.txt").exists());
assert!(td.path().join("c.txt").exists());
}
#[test]
fn rm_unmatched_pathspec_errors() {
let td = init_with_commit(&[("a.txt", b"a")]);
let out = run_in(td.path(), &["rm", "nope.txt"]);
assert!(!out.status.success(), "rm of untracked path should error");
assert!(td.path().join("a.txt").exists());
}
#[test]
fn diff_emits_unified_hunks_for_modified_file() {
let td = init_with_commit(&[("f.txt", b"line1\nline2\nline3\n")]);
fs::write(td.path().join("f.txt"), b"line1\nCHANGED\nline3\n").unwrap();
let out = run_in(td.path(), &["diff"]);
assert!(out.status.success(), "diff failed: {out:?}");
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("diff --git a/f.txt b/f.txt"), "{stdout}");
assert!(stdout.contains("--- a/f.txt"), "{stdout}");
assert!(stdout.contains("+++ b/f.txt"), "{stdout}");
assert!(stdout.contains("@@ "), "expected a hunk header: {stdout}");
assert!(stdout.contains("-line2"), "{stdout}");
assert!(stdout.contains("+CHANGED"), "{stdout}");
}
#[test]
fn diff_staged_shows_index_vs_head() {
let td = init_with_commit(&[("f.txt", b"original\n")]);
fs::write(td.path().join("f.txt"), b"staged change\n").unwrap();
assert!(run_in(td.path(), &["add", "f.txt"]).status.success());
fs::write(td.path().join("f.txt"), b"original\n").unwrap();
let plain = run_in(td.path(), &["diff"]);
let plain_out = String::from_utf8(plain.stdout).unwrap();
assert!(
plain_out.trim().is_empty(),
"plain diff should be empty after reverting worktree, got: {plain_out}"
);
let staged = run_in(td.path(), &["diff", "--staged"]);
assert!(staged.status.success());
let staged_out = String::from_utf8(staged.stdout).unwrap();
assert!(
staged_out.contains("+staged change"),
"--staged should show the staged delta: {staged_out}"
);
}
#[test]
fn diff_pathspec_filters_output() {
let td = init_with_commit(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]);
fs::write(td.path().join("a.txt"), b"a-changed\n").unwrap();
fs::write(td.path().join("b.txt"), b"b-changed\n").unwrap();
let out = run_in(td.path(), &["diff", "a.txt"]);
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(stdout.contains("a/a.txt"), "{stdout}");
assert!(
!stdout.contains("b.txt"),
"pathspec must exclude b.txt: {stdout}"
);
}
#[test]
fn diff_binary_reports_differ() {
let td = init_with_commit(&[("bin", &[0x00, 0x01, 0xff])]);
fs::write(td.path().join("bin"), [0x00, 0x02, 0xfe]).unwrap();
let out = run_in(td.path(), &["diff"]);
assert!(out.status.success());
let stdout = String::from_utf8(out.stdout).unwrap();
assert!(
stdout.contains("Binary files a/bin and b/bin differ"),
"{stdout}"
);
}
#[test]
fn add_all_stages_new_and_deleted() {
let td = init_with_commit(&[("a.txt", b"a"), ("b.txt", b"b")]);
fs::write(td.path().join("c.txt"), b"c").unwrap();
fs::remove_file(td.path().join("b.txt")).unwrap();
let out = run_in(td.path(), &["add", "-A"]);
assert!(out.status.success(), "add -A failed: {out:?}");
let p = porcelain(td.path());
assert!(
p.lines().any(|l| l == "A c.txt"),
"c.txt should be staged-added: {p}"
);
assert!(
p.lines().any(|l| l == "D b.txt"),
"b.txt deletion should be staged: {p}"
);
}
#[test]
fn add_update_stages_only_tracked() {
let td = init_with_commit(&[("a.txt", b"a")]);
fs::write(td.path().join("a.txt"), b"a-modified").unwrap();
fs::write(td.path().join("u.txt"), b"untracked").unwrap();
let out = run_in(td.path(), &["add", "-u"]);
assert!(out.status.success(), "add -u failed: {out:?}");
let p = porcelain(td.path());
assert!(
p.lines().any(|l| l == "M a.txt"),
"a.txt mod should be staged: {p}"
);
assert!(
p.lines().any(|l| l == "?? u.txt"),
"u.txt must remain untracked under -u: {p}"
);
}
#[test]
fn add_multiple_pathspecs() {
let td = init_with_commit(&[("seed.txt", b"seed")]);
fs::write(td.path().join("a.txt"), b"a").unwrap();
fs::write(td.path().join("b.txt"), b"b").unwrap();
fs::write(td.path().join("c.txt"), b"c").unwrap();
let out = run_in(td.path(), &["add", "a.txt", "b.txt"]);
assert!(out.status.success(), "multi add failed: {out:?}");
let p = porcelain(td.path());
assert!(p.lines().any(|l| l == "A a.txt"), "{p}");
assert!(p.lines().any(|l| l == "A b.txt"), "{p}");
assert!(p.lines().any(|l| l == "?? c.txt"), "{p}");
}
#[test]
fn add_rejects_all_with_paths() {
let td = init_with_commit(&[("a.txt", b"a")]);
let out = run_in(td.path(), &["add", "-A", "a.txt"]);
assert!(!out.status.success(), "-A with a path must error");
}
#[test]
fn add_rejects_update_with_paths() {
let td = init_with_commit(&[("a.txt", b"a")]);
let out = run_in(td.path(), &["add", "-u", "a.txt"]);
assert!(!out.status.success(), "-u with a path must error");
}
#[test]
fn add_rejects_all_and_update_together() {
let td = init_with_commit(&[("a.txt", b"a")]);
let out = run_in(td.path(), &["add", "-A", "-u"]);
assert!(!out.status.success(), "-A and -u together must error");
}