use crate::common;
use common::{OutputAssertions, TestRepo};
use std::fs;
use std::path::PathBuf;
fn default_worktree_root(repo: &TestRepo, home: &str) -> PathBuf {
let repo_name = repo
.path()
.file_name()
.expect("repo dir name")
.to_string_lossy()
.into_owned();
PathBuf::from(home)
.join(".stax")
.join("worktrees")
.join(repo_name)
}
fn linked_worktree_dirs(repo: &TestRepo, home: &str) -> Vec<PathBuf> {
let root = default_worktree_root(repo, home);
if !root.exists() {
return Vec::new();
}
fs::read_dir(&root)
.expect("read worktree root")
.map(|entry| entry.expect("dir entry").path())
.filter(|path| path.is_dir())
.collect()
}
fn manifest_path(repo: &TestRepo, home: &str) -> PathBuf {
default_worktree_root(repo, home).join(".stax-pool.json")
}
fn idle_slot_count(repo: &TestRepo, home: &str) -> usize {
let path = manifest_path(repo, home);
if !path.exists() {
return 0;
}
fs::read_to_string(&path)
.expect("read pool manifest")
.matches("\"idle\"")
.count()
}
fn write_config(home: &str, contents: &str) {
let config_dir = PathBuf::from(home).join(".config").join("stax");
fs::create_dir_all(&config_dir).expect("create config dir");
fs::write(config_dir.join("config.toml"), contents).expect("write config.toml");
}
fn repo_with_gitignore() -> (TestRepo, String) {
let repo = TestRepo::new();
let home = repo.clean_home();
repo.create_file(".gitignore", "node_modules/\n");
repo.commit("add gitignore");
(repo, home)
}
#[test]
fn adopt_reuses_parked_slot_and_gitignored_deps_survive() {
let (repo, home) = repo_with_gitignore();
repo.run_stax_with_env(
&["wt", "c", "lane-a", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let lane_a = default_worktree_root(&repo, &home).join("lane-a");
fs::create_dir_all(lane_a.join("node_modules")).unwrap();
fs::write(lane_a.join("node_modules").join("dep.txt"), "cached-dep").unwrap();
repo.run_stax_with_env(&["wt", "rm", "lane-a"], &[("HOME", home.as_str())])
.assert_success();
assert!(lane_a.exists(), "parked slot directory should be kept");
assert!(
manifest_path(&repo, &home).exists(),
"parking should write a pool manifest"
);
repo.run_stax_with_env(
&["wt", "c", "lane-b", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let dirs = linked_worktree_dirs(&repo, &home);
assert_eq!(
dirs.len(),
1,
"adopt should recycle the parked dir, not create a new one: {:?}",
dirs
);
assert_eq!(
dirs[0], lane_a,
"adopted worktree must reuse the parked path"
);
let seeded = lane_a.join("node_modules").join("dep.txt");
assert!(
seeded.exists(),
"gitignored deps must survive slot recycling"
);
assert_eq!(fs::read_to_string(&seeded).unwrap(), "cached-dep");
}
#[test]
fn park_preserves_gitignored_deps_but_discards_tracked_changes() {
let (repo, home) = repo_with_gitignore();
repo.create_file("tracked.txt", "original\n");
repo.commit("add tracked file");
repo.run_stax_with_env(
&["wt", "c", "lane-a", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let lane_a = default_worktree_root(&repo, &home).join("lane-a");
fs::create_dir_all(lane_a.join("node_modules")).unwrap();
fs::write(lane_a.join("node_modules").join("dep.txt"), "cached-dep").unwrap();
repo.run_stax_with_env(&["wt", "rm", "lane-a"], &[("HOME", home.as_str())])
.assert_success();
assert!(lane_a.exists(), "slot should be parked");
assert!(
lane_a.join("node_modules").join("dep.txt").exists(),
"gitignored dep must be preserved across park"
);
assert_eq!(
fs::read_to_string(lane_a.join("tracked.txt")).unwrap(),
"original\n"
);
}
#[test]
fn force_dirty_removal_never_parks() {
let (repo, home) = repo_with_gitignore();
repo.create_file("tracked.txt", "original\n");
repo.commit("add tracked file");
repo.run_stax_with_env(
&["wt", "c", "lane-a", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let lane_a = default_worktree_root(&repo, &home).join("lane-a");
fs::write(lane_a.join("tracked.txt"), "modified\n").unwrap();
repo.run_stax_with_env(
&["wt", "rm", "lane-a", "--force"],
&[("HOME", home.as_str())],
)
.assert_success();
assert!(
!lane_a.exists(),
"a --force dirty removal must delete the worktree, not park it"
);
assert!(
!manifest_path(&repo, &home).exists()
|| fs::read_to_string(manifest_path(&repo, &home))
.unwrap()
.contains("\"slots\": []"),
"forced removal should not leave a parked slot behind"
);
}
#[test]
fn reuse_slots_false_uses_cold_create_and_real_remove() {
let (repo, home) = repo_with_gitignore();
write_config(&home, "[worktree]\nreuse_slots = false\n");
repo.run_stax_with_env(
&["wt", "c", "lane-a", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let lane_a = default_worktree_root(&repo, &home).join("lane-a");
repo.run_stax_with_env(&["wt", "rm", "lane-a"], &[("HOME", home.as_str())])
.assert_success();
assert!(
!lane_a.exists(),
"reuse_slots = false must really remove the worktree"
);
assert!(
!manifest_path(&repo, &home).exists(),
"reuse_slots = false must not create a pool manifest"
);
}
#[test]
fn max_idle_slots_cap_forces_real_remove_beyond_cap() {
let (repo, home) = repo_with_gitignore();
write_config(&home, "[worktree]\nmax_idle_slots = 1\n");
repo.run_stax_with_env(
&["wt", "c", "lane-a", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let lane_a = default_worktree_root(&repo, &home).join("lane-a");
fs::create_dir_all(lane_a.join("node_modules")).unwrap();
fs::write(lane_a.join("node_modules").join("dep.txt"), "cached-dep").unwrap();
repo.run_stax_with_env(
&["wt", "c", "lane-b", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let lane_b = default_worktree_root(&repo, &home).join("lane-b");
fs::create_dir_all(lane_b.join("node_modules")).unwrap();
fs::write(lane_b.join("node_modules").join("dep.txt"), "cached-dep").unwrap();
repo.run_stax_with_env(&["wt", "rm", "lane-a"], &[("HOME", home.as_str())])
.assert_success();
assert!(lane_a.exists(), "first park should keep the slot on disk");
assert_eq!(
idle_slot_count(&repo, &home),
1,
"parking lane-a should leave exactly one idle slot at the cap"
);
repo.run_stax_with_env(&["wt", "rm", "lane-b"], &[("HOME", home.as_str())])
.assert_success();
assert!(
!lane_b.exists(),
"parking beyond max_idle_slots must fall back to a real remove"
);
assert_eq!(
idle_slot_count(&repo, &home),
1,
"idle slot count must stay capped at 1 (only lane-a parked)"
);
}
#[test]
fn adopt_skips_slot_leased_by_live_pid() {
let (repo, home) = repo_with_gitignore();
repo.run_stax_with_env(
&["wt", "c", "lane-a", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let lane_a = default_worktree_root(&repo, &home).join("lane-a");
repo.run_stax_with_env(&["wt", "rm", "lane-a"], &[("HOME", home.as_str())])
.assert_success();
let manifest = manifest_path(&repo, &home);
let leased = format!(
"{{\n \"slots\": [\n {{\n \"path\": {:?},\n \"state\": \"leased\",\n \"branch\": null,\n \"lease_owner_pid\": {},\n \"last_used\": 1\n }}\n ]\n}}\n",
lane_a.to_string_lossy(),
std::process::id()
);
fs::write(&manifest, leased).unwrap();
repo.run_stax_with_env(
&["wt", "c", "lane-b", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
let lane_b = default_worktree_root(&repo, &home).join("lane-b");
assert!(
lane_b.exists(),
"a slot leased by a live pid must not be adopted"
);
}
#[test]
fn reconcile_hook_runs_on_adopt_and_is_non_fatal() {
let (repo, home) = repo_with_gitignore();
let marker = default_worktree_root(&repo, &home).join("reconcile-marker.txt");
write_config(
&home,
&format!(
"[worktree]\nreconcile = \"touch {} && exit 3\"\n",
marker.to_string_lossy()
),
);
repo.run_stax_with_env(
&["wt", "c", "lane-a", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
repo.run_stax_with_env(&["wt", "rm", "lane-a"], &[("HOME", home.as_str())])
.assert_success();
repo.run_stax_with_env(&["wt", "c", "lane-b"], &[("HOME", home.as_str())])
.assert_success();
assert!(
marker.exists(),
"reconcile hook should have run on adopt and created its marker"
);
}
#[test]
fn manifest_consistent_across_create_remove_create_cycles() {
let (repo, home) = repo_with_gitignore();
for _ in 0..3 {
repo.run_stax_with_env(
&["wt", "c", "cycle", "--no-verify"],
&[("HOME", home.as_str())],
)
.assert_success();
repo.run_stax_with_env(&["wt", "rm", "cycle"], &[("HOME", home.as_str())])
.assert_success();
}
let dirs = linked_worktree_dirs(&repo, &home);
assert_eq!(
dirs.len(),
1,
"create/remove cycles should converge on a single recycled slot: {:?}",
dirs
);
}