mod common;
use std::path::{Path, PathBuf};
use std::process::Output;
use common::{Repo, mkit};
fn wt_add(repo: &Repo, extra: &[&str], name: &str) -> (tempfile::TempDir, PathBuf, Output) {
let host = tempfile::tempdir().expect("worktree host dir");
let tree = host.path().join(name);
let target_arg = tree.to_string_lossy().into_owned();
let mut argv = vec!["worktree", "add", &target_arg];
argv.extend_from_slice(extra);
let out = repo.run(&argv);
(host, tree, out)
}
fn object_count(mkit_dir: &Path) -> usize {
fn walk(d: &Path, n: &mut usize) {
for e in std::fs::read_dir(d).expect("read dir") {
let p = e.expect("entry").path();
if p.is_dir() {
walk(&p, n);
} else {
*n += 1;
}
}
}
let mut n = 0;
walk(&mkit_dir.join("objects"), &mut n);
n
}
#[test]
fn add_creates_branch_named_after_path_and_shares_store() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "seed");
let (_host, tree, out) = wt_add(&repo, &[], "topic");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(
stdout.contains("Preparing worktree (new branch 'topic')"),
"unexpected output: {stdout}"
);
assert_eq!(std::fs::read(tree.join("a.txt")).unwrap(), b"one\n");
assert!(tree.join(".mkit").is_file(), "pointer file expected");
let branches = repo.ok(&["branch"]);
assert!(String::from_utf8_lossy(&branches.stdout).contains("topic"));
let before = object_count(&repo.mkit_dir());
std::fs::write(tree.join("b.txt"), b"two\n").unwrap();
let out = mkit(&tree, repo.xdg(), &["add", "b.txt"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let out = mkit(&tree, repo.xdg(), &["commit", "-m", "in topic"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(object_count(&repo.mkit_dir()) > before);
assert!(!tree.join(".mkit").is_dir());
common::check_invariants(repo.path(), "after linked-tree commit").unwrap();
}
#[test]
fn add_existing_branch_and_detached_forms() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "seed");
repo.ok(&["branch", "feature"]);
let (_h1, t1, out) = wt_add(&repo, &["feature"], "wt-feature");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stdout).contains("checking out 'feature'"),);
let head = std::fs::read_to_string(repo.mkit_dir().join("worktrees/wt-feature/HEAD")).unwrap();
assert_eq!(head, "ref: refs/heads/feature\n");
let _ = t1;
let (_h2, _t2, out) = wt_add(&repo, &["HEAD"], "wt-detached");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stdout).contains("detached HEAD"),);
}
#[test]
fn branch_checked_out_elsewhere_is_refused_everywhere() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "seed");
repo.ok(&["branch", "feature"]);
let (_host, tree, out) = wt_add(&repo, &["feature"], "wt1");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let (_h2, _t2, out) = wt_add(&repo, &["feature"], "wt2");
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("already checked out at"),);
let out = repo.run(&["checkout", "feature"]);
assert!(!out.status.success());
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.contains("already checked out at") && stderr.contains("wt1"),
"diagnostic must name the holding tree: {stderr}"
);
let out = repo.run(&["switch", "feature"]);
assert!(!out.status.success());
let out = repo.run(&["branch", "-D", "feature"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("checked out at"));
let out = repo.run(&["branch", "-m", "feature", "renamed"]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("checked out at"));
std::fs::write(tree.join("a.txt"), b"changed\n").unwrap();
let out = mkit(&tree, repo.xdg(), &["add", "a.txt"]);
assert!(out.status.success());
let out = mkit(&tree, repo.xdg(), &["commit", "-m", "advance"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn add_refusals_nested_nonempty_existing_branch_no_commits() {
let repo = Repo::new();
let (_h0, _t0, out) = wt_add(&repo, &[], "early");
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("no commits yet"));
repo.commit_file("a.txt", b"one\n", "seed");
let inside = repo.path().join("nested-wt").to_string_lossy().into_owned();
let out = repo.run(&["worktree", "add", &inside]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("inside the worktree"));
let host = tempfile::tempdir().unwrap();
let busy = host.path().join("busy");
std::fs::create_dir_all(&busy).unwrap();
std::fs::write(busy.join("x"), b"x").unwrap();
let busy_str = busy.to_string_lossy().into_owned();
let out = repo.run(&["worktree", "add", &busy_str]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("not empty"));
repo.ok(&["branch", "taken"]);
let taken = host.path().join("taken").to_string_lossy().into_owned();
let out = repo.run(&["worktree", "add", &taken]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("already exists"));
}
#[test]
fn list_shows_main_and_linked_trees() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "seed");
let (_host, tree, out) = wt_add(&repo, &[], "topic");
assert!(out.status.success());
let out = repo.ok(&["worktree", "list"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("[main]"), "main row missing: {stdout}");
assert!(stdout.contains("[topic]"), "linked row missing: {stdout}");
let out = repo.ok(&["worktree", "list", "--porcelain"]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("branch refs/heads/main"));
assert!(stdout.contains("branch refs/heads/topic"));
assert!(
stdout.contains(&format!(
"worktree {}",
tree.canonicalize().unwrap().display()
)) || stdout.contains(&format!("worktree {}", tree.display())),
"porcelain must name the tree path: {stdout}"
);
let out = mkit(&tree, repo.xdg(), &["worktree", "list"]);
assert!(out.status.success());
assert!(String::from_utf8_lossy(&out.stdout).contains("[main]"));
}
#[test]
fn remove_protects_local_work_and_force_overrides() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "seed");
let (_host, tree, out) = wt_add(&repo, &[], "topic");
assert!(out.status.success());
let tree_str = tree.to_string_lossy().into_owned();
std::fs::write(tree.join("a.txt"), b"dirty\n").unwrap();
let out = repo.run(&["worktree", "remove", &tree_str]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("local changes"));
std::fs::write(tree.join("a.txt"), b"one\n").unwrap();
std::fs::write(tree.join("scratch.txt"), b"keep me\n").unwrap();
let out = repo.run(&["worktree", "remove", &tree_str]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("untracked file"));
let out = repo.ok(&["worktree", "remove", "--force", &tree_str]);
assert!(out.status.success());
assert!(!tree.exists());
assert!(!repo.mkit_dir().join("worktrees/topic").exists());
let out = repo.ok(&["branch"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("topic"));
let main = repo.path().to_string_lossy().into_owned();
let out = repo.run(&["worktree", "remove", &main]);
assert!(!out.status.success());
assert!(String::from_utf8_lossy(&out.stderr).contains("main working tree"));
common::check_invariants(repo.path(), "after worktree remove").unwrap();
}
#[test]
fn prune_reaps_only_dead_entries_and_honors_dry_run() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "seed");
let (_h1, live, out) = wt_add(&repo, &[], "alive");
assert!(out.status.success());
let (_h2, dead, out) = wt_add(&repo, &[], "dead");
assert!(out.status.success());
std::fs::remove_dir_all(&dead).unwrap();
let out = repo.ok(&["worktree", "list"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("prunable"));
let out = repo.ok(&["worktree", "prune", "--dry-run"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("would prune worktrees/dead"));
assert!(repo.mkit_dir().join("worktrees/dead").exists());
let out = repo.ok(&["worktree", "prune"]);
assert!(String::from_utf8_lossy(&out.stdout).contains("pruned worktrees/dead"));
assert!(!repo.mkit_dir().join("worktrees/dead").exists());
assert!(repo.mkit_dir().join("worktrees/alive").exists());
assert!(live.join("a.txt").exists());
}
#[test]
fn worktree_ids_uniquify_on_basename_collision() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "seed");
let (_h1, _t1, out) = wt_add(&repo, &["HEAD"], "same");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
let (_h2, _t2, out) = wt_add(&repo, &["HEAD"], "same");
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
assert!(repo.mkit_dir().join("worktrees/same").is_dir());
assert!(repo.mkit_dir().join("worktrees/same-1").is_dir());
}
#[test]
fn gc_keeps_sibling_staged_objects_and_prunes_real_garbage() {
let repo = Repo::new();
repo.commit_file("a.txt", b"one\n", "seed");
let (_host, tree, out) = wt_add(&repo, &[], "topic");
assert!(out.status.success());
std::fs::write(tree.join("staged-only.txt"), b"precious\n").unwrap();
let out = mkit(&tree, repo.xdg(), &["add", "staged-only.txt"]);
assert!(out.status.success());
repo.write("garbage.txt", b"transient\n");
repo.ok(&["add", "garbage.txt"]);
repo.ok(&["rm", "--cached", "garbage.txt"]);
std::fs::remove_file(repo.path().join("garbage.txt")).unwrap();
let before = object_count(&repo.mkit_dir());
repo.ok(&["gc", "--grace-secs", "0"]);
assert!(
object_count(&repo.mkit_dir()) < before,
"unreachable object must be pruned"
);
let out = mkit(&tree, repo.xdg(), &["status", "--porcelain"]);
assert!(
String::from_utf8_lossy(&out.stdout).contains("staged-only.txt"),
"sibling staged entry must survive"
);
let out = mkit(&tree, repo.xdg(), &["commit", "-m", "staged survived"]);
assert!(
out.status.success(),
"{}",
String::from_utf8_lossy(&out.stderr)
);
common::check_invariants(repo.path(), "after cross-tree gc").unwrap();
}