#![allow(clippy::unwrap_used)]
use std::fs;
use std::path::Path;
use std::process::Output;
fn mkit_bin() -> &'static str {
env!("CARGO_BIN_EXE_mkit")
}
fn run_in(cwd: &Path, xdg: &Path, args: &[&str]) -> Output {
std::process::Command::new(mkit_bin())
.args(args)
.current_dir(cwd)
.env("XDG_CONFIG_HOME", xdg)
.output()
.expect("spawn mkit")
}
fn out_str(o: &Output) -> String {
String::from_utf8_lossy(&o.stdout).trim().to_string()
}
fn repo() -> (tempfile::TempDir, tempfile::TempDir) {
let td = tempfile::tempdir().unwrap();
let xdg = tempfile::tempdir().unwrap();
let (root, x) = (td.path(), xdg.path());
assert!(run_in(root, x, &["init"]).status.success());
assert!(run_in(root, x, &["keygen"]).status.success());
(td, xdg)
}
#[test]
fn anchored_pattern_ignores_root_only_through_add() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".mkitignore"), "/secret.txt\n").unwrap();
fs::write(root.join("secret.txt"), b"top\n").unwrap();
fs::create_dir(root.join("sub")).unwrap();
fs::write(root.join("sub/secret.txt"), b"nested\n").unwrap();
assert!(run_in(root, x, &["add", "."]).status.success());
let tracked = out_str(&run_in(root, x, &["ls-files"]));
assert!(
!tracked.contains("\nsecret.txt") && !tracked.starts_with("secret.txt"),
"anchored /secret.txt must not be staged at root: {tracked:?}"
);
assert!(
tracked.contains("sub/secret.txt"),
"the nested secret.txt is not anchored and must be staged: {tracked:?}"
);
}
#[test]
fn double_star_dir_pattern_ignores_at_any_depth() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".mkitignore"), "**/build/\n").unwrap();
fs::create_dir_all(root.join("a/build")).unwrap();
fs::write(root.join("a/build/out.o"), b"x\n").unwrap();
fs::write(root.join("a/keep.txt"), b"k\n").unwrap();
assert!(run_in(root, x, &["add", "."]).status.success());
let tracked = out_str(&run_in(root, x, &["ls-files"]));
assert!(
tracked.contains("a/keep.txt"),
"kept file staged: {tracked:?}"
);
assert!(
!tracked.contains("build"),
"nothing under a/build should be staged: {tracked:?}"
);
}
#[test]
fn gitignore_is_read_too() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".gitignore"), "*.log\n").unwrap();
fs::write(root.join("debug.log"), b"x\n").unwrap();
fs::write(root.join("app.txt"), b"y\n").unwrap();
assert!(run_in(root, x, &["add", "."]).status.success());
let tracked = out_str(&run_in(root, x, &["ls-files"]));
assert!(tracked.contains("app.txt"), "app.txt staged: {tracked:?}");
assert!(
!tracked.contains("debug.log"),
".gitignore *.log must be honored: {tracked:?}"
);
}
#[test]
fn mkitignore_reinclude_overrides_gitignore() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".gitignore"), "*.log\n").unwrap();
fs::write(root.join(".mkitignore"), "!keep.log\n").unwrap();
fs::write(root.join("keep.log"), b"k\n").unwrap();
fs::write(root.join("drop.log"), b"d\n").unwrap();
assert!(run_in(root, x, &["add", "."]).status.success());
let tracked = out_str(&run_in(root, x, &["ls-files"]));
assert!(
tracked.contains("keep.log"),
"re-included keep.log must be staged: {tracked:?}"
);
assert!(
!tracked.contains("drop.log"),
"drop.log stays ignored: {tracked:?}"
);
}
#[test]
fn ls_files_others_excludes_files_under_ignored_dir() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".mkitignore"), "node_modules/\n").unwrap();
fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
fs::write(root.join("node_modules/pkg/index.js"), b"x\n").unwrap();
fs::write(root.join("main.rs"), b"y\n").unwrap();
let all = out_str(&run_in(root, x, &["ls-files", "--others"]));
assert!(
all.contains("node_modules/pkg/index.js"),
"all others: {all:?}"
);
let excl = out_str(&run_in(
root,
x,
&["ls-files", "--others", "--exclude-standard"],
));
assert!(excl.contains("main.rs"), "main.rs kept: {excl:?}");
assert!(
!excl.contains("node_modules"),
"ignored subtree dropped: {excl:?}"
);
let ign = out_str(&run_in(root, x, &["ls-files", "--others", "--ignored"]));
assert!(
ign.contains("node_modules/pkg/index.js") && !ign.contains("main.rs"),
"--ignored lists only the ignored subtree: {ign:?}"
);
}
#[test]
fn clean_fd_keeps_files_under_ignored_dir_without_x() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".mkitignore"), "node_modules/\n").unwrap();
fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
fs::write(root.join("node_modules/pkg/index.js"), b"x\n").unwrap();
fs::create_dir(root.join("junk")).unwrap();
fs::write(root.join("junk/tmp.txt"), b"t\n").unwrap();
assert!(run_in(root, x, &["clean", "-fd"]).status.success());
assert!(
root.join("node_modules/pkg/index.js").exists(),
"files under an ignored dir must survive clean -fd"
);
assert!(
!root.join("junk").exists(),
"an unrelated untracked dir is removed by clean -fd"
);
}
#[test]
fn clean_fdx_removes_ignored_dir() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".mkitignore"), "node_modules/\n").unwrap();
fs::create_dir_all(root.join("node_modules/pkg")).unwrap();
fs::write(root.join("node_modules/pkg/index.js"), b"x\n").unwrap();
fs::write(root.join("keep.txt"), b"k\n").unwrap();
assert!(run_in(root, x, &["clean", "-fdX"]).status.success());
assert!(
!root.join("node_modules").exists(),
"clean -fdX removes the ignored directory and its contents"
);
assert!(
root.join("keep.txt").exists(),
"clean -X keeps non-ignored untracked files"
);
}
#[test]
fn tracked_file_matching_gitignore_stays_visible_and_restages() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join("debug.log"), b"v1\n").unwrap();
assert!(run_in(root, x, &["add", "debug.log"]).status.success());
assert!(
run_in(root, x, &["commit", "-m", "track log"])
.status
.success()
);
fs::write(root.join(".gitignore"), "*.log\n").unwrap();
fs::write(root.join("debug.log"), b"v2\n").unwrap();
let status = out_str(&run_in(root, x, &["status", "--porcelain"]));
assert!(
!status
.lines()
.any(|l| l.contains("debug.log") && l.trim_start().starts_with('D')),
"tracked file matching .gitignore must not show as deleted: {status:?}"
);
assert!(
out_str(&run_in(root, x, &["ls-files"])).contains("debug.log"),
"the tracked file stays tracked despite the ignore rule"
);
assert!(run_in(root, x, &["add", "."]).status.success());
let staged = out_str(&run_in(root, x, &["status", "--porcelain"]));
assert!(
staged
.lines()
.any(|l| l.contains("debug.log") && l.starts_with('M')),
"add . must restage the tracked-but-ignored modification: {staged:?}"
);
}
#[test]
fn tracked_ignored_file_not_deleted_in_status_without_index_file() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join("debug.log"), b"v1\n").unwrap();
assert!(run_in(root, x, &["add", "debug.log"]).status.success());
assert!(
run_in(root, x, &["commit", "-m", "track log"])
.status
.success()
);
fs::write(root.join(".gitignore"), "*.log\n").unwrap();
let _ = fs::remove_file(root.join(".mkit").join("index"));
let status = out_str(&run_in(root, x, &["status", "--porcelain"]));
assert!(
!status
.lines()
.any(|l| l.contains("debug.log") && l.trim_start().starts_with('D')),
"tracked file matching .gitignore must not show as deleted without an index file: {status:?}"
);
}
#[test]
fn clean_fd_keeps_untracked_sibling_in_ignored_dir_with_tracked_content() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".gitignore"), "node_modules/\n").unwrap();
fs::create_dir(root.join("node_modules")).unwrap();
fs::write(root.join("node_modules/tracked.js"), b"t\n").unwrap();
assert!(
run_in(root, x, &["add", "-f", "node_modules/tracked.js"])
.status
.success()
);
assert!(
run_in(root, x, &["commit", "-m", "track one"])
.status
.success()
);
fs::write(root.join("node_modules/local.tmp"), b"l\n").unwrap();
assert!(run_in(root, x, &["clean", "-fd"]).status.success());
assert!(
root.join("node_modules/local.tmp").exists(),
"untracked sibling under an ignored dir must survive clean -fd"
);
assert!(
root.join("node_modules/tracked.js").exists(),
"tracked file is never cleaned"
);
}
#[test]
fn add_explicit_ignored_path_refused_without_force() {
let (td, xdg) = repo();
let (root, x) = (td.path(), xdg.path());
fs::write(root.join(".mkitignore"), "*.secret\n").unwrap();
fs::write(root.join("app.secret"), b"s\n").unwrap();
fs::write(root.join("app.txt"), b"t\n").unwrap();
let refused = run_in(root, x, &["add", "app.secret"]);
assert!(
!refused.status.success(),
"ignored add must error: {refused:?}"
);
assert!(
!out_str(&run_in(root, x, &["ls-files"])).contains("app.secret"),
"the ignored path must not have been staged"
);
assert!(run_in(root, x, &["add", "app.txt"]).status.success());
assert!(
run_in(root, x, &["add", "-f", "app.secret"])
.status
.success()
);
assert!(
out_str(&run_in(root, x, &["ls-files"])).contains("app.secret"),
"-f must stage the ignored path"
);
}