#![allow(clippy::unwrap_used)]
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
}
#[test]
fn checkout_refuses_removed_tracked_files() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("a.txt"), b"alpha\n").unwrap();
fs::write(td.path().join("b.txt"), b"bravo\n").unwrap();
fs::create_dir_all(td.path().join("sub")).unwrap();
fs::write(td.path().join("sub/c.txt"), b"charlie\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
let out = run_in(td.path(), &["commit", "-m", "initial"]);
assert!(out.status.success(), "commit failed: {out:?}");
fs::remove_file(td.path().join("a.txt")).unwrap();
fs::remove_file(td.path().join("b.txt")).unwrap();
fs::remove_dir_all(td.path().join("sub")).unwrap();
assert!(!td.path().join("a.txt").exists());
assert!(!td.path().join("sub/c.txt").exists());
let out = run_in(td.path(), &["checkout", "main"]);
assert!(!out.status.success(), "dirty checkout should fail: {out:?}");
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(
stderr.contains("restore would overwrite local changes"),
"stderr should explain dirty worktree refusal: {stderr}"
);
assert!(!td.path().join("a.txt").exists());
assert!(!td.path().join("sub/c.txt").exists());
}
#[test]
fn checkout_refuses_dirty_tracked_file() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("a.txt"), b"v1\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "main"])
.status
.success()
);
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
fs::write(td.path().join("a.txt"), b"feature\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "feature"])
.status
.success()
);
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
fs::write(td.path().join("a.txt"), b"local edit\n").unwrap();
let out = run_in(td.path(), &["checkout", "feature"]);
assert!(!out.status.success(), "dirty checkout 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");
}
#[test]
fn checkout_refuses_staged_change() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("a.txt"), b"v1\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "main"])
.status
.success()
);
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
fs::write(td.path().join("feature.txt"), b"feature\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "feature"])
.status
.success()
);
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
fs::write(td.path().join("a.txt"), b"staged\n").unwrap();
assert!(run_in(td.path(), &["add", "a.txt"]).status.success());
let out = run_in(td.path(), &["checkout", "feature"]);
assert!(
!out.status.success(),
"staged checkout should fail: {out:?}"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("restore would overwrite staged changes"));
}
#[test]
fn checkout_refuses_untracked_collision() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("base.txt"), b"base\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "main"])
.status
.success()
);
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
fs::write(td.path().join("collision.txt"), b"tracked on feature\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "feature"])
.status
.success()
);
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
fs::write(td.path().join("collision.txt"), b"local only\n").unwrap();
let out = run_in(td.path(), &["checkout", "feature"]);
assert!(
!out.status.success(),
"untracked collision should fail: {out:?}"
);
let stderr = String::from_utf8(out.stderr).unwrap();
assert!(stderr.contains("restore would overwrite untracked path"));
assert_eq!(
fs::read(td.path().join("collision.txt")).unwrap(),
b"local only\n"
);
}
#[test]
fn checkout_preserves_noncolliding_untracked_file() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("base.txt"), b"base\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "main"])
.status
.success()
);
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
fs::write(td.path().join("feature.txt"), b"feature\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "feature"])
.status
.success()
);
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
fs::write(td.path().join("notes.txt"), b"local notes\n").unwrap();
let out = run_in(td.path(), &["checkout", "feature"]);
assert!(
out.status.success(),
"checkout with a non-colliding untracked file should succeed: {out:?}"
);
assert_eq!(
fs::read(td.path().join("notes.txt")).unwrap(),
b"local notes\n",
"untracked file must survive the branch switch"
);
assert_eq!(
fs::read(td.path().join("feature.txt")).unwrap(),
b"feature\n"
);
}
#[test]
fn checkout_same_tree_succeeds_with_untracked_file() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("tracked.txt"), b"tracked\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "main"])
.status
.success()
);
fs::write(td.path().join("notes.txt"), b"local notes\n").unwrap();
let out = run_in(td.path(), &["checkout", "main"]);
assert!(
out.status.success(),
"same-tree checkout with an untracked file should succeed: {out:?}"
);
assert_eq!(
fs::read(td.path().join("notes.txt")).unwrap(),
b"local notes\n",
"untracked file must survive a same-tree checkout"
);
assert_eq!(
fs::read(td.path().join("tracked.txt")).unwrap(),
b"tracked\n"
);
}
#[test]
fn checkout_removes_dropped_tracked_files_but_keeps_untracked() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("base.txt"), b"base\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "main"])
.status
.success()
);
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
fs::write(td.path().join("feature.txt"), b"feature\n").unwrap();
fs::create_dir_all(td.path().join("sub")).unwrap();
fs::write(td.path().join("sub/nested.txt"), b"nested\n").unwrap();
fs::create_dir_all(td.path().join("gone")).unwrap();
fs::write(td.path().join("gone/only.txt"), b"only\n").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "feature"])
.status
.success()
);
fs::write(td.path().join("notes.txt"), b"local notes\n").unwrap();
fs::write(td.path().join("sub/keep-me.txt"), b"keep\n").unwrap();
let out = run_in(td.path(), &["checkout", "main"]);
assert!(out.status.success(), "checkout main failed: {out:?}");
assert!(!td.path().join("feature.txt").exists());
assert!(!td.path().join("sub/nested.txt").exists());
assert!(!td.path().join("gone/only.txt").exists());
assert!(!td.path().join("gone").exists(), "emptied dir not pruned");
assert_eq!(
fs::read(td.path().join("sub/keep-me.txt")).unwrap(),
b"keep\n",
"untracked file under a dropped dir must survive"
);
assert_eq!(
fs::read(td.path().join("notes.txt")).unwrap(),
b"local notes\n"
);
assert_eq!(fs::read(td.path().join("base.txt")).unwrap(), b"base\n");
}
#[test]
fn checkout_respects_mkitignore() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("tracked.txt"), b"v1").unwrap();
assert!(run_in(td.path(), &["add", "."]).status.success());
assert!(run_in(td.path(), &["commit", "-m", "v1"]).status.success());
fs::write(td.path().join(".mkitignore"), "local.txt\n").unwrap();
fs::write(td.path().join("local.txt"), b"untracked").unwrap();
let out = run_in(td.path(), &["checkout", "main"]);
assert!(out.status.success(), "checkout failed: {out:?}");
assert_eq!(
fs::read_to_string(td.path().join(".mkitignore")).unwrap(),
"local.txt\n"
);
assert_eq!(fs::read(td.path().join("local.txt")).unwrap(), b"untracked");
assert_eq!(fs::read(td.path().join("tracked.txt")).unwrap(), b"v1");
}
fn head_commit(cwd: &std::path::Path) -> String {
fs::read_to_string(cwd.join(".mkit/refs/heads/main"))
.unwrap()
.trim()
.to_string()
}
fn tree_of_commit(cwd: &std::path::Path, commit: &str) -> String {
let out = run_in(cwd, &["cat", commit]);
assert!(out.status.success(), "cat commit failed: {out:?}");
let body = String::from_utf8(out.stdout).unwrap();
body.lines()
.find_map(|line| line.strip_prefix("tree "))
.expect("commit has tree")
.trim()
.to_string()
}
#[test]
fn checkout_resets_index_to_checked_out_tree() {
let td = tempfile::tempdir().unwrap();
assert!(run_in(td.path(), &["init"]).status.success());
assert!(run_in(td.path(), &["keygen"]).status.success());
fs::write(td.path().join("main.txt"), b"main").unwrap();
assert!(run_in(td.path(), &["add", "main.txt"]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "main"])
.status
.success()
);
assert!(run_in(td.path(), &["branch", "feature"]).status.success());
assert!(run_in(td.path(), &["checkout", "feature"]).status.success());
fs::write(td.path().join("feature.txt"), b"feature").unwrap();
assert!(run_in(td.path(), &["add", "feature.txt"]).status.success());
assert!(
run_in(td.path(), &["commit", "-m", "feature"])
.status
.success()
);
assert!(run_in(td.path(), &["checkout", "main"]).status.success());
assert!(!td.path().join("feature.txt").exists());
let status = run_in(td.path(), &["status", "--porcelain"]);
assert!(status.status.success());
let stdout = String::from_utf8(status.stdout).unwrap();
assert!(
stdout.is_empty(),
"checkout should leave index aligned with main: {stdout:?}"
);
assert!(
run_in(td.path(), &["commit", "-m", "after checkout"])
.status
.success()
);
let commit = head_commit(td.path());
let tree = tree_of_commit(td.path(), &commit);
let cat = run_in(td.path(), &["cat", &tree]);
assert!(cat.status.success());
let body = String::from_utf8(cat.stdout).unwrap();
assert!(body.contains("main.txt"));
assert!(
!body.contains("feature.txt"),
"stale feature index leaked into main commit: {body}"
);
}