use super::*;
fn init_deps_in_ignored_dir_project(dir: &std::path::Path) {
std::fs::write(dir.join(".heddleignore"), "node_modules/\n.venv/\n").unwrap();
std::fs::write(dir.join("index.ts"), "export const x = 1;\n").unwrap();
let node_modules = dir.join("node_modules");
std::fs::create_dir_all(node_modules.join("left-pad")).unwrap();
std::fs::write(
node_modules.join("left-pad").join("index.js"),
"module.exports = () => {};\n",
)
.unwrap();
let venv = dir.join(".venv");
std::fs::create_dir_all(venv.join("bin")).unwrap();
std::fs::write(venv.join("bin").join("python"), "#!/bin/sh\n").unwrap();
}
fn init_gitignore_only_overlay_project(dir: &std::path::Path) {
let git = |args: &[&str]| {
let status = Command::new("git")
.args(args)
.current_dir(dir)
.status()
.expect("git should run");
assert!(status.success(), "git {args:?} should succeed");
};
git(&["init"]);
git(&["config", "user.name", "Heddle Test"]);
git(&["config", "user.email", "heddle@example.com"]);
git(&["checkout", "-b", "main"]);
std::fs::write(dir.join(".gitignore"), "node_modules/\n.venv/\n").unwrap();
std::fs::write(dir.join("index.ts"), "export const x = 1;\n").unwrap();
let node_modules = dir.join("node_modules");
std::fs::create_dir_all(node_modules.join("left-pad")).unwrap();
std::fs::write(
node_modules.join("left-pad").join("index.js"),
"module.exports = () => {};\n",
)
.unwrap();
}
#[test]
fn hydrate_preserves_gitignore_only_ignores_in_isolated_checkout() {
let temp = TempDir::new().unwrap();
init_gitignore_only_overlay_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let checkout_root = TempDir::new().unwrap();
let thread_path = checkout_root.path().join("iso");
heddle(
&[
"start",
"iso",
"--path",
thread_path.to_str().unwrap(),
"--hydrate",
],
Some(temp.path()),
)
.expect("start --hydrate should succeed");
let linked = thread_path.join("node_modules");
assert!(
std::fs::symlink_metadata(&linked)
.map(|m| m.file_type().is_symlink())
.unwrap_or(false),
"node_modules should be hydrated as a symlink"
);
let status = heddle(&["status"], Some(&thread_path))
.expect("status should run from the hydrated checkout");
assert!(
!status.contains("node_modules"),
"hydrated node_modules must stay ignored in a .gitignore-only overlay; got:\n{status}"
);
heddle(&["capture", "-m", "iso work"], Some(&thread_path))
.expect("capture in the hydrated checkout must succeed");
}
#[test]
fn hydrate_does_not_dirty_a_tracked_heddleignore() {
let temp = TempDir::new().unwrap();
let git = |args: &[&str]| {
let status = Command::new("git")
.args(args)
.current_dir(temp.path())
.status()
.expect("git should run");
assert!(status.success(), "git {args:?} should succeed");
};
git(&["init"]);
git(&["config", "user.name", "Heddle Test"]);
git(&["config", "user.email", "heddle@example.com"]);
git(&["checkout", "-b", "main"]);
std::fs::write(temp.path().join(".gitignore"), "node_modules/\n").unwrap();
std::fs::write(temp.path().join(".heddleignore"), "*.log\n").unwrap();
std::fs::write(temp.path().join("index.ts"), "export const x = 1;\n").unwrap();
let node_modules = temp.path().join("node_modules");
std::fs::create_dir_all(node_modules.join("left-pad")).unwrap();
std::fs::write(
node_modules.join("left-pad").join("index.js"),
"module.exports = () => {};\n",
)
.unwrap();
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let checkout_root = TempDir::new().unwrap();
let thread_path = checkout_root.path().join("iso");
heddle(
&[
"start",
"iso",
"--path",
thread_path.to_str().unwrap(),
"--hydrate",
],
Some(temp.path()),
)
.expect("start --hydrate should succeed");
assert!(
std::fs::symlink_metadata(thread_path.join("node_modules"))
.map(|m| m.file_type().is_symlink())
.unwrap_or(false),
"node_modules should be hydrated as a symlink"
);
let checkout_ignore = std::fs::read_to_string(thread_path.join(".heddleignore")).unwrap();
assert_eq!(
checkout_ignore, "*.log\n",
"hydrate must not modify the tracked .heddleignore"
);
let exclude = std::fs::read_to_string(thread_path.join(".heddle").join("info").join("exclude"))
.expect("hydrate should write the worktree-local exclude");
assert!(
exclude.contains("node_modules"),
"the dep-ignore rule must live in the worktree-local exclude; got:\n{exclude}"
);
let status = heddle(&["status"], Some(&thread_path))
.expect("status should run from the hydrated checkout");
assert!(
!status.contains("node_modules"),
"hydrated node_modules must stay ignored in the checkout; got:\n{status}"
);
heddle(&["capture", "-m", "iso work"], Some(&thread_path))
.expect("capture in the hydrated checkout must succeed");
}
#[test]
fn hydrate_symlinks_ignored_dep_dirs_into_checkout() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("iso");
heddle(
&[
"start",
"iso",
"--path",
thread_path.to_str().unwrap(),
"--hydrate",
],
Some(temp.path()),
)
.expect("start --hydrate should succeed");
let linked = thread_path.join("node_modules");
let meta = std::fs::symlink_metadata(&linked)
.unwrap_or_else(|e| panic!("expected node_modules symlink at {}: {e}", linked.display()));
assert!(
meta.file_type().is_symlink(),
"node_modules must be a symlink, not a real dir"
);
let target = std::fs::read_link(&linked).unwrap();
assert!(
target.is_absolute(),
"hydrate link target should be absolute, got {}",
target.display()
);
let dep_file = linked.join("left-pad").join("index.js");
assert!(
dep_file.is_file(),
"dependency file must be reachable through the link: {}",
dep_file.display()
);
assert!(
std::fs::symlink_metadata(thread_path.join(".venv"))
.map(|m| m.file_type().is_symlink())
.unwrap_or(false),
".venv should also be hydrated"
);
let src = thread_path.join("index.ts");
assert!(src.is_file());
assert!(
!std::fs::symlink_metadata(&src)
.unwrap()
.file_type()
.is_symlink(),
"tracked source must be a real captured file, not a symlink"
);
}
#[test]
fn hydrate_symlink_failure_leaves_no_partial_thread() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("iso");
let output = heddle_output_with_env(
&[
"start",
"iso",
"--path",
thread_path.to_str().unwrap(),
"--hydrate",
],
Some(temp.path()),
&[("HEDDLE_FAULT_INJECT", "hydrate_symlink_dir")],
)
.expect("the heddle binary should run");
assert!(
!output.status.success(),
"start --hydrate must fail when directory symlinks are rejected"
);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
stderr.contains("directory symlink"),
"error must name the platform/FS limitation (directory symlinks); got:\n{stderr}"
);
assert!(
std::fs::symlink_metadata(&thread_path).is_err(),
"a hydrate failure must not leave the partially-materialized checkout at {}",
thread_path.display()
);
let list = heddle(&["thread", "list"], Some(temp.path()))
.expect("thread list should run after the rolled-back start");
assert!(
!list.contains("iso"),
"a hydrate failure must not leave a dangling thread ref; got:\n{list}"
);
}
#[test]
fn hydrate_symlink_failure_removes_self_created_target_dir() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("iso-new");
assert!(
std::fs::symlink_metadata(&thread_path).is_err(),
"precondition: target dir must not exist before start"
);
let output = heddle_output_with_env(
&[
"start",
"iso-new",
"--path",
thread_path.to_str().unwrap(),
"--hydrate",
],
Some(temp.path()),
&[("HEDDLE_FAULT_INJECT", "hydrate_symlink_dir")],
)
.expect("the heddle binary should run");
assert!(
!output.status.success(),
"start --hydrate must fail when directory symlinks are rejected"
);
assert!(
std::fs::symlink_metadata(&thread_path).is_err(),
"a self-created target dir must be removed entirely on rollback: {}",
thread_path.display()
);
}
#[test]
fn hydrate_symlink_failure_preserves_preexisting_empty_target_dir() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("iso-existing");
std::fs::create_dir(&thread_path).unwrap();
let output = heddle_output_with_env(
&[
"start",
"iso-existing",
"--path",
thread_path.to_str().unwrap(),
"--hydrate",
],
Some(temp.path()),
&[("HEDDLE_FAULT_INJECT", "hydrate_symlink_dir")],
)
.expect("the heddle binary should run");
assert!(
!output.status.success(),
"start --hydrate must fail when directory symlinks are rejected"
);
let meta = std::fs::symlink_metadata(&thread_path).unwrap_or_else(|e| {
panic!(
"a pre-existing user dir must NOT be deleted on rollback ({}): {e}",
thread_path.display()
)
});
assert!(
meta.is_dir(),
"the pre-existing target must remain a directory after rollback"
);
let remaining: Vec<_> = std::fs::read_dir(&thread_path)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert!(
remaining.is_empty(),
"rollback must clear materialized contents from a pre-existing dir, leaving it empty; \
found: {remaining:?}"
);
let list = heddle(&["thread", "list"], Some(temp.path()))
.expect("thread list should run after the rolled-back start");
assert!(
!list.contains("iso-existing"),
"a hydrate failure must not leave a dangling thread ref; got:\n{list}"
);
}
#[test]
fn hydrate_does_not_link_admin_dirs() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("iso");
heddle(
&[
"start",
"iso",
"--path",
thread_path.to_str().unwrap(),
"--hydrate",
],
Some(temp.path()),
)
.unwrap();
let heddle_link = thread_path.join(".heddle");
if let Ok(meta) = std::fs::symlink_metadata(&heddle_link) {
assert!(
!meta.file_type().is_symlink(),
".heddle must never be hydrated as a symlink"
);
}
}
#[test]
fn no_hydrate_flag_means_no_links() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("plain");
heddle(
&["start", "plain", "--path", thread_path.to_str().unwrap()],
Some(temp.path()),
)
.unwrap();
assert!(
std::fs::symlink_metadata(thread_path.join("node_modules")).is_err(),
"without --hydrate, node_modules must not be linked into the checkout"
);
}
#[test]
fn hydrated_deps_stay_ignored_in_checkout() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("iso");
heddle(
&[
"start",
"iso",
"--path",
thread_path.to_str().unwrap(),
"--hydrate",
],
Some(temp.path()),
)
.unwrap();
let status = heddle(&["status"], Some(&thread_path))
.expect("status should run from the hydrated checkout");
assert!(
!status.contains("node_modules"),
"hydrated node_modules must stay ignored (not reported by status); got:\n{status}"
);
}
#[test]
fn start_partial_materialize_rolls_back_self_created_dir() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("iso");
let output = heddle_output_with_env(
&["start", "iso", "--path", thread_path.to_str().unwrap()],
Some(temp.path()),
&[("HEDDLE_FAULT_INJECT", "start_materialize_checkout")],
)
.expect("the heddle binary should run");
assert!(
!output.status.success(),
"a mid-materialize fault must fail the start"
);
assert!(
std::fs::symlink_metadata(&thread_path).is_err(),
"a partial materialize must remove the self-created checkout dir: {}",
thread_path.display()
);
let list = heddle(&["thread", "list"], Some(temp.path()))
.expect("thread list should run after the rolled-back start");
assert!(
!list.contains("iso"),
"a materialize fault must not leave a dangling thread ref; got:\n{list}"
);
}
#[test]
fn start_partial_materialize_preserves_preexisting_empty_dir() {
let temp = TempDir::new().unwrap();
init_deps_in_ignored_dir_project(temp.path());
heddle(&["init"], Some(temp.path())).unwrap();
heddle(&["capture", "-m", "main"], Some(temp.path())).unwrap();
let thread_path = temp.path().join("iso-existing");
std::fs::create_dir(&thread_path).unwrap();
let output = heddle_output_with_env(
&[
"start",
"iso-existing",
"--path",
thread_path.to_str().unwrap(),
],
Some(temp.path()),
&[("HEDDLE_FAULT_INJECT", "start_materialize_checkout")],
)
.expect("the heddle binary should run");
assert!(
!output.status.success(),
"a mid-materialize fault must fail the start"
);
let meta = std::fs::symlink_metadata(&thread_path).unwrap_or_else(|e| {
panic!(
"a pre-existing user dir must NOT be deleted on rollback ({}): {e}",
thread_path.display()
)
});
assert!(
meta.is_dir(),
"the pre-existing target must remain a directory"
);
let remaining: Vec<_> = std::fs::read_dir(&thread_path)
.unwrap()
.map(|e| e.unwrap().file_name().to_string_lossy().into_owned())
.collect();
assert!(
remaining.is_empty(),
"rollback must clear materialized contents from a pre-existing dir; found: {remaining:?}"
);
let list = heddle(&["thread", "list"], Some(temp.path()))
.expect("thread list should run after the rolled-back start");
assert!(
!list.contains("iso-existing"),
"a materialize fault must not leave a dangling thread ref; got:\n{list}"
);
}