#![allow(clippy::unwrap_used)]
use std::fs;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::time::{Duration, SystemTime};
use pristine::{Deleter, Freeing, Plan, Planner, Refusal, Removed, Step, Target};
use tempfile::TempDir;
fn write(path: &Path, bytes: usize) {
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(path, vec![b'x'; bytes]).unwrap();
}
fn touch(path: &Path) {
write(path, 0);
}
fn mkdir(path: &Path) {
fs::create_dir_all(path).unwrap();
}
fn walk_files(dir: &Path) -> Vec<PathBuf> {
let mut found = Vec::new();
let mut stack = vec![dir.to_path_buf()];
while let Some(current) = stack.pop() {
for entry in fs::read_dir(¤t).unwrap() {
let path = entry.unwrap().path();
if path.symlink_metadata().unwrap().is_dir() {
stack.push(path);
} else {
found.push(path);
}
}
}
found.sort();
found
}
fn fixture() -> (TempDir, PathBuf) {
let tmp = TempDir::new().unwrap();
let base = fs::canonicalize(tmp.path()).unwrap();
(tmp, base)
}
fn plan_for(root: &Path, targets: &[PathBuf]) -> Plan {
Planner::new(root).plan(targets.iter().map(Target::at))
}
fn refusals(plan: &Plan) -> Vec<(PathBuf, Refusal)> {
let mut kept: Vec<_> = plan
.kept()
.iter()
.map(|refused| (refused.path.clone(), refused.reason.clone()))
.collect();
kept.sort_by(|a, b| a.0.cmp(&b.0));
kept
}
fn targets(plan: &Plan) -> Vec<PathBuf> {
let mut paths: Vec<_> = plan
.targets()
.iter()
.map(|target| target.path.clone())
.collect();
paths.sort();
paths
}
#[cfg(unix)]
fn seal(dir: &Path) -> bool {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o000)).unwrap();
fs::read_dir(dir).is_err()
}
#[cfg(unix)]
fn unseal(dir: &Path) {
use std::os::unix::fs::PermissionsExt;
fs::set_permissions(dir, fs::Permissions::from_mode(0o755)).unwrap();
}
#[test]
fn a_target_outside_the_scan_root_is_refused() {
let (_tmp, base) = fixture();
let root = base.join("root");
let inside = root.join("app/node_modules");
let outside = base.join("elsewhere/node_modules");
touch(&inside.join("left-pad/index.js"));
touch(&outside.join("index.js"));
let plan = plan_for(&root, &[inside.clone(), outside.clone()]);
assert_eq!(targets(&plan), [inside]);
assert_eq!(refusals(&plan), [(outside, Refusal::OutsideRoot)]);
}
#[test]
fn a_target_that_climbs_out_of_the_root_with_dot_dot_is_refused() {
let (_tmp, base) = fixture();
let root = base.join("root");
mkdir(&root);
touch(&base.join("elsewhere/keep.txt"));
let escaped = root.join("../elsewhere");
let plan = plan_for(&root, std::slice::from_ref(&escaped));
assert!(targets(&plan).is_empty());
assert_eq!(refusals(&plan), [(escaped, Refusal::OutsideRoot)]);
assert!(Deleter::new().remove(&plan).removed.is_empty());
assert!(base.join("elsewhere/keep.txt").exists());
}
#[cfg(unix)]
#[test]
fn a_target_reached_through_a_symlinked_parent_is_judged_where_it_really_lives() {
let (_tmp, base) = fixture();
let root = base.join("root");
mkdir(&root);
touch(&base.join("elsewhere/target/keep.txt"));
std::os::unix::fs::symlink(base.join("elsewhere"), root.join("outside")).unwrap();
let escaped = root.join("outside/target");
let plan = plan_for(&root, std::slice::from_ref(&escaped));
assert!(targets(&plan).is_empty(), "{:?}", targets(&plan));
assert_eq!(refusals(&plan), [(escaped, Refusal::OutsideRoot)]);
assert!(Deleter::new().remove(&plan).removed.is_empty());
assert!(base.join("elsewhere/target/keep.txt").exists());
}
#[test]
fn the_scan_root_itself_is_never_a_target() {
let (_tmp, base) = fixture();
let root = base.join("root");
touch(&root.join("keep.txt"));
let plan = plan_for(&root, std::slice::from_ref(&root));
assert!(targets(&plan).is_empty());
assert_eq!(refusals(&plan), [(root.clone(), Refusal::OutsideRoot)]);
assert!(Deleter::new().remove(&plan).removed.is_empty());
assert!(root.join("keep.txt").exists());
}
#[test]
fn a_target_inside_another_target_is_dropped_rather_than_failing_later() {
let (_tmp, base) = fixture();
let outer = base.join("app/node_modules");
let inner = outer.join("dep/target");
touch(&inner.join("build.o"));
let plan = plan_for(&base, &[outer.clone(), inner.clone()]);
assert_eq!(
refusals(&plan),
[(inner, Refusal::AlreadyCovered(outer.clone()))]
);
assert_eq!(targets(&plan), [outer]);
}
#[test]
fn a_target_that_is_no_longer_there_is_reported_rather_than_silently_dropped() {
let (_tmp, base) = fixture();
let gone = base.join("app/node_modules");
let plan = plan_for(&base, std::slice::from_ref(&gone));
assert!(targets(&plan).is_empty());
assert!(
matches!(
refusals(&plan).as_slice(),
[(path, Refusal::Unreadable(_))] if path == &gone
),
"{:?}",
refusals(&plan)
);
}
#[cfg(unix)]
#[test]
fn an_ancestor_swapped_for_a_link_after_planning_cannot_take_the_removal_out_of_the_root() {
let (_tmp, base) = fixture();
let root = base.join("root");
let target = root.join("app/node_modules");
touch(&target.join("left-pad/index.js"));
let outside = base.join("precious");
touch(&outside.join("node_modules/thesis.md"));
let plan = plan_for(&root, std::slice::from_ref(&target));
assert_eq!(targets(&plan), [target]);
fs::remove_dir_all(root.join("app")).unwrap();
std::os::unix::fs::symlink(&outside, root.join("app")).unwrap();
let removal = Deleter::new().remove(&plan);
assert!(
outside.join("node_modules/thesis.md").exists(),
"the removal followed a swapped ancestor out of the root and deleted {}",
outside.display()
);
assert!(removal.removed.is_empty(), "{:?}", removal.removed);
assert!(!removal.failures.is_empty(), "the swap was not reported");
}
#[cfg(unix)]
#[test]
fn the_scan_root_swapped_for_a_link_after_planning_misdirects_nothing() {
let (_tmp, base) = fixture();
let root = base.join("root");
let target = root.join("app/node_modules");
touch(&target.join("left-pad/index.js"));
let mirror = base.join("mirror");
touch(&mirror.join("app/node_modules/left-pad/index.js"));
let intact = walk_files(&mirror);
let plan = plan_for(&root, std::slice::from_ref(&target));
assert_eq!(targets(&plan), std::slice::from_ref(&target));
fs::rename(&root, base.join("parked")).unwrap();
std::os::unix::fs::symlink(&mirror, &root).unwrap();
let removal = Deleter::new().remove(&plan);
assert_eq!(
walk_files(&mirror),
intact,
"the batch was anchored to a swapped root and deleted from the mirror"
);
assert!(removal.removed.is_empty(), "{:?}", removal.removed);
assert!(
!removal.failures.is_empty(),
"the swapped root was not reported"
);
}
#[cfg(unix)]
#[test]
fn the_scan_root_replaced_by_a_real_directory_after_planning_misdirects_nothing() {
let (_tmp, base) = fixture();
let root = base.join("root");
let target = root.join("app/node_modules");
touch(&target.join("left-pad/index.js"));
let mirror = base.join("mirror");
touch(&mirror.join("app/node_modules/left-pad/index.js"));
let plan = plan_for(&root, std::slice::from_ref(&target));
assert_eq!(targets(&plan), std::slice::from_ref(&target));
fs::rename(&root, base.join("parked")).unwrap();
fs::rename(&mirror, &root).unwrap();
let removal = Deleter::new().remove(&plan);
assert!(
root.join("app/node_modules/left-pad/index.js").exists(),
"the batch was anchored to a replaced root and deleted from it"
);
assert!(removal.removed.is_empty(), "{:?}", removal.removed);
assert!(
!removal.failures.is_empty(),
"the replaced root was not reported"
);
}
#[cfg(unix)]
#[test]
fn an_ancestor_swapped_while_the_sweep_is_inside_the_target_cannot_redirect_it() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
const FILES: usize = 400;
const ROUNDS: usize = 6;
for round in 0..ROUNDS {
let (_tmp, base) = fixture();
let root = base.join("root");
let outside = base.join("precious");
let target = root.join("app/nm");
for file in 0..FILES {
let name = format!("f{file:04}.js");
write(&target.join(&name), 256);
write(&outside.join("nm").join(&name), 256);
}
let bait = walk_files(&outside);
assert_eq!(bait.len(), FILES);
let plan = plan_for(&root, std::slice::from_ref(&target));
assert_eq!(targets(&plan), std::slice::from_ref(&target));
let decoy = root.join("decoy");
let parked = root.join("parked");
std::os::unix::fs::symlink(&outside, &decoy).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let swapped = Arc::new(AtomicBool::new(false));
let attacker = {
let stop = Arc::clone(&stop);
let swapped = Arc::clone(&swapped);
let app = root.join("app");
let target = target.clone();
std::thread::spawn(move || {
while !stop.load(Ordering::Relaxed) {
let remaining = fs::read_dir(&target).map_or(0, Iterator::count);
if remaining < FILES {
break;
}
std::thread::yield_now();
}
if fs::rename(&app, &parked).is_ok() && fs::rename(&decoy, &app).is_ok() {
swapped.store(true, Ordering::Relaxed);
}
while !stop.load(Ordering::Relaxed) {
std::thread::yield_now();
}
let _ = fs::rename(&app, &decoy);
let _ = fs::rename(&parked, &app);
})
};
let removal = Deleter::new().remove(&plan);
stop.store(true, Ordering::Relaxed);
attacker.join().expect("the attacker thread must not panic");
assert!(
swapped.load(Ordering::Relaxed),
"round {round}: the ancestor was never swapped, so nothing was exercised"
);
assert_eq!(
walk_files(&outside),
bait,
"round {round}: the removal was redirected out of the scan root"
);
assert!(removal.is_clean(), "round {round}: {:?}", removal.failures);
assert!(
!target.exists(),
"round {round}: the target was left behind"
);
}
}
#[cfg(unix)]
#[test]
fn hammering_an_ancestor_throughout_a_removal_never_reaches_outside_the_root() {
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
const TARGETS: usize = 16;
const ROUNDS: usize = 12;
let mut total_swaps = 0_u64;
for round in 0..ROUNDS {
let (_tmp, base) = fixture();
let root = base.join("root");
let outside = base.join("precious");
let mut targets = Vec::new();
for i in 0..TARGETS {
let target = root.join(format!("app/nm{i}"));
for pkg in 0..8 {
write(&target.join(format!("pkg{pkg}/index.js")), 512);
write(&target.join(format!("pkg{pkg}/readme.md")), 512);
write(&outside.join(format!("nm{i}/pkg{pkg}/index.js")), 512);
write(&outside.join(format!("nm{i}/pkg{pkg}/readme.md")), 512);
}
targets.push(target);
}
let bait: Vec<PathBuf> = walk_files(&outside);
assert!(!bait.is_empty());
let plan = plan_for(&root, &targets);
assert_eq!(plan.targets().len(), TARGETS, "{:?}", refusals(&plan));
let parked = root.join("parked");
let decoy = root.join("decoy");
std::os::unix::fs::symlink(&outside, &decoy).unwrap();
let stop = Arc::new(AtomicBool::new(false));
let attacker = {
let stop = Arc::clone(&stop);
let app = root.join("app");
std::thread::spawn(move || {
let mut swaps = 0_u64;
while !stop.load(Ordering::Relaxed) {
if fs::rename(&app, &parked).is_ok() {
if fs::rename(&decoy, &app).is_ok() {
swaps += 1;
std::thread::sleep(Duration::from_micros(200));
let _ = fs::rename(&app, &decoy);
}
let _ = fs::rename(&parked, &app);
}
std::thread::yield_now();
}
swaps
})
};
let removal = Deleter::new().remove(&plan);
stop.store(true, Ordering::Relaxed);
total_swaps += attacker.join().expect("the attacker thread must not panic");
for file in &bait {
assert!(
file.exists(),
"round {round}: a swapped ancestor took the removal out of the root and \
deleted {}",
file.display()
);
}
for removed in &removal.removed {
assert!(
removed.path.starts_with(&root),
"round {round}: removed {} from outside the root",
removed.path.display()
);
}
}
assert!(
total_swaps > 0,
"the attacker never completed a swap in {ROUNDS} rounds, so nothing was exercised"
);
}
#[cfg(unix)]
fn on_another_filesystem() -> Option<PathBuf> {
use std::os::unix::fs::MetadataExt;
let root = Path::new("/").symlink_metadata().ok()?.dev();
["/dev/null", "/dev", "/proc/self", "/sys"]
.into_iter()
.map(PathBuf::from)
.find(|candidate| {
candidate
.symlink_metadata()
.is_ok_and(|metadata| metadata.dev() != root)
})
}
#[cfg(unix)]
#[test]
fn a_mount_point_is_refused_and_only_the_flag_lets_it_through() {
let Some(elsewhere) = on_another_filesystem() else {
return; };
let plan = Planner::new("/").plan([Target::at(&elsewhere)]);
assert!(plan.targets().is_empty(), "{:?}", targets(&plan));
assert_eq!(
refusals(&plan),
[(elsewhere.clone(), Refusal::OtherFileSystem)]
);
let crossed = Planner::new("/")
.one_file_system(false)
.plan([Target::at(&elsewhere)]);
assert_eq!(targets(&crossed), [elsewhere]);
assert!(crossed.kept().is_empty(), "{:?}", crossed.kept());
}
#[cfg(unix)]
#[test]
fn a_symlink_inside_a_target_is_unlinked_as_a_link_and_never_walked() {
let (_tmp, base) = fixture();
let outside = base.join("precious");
touch(&outside.join("thesis.md"));
let target = base.join("app/node_modules");
touch(&target.join("dep/index.js"));
std::os::unix::fs::symlink(&outside, target.join("dep/escape")).unwrap();
let removal = Deleter::new().remove(&plan_for(&base, std::slice::from_ref(&target)));
assert!(removal.failures.is_empty(), "{:?}", removal.failures);
assert!(!target.exists(), "the target survived");
assert!(
outside.join("thesis.md").exists(),
"the deleter walked through a symlink and out of the root"
);
}
#[cfg(unix)]
#[test]
fn a_target_that_is_itself_a_symlink_is_unlinked_without_touching_what_it_points_at() {
let (_tmp, base) = fixture();
let outside = base.join("precious");
touch(&outside.join("thesis.md"));
let target = base.join("repo/bazel-out");
mkdir(target.parent().unwrap());
std::os::unix::fs::symlink(&outside, &target).unwrap();
let removal = Deleter::new().remove(&plan_for(&base, std::slice::from_ref(&target)));
assert!(removal.failures.is_empty(), "{:?}", removal.failures);
assert!(target.symlink_metadata().is_err(), "the link survived");
assert!(outside.join("thesis.md").exists(), "the link was followed");
}
#[test]
fn a_checkout_inside_a_target_stops_the_removal_and_is_reported() {
let (_tmp, base) = fixture();
let target = base.join("ignored");
touch(&target.join("junk/scratch.o"));
let checkout = target.join("work/repo");
touch(&checkout.join(".git/HEAD"));
touch(&checkout.join("uncommitted.rs"));
let removal = Deleter::new().remove(&plan_for(&base, std::slice::from_ref(&target)));
assert!(removal.failures.is_empty(), "{:?}", removal.failures);
assert!(
checkout.join("uncommitted.rs").exists(),
"uncommitted work was deleted"
);
assert_eq!(
removal
.kept
.iter()
.map(|refused| (refused.path.clone(), refused.reason.clone()))
.collect::<Vec<_>>(),
[(checkout, Refusal::HoldsCheckout)]
);
assert!(!target.join("junk").exists());
assert!(target.exists());
assert!(removal.removed.iter().all(|removed| !removed.complete));
}
#[test]
fn a_git_file_marks_a_checkout_just_as_a_git_directory_does() {
let (_tmp, base) = fixture();
let target = base.join("ignored");
let worktree = target.join("linked");
write(&worktree.join(".git"), 32);
touch(&worktree.join("uncommitted.rs"));
let removal = Deleter::new().remove(&plan_for(&base, &[target]));
assert!(worktree.join("uncommitted.rs").exists());
assert_eq!(removal.kept.len(), 1, "{:?}", removal.kept);
assert_eq!(removal.kept[0].reason, Refusal::HoldsCheckout);
}
#[test]
fn a_target_that_is_itself_a_checkout_is_left_whole() {
let (_tmp, base) = fixture();
let target = base.join("vendored");
touch(&target.join(".git/HEAD"));
touch(&target.join("uncommitted.rs"));
let removal = Deleter::new().remove(&plan_for(&base, std::slice::from_ref(&target)));
assert!(target.join("uncommitted.rs").exists());
assert!(target.join(".git/HEAD").exists());
assert_eq!(removal.kept.len(), 1, "{:?}", removal.kept);
assert_eq!(removal.kept[0].reason, Refusal::HoldsCheckout);
assert!(removal.removed.is_empty(), "{:?}", removal.removed);
}
#[test]
fn a_recently_touched_target_is_refused_when_an_age_floor_is_set() {
let (_tmp, base) = fixture();
let fresh = base.join("fresh/node_modules");
let stale = base.join("stale/node_modules");
touch(&fresh.join("index.js"));
touch(&stale.join("index.js"));
let a_year_ago = SystemTime::now() - Duration::from_secs(365 * 24 * 60 * 60);
filetime::set_file_mtime(&stale, filetime::FileTime::from_system_time(a_year_ago)).unwrap();
let plan = Planner::new(&base)
.older_than(Some(Duration::from_secs(30 * 24 * 60 * 60)))
.plan([Target::at(&fresh), Target::at(&stale)]);
assert_eq!(targets(&plan), [stale]);
assert!(
matches!(
refusals(&plan).as_slice(),
[(path, Refusal::RecentlyUsed { .. })] if path == &fresh
),
"{:?}",
refusals(&plan)
);
}
#[test]
fn nothing_is_excluded_for_its_age_by_default() {
let (_tmp, base) = fixture();
let fresh = base.join("fresh/node_modules");
touch(&fresh.join("index.js"));
let plan = plan_for(&base, std::slice::from_ref(&fresh));
assert_eq!(targets(&plan), [fresh]);
assert!(plan.kept().is_empty());
}
#[cfg(unix)]
#[test]
fn one_unremovable_target_does_not_cost_the_others() {
let (_tmp, base) = fixture();
let sealed = base.join("a/node_modules");
touch(&sealed.join("dep/index.js"));
let fine: Vec<PathBuf> = (0..8)
.map(|n| base.join(format!("b{n}/node_modules")))
.collect();
for target in &fine {
touch(&target.join("dep/index.js"));
}
if !seal(&sealed.join("dep")) {
return; }
let mut all = vec![sealed.clone()];
all.extend(fine.iter().cloned());
let removal = Deleter::new().remove(&plan_for(&base, &all));
unseal(&sealed.join("dep"));
assert_eq!(removal.failures.len(), 1, "{:?}", removal.failures);
assert_eq!(removal.failures[0].path, sealed.join("dep"));
for target in &fine {
assert!(!target.exists(), "{} survived", target.display());
}
assert!(sealed.exists(), "the sealed target was removed anyway");
}
#[test]
fn a_plain_target_is_removed_whole_and_its_bytes_are_reported() {
let (_tmp, base) = fixture();
let target = base.join("app/node_modules");
write(&target.join("a/one.bin"), 64 * 1024);
write(&target.join("a/b/two.bin"), 64 * 1024);
let removal = Deleter::new().remove(&plan_for(&base, std::slice::from_ref(&target)));
assert!(removal.failures.is_empty(), "{:?}", removal.failures);
assert!(removal.kept.is_empty(), "{:?}", removal.kept);
assert!(!target.exists());
assert!(base.join("app").exists(), "the parent went too");
assert_eq!(removal.removed.len(), 1);
assert!(removal.removed[0].complete);
assert!(removal.bytes_freed() >= 128 * 1024, "{removal:?}");
assert_eq!(removal.entries_removed(), 5);
}
#[test]
fn several_targets_are_removed_in_one_batch() {
let (_tmp, base) = fixture();
let targets: Vec<PathBuf> = (0..32)
.map(|n| base.join(format!("p{n}/node_modules")))
.collect();
for target in &targets {
write(&target.join("dep/index.js"), 1024);
}
let removal = Deleter::new().remove(&plan_for(&base, &targets));
assert!(removal.failures.is_empty(), "{:?}", removal.failures);
assert_eq!(removal.removed.len(), 32);
for target in &targets {
assert!(!target.exists(), "{} survived", target.display());
}
}
#[test]
fn a_watcher_is_told_about_each_target_as_it_finishes_and_is_told_the_same_thing_twice() {
let (_tmp, base) = fixture();
let targets: Vec<PathBuf> = (0..32)
.map(|n| base.join(format!("p{n}/node_modules")))
.collect();
for target in &targets {
write(&target.join("dep/index.js"), 1024);
}
let watched = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&watched);
let removal = Deleter::new()
.watching(move |step| {
if let Step::Finished(removed) = step {
sink.lock().unwrap().push(removed.clone());
}
})
.remove(&plan_for(&base, &targets));
let mut watched: Vec<_> = watched.lock().unwrap().iter().map(summarise).collect();
let mut reported: Vec<_> = removal.removed.iter().map(summarise).collect();
watched.sort();
reported.sort();
assert_eq!(watched.len(), 32);
assert_eq!(watched, reported);
}
#[test]
fn a_watcher_is_told_how_far_a_target_has_got_while_it_is_still_going() {
let (_tmp, base) = fixture();
let target = base.join("app/node_modules");
for pkg in 0..40 {
for file in 0..50 {
write(&target.join(format!("p{pkg}/f{file}.js")), 1024);
}
}
let steps = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&steps);
let removal = Deleter::new()
.threads(1)
.watching(move |step| sink.lock().unwrap().push(step.clone()))
.remove(&plan_for(&base, std::slice::from_ref(&target)));
let steps = steps.lock().unwrap();
let progress: Vec<&Freeing> = steps
.iter()
.filter_map(|step| match step {
Step::Freeing(freeing) => Some(freeing),
Step::Finished(_) | Step::Swept(_) => None,
})
.collect();
assert!(progress.len() > 10, "{} reports", progress.len());
assert!(progress.iter().all(|freeing| freeing.path == target));
for pair in progress.windows(2) {
assert!(pair[1].bytes >= pair[0].bytes, "{:?}", (pair[0], pair[1]));
assert!(
pair[1].entries > pair[0].entries,
"{:?}",
(pair[0], pair[1])
);
}
let last = progress.last().expect("progress was reported");
assert!(last.bytes <= removal.bytes_freed());
assert_eq!(removal.removed.len(), 1);
assert_eq!(removal.removed[0].bytes, removal.bytes_freed());
assert!(
last.entries < removal.entries_removed(),
"the last progress report was the whole job"
);
let finished = steps
.iter()
.filter(|step| matches!(step, Step::Finished(_)))
.count();
assert_eq!(finished, 1);
assert!(
matches!(steps.last(), Some(Step::Swept(path)) if path == &target),
"the sweep reported progress after it had finished"
);
let order: Vec<&str> = steps
.iter()
.rev()
.take(2)
.map(|step| match step {
Step::Freeing(_) => "freeing",
Step::Finished(_) => "finished",
Step::Swept(_) => "swept",
})
.collect();
assert_eq!(order, ["swept", "finished"]);
}
#[test]
fn a_watcher_is_told_the_pool_moved_on_even_from_a_target_nothing_happened_to() {
let (_tmp, base) = fixture();
let doomed = base.join("vanishes/node_modules");
let survives = base.join("app/node_modules");
write(&doomed.join("dep/index.js"), 1024);
write(&survives.join("dep/index.js"), 1024);
let plan = plan_for(&base, &[doomed.clone(), survives.clone()]);
fs::remove_dir_all(base.join("vanishes")).unwrap();
let steps = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&steps);
let removal = Deleter::new()
.watching(move |step| sink.lock().unwrap().push(step.clone()))
.remove(&plan);
let steps = steps.lock().unwrap();
let swept: Vec<&PathBuf> = steps
.iter()
.filter_map(|step| match step {
Step::Swept(path) => Some(path),
Step::Freeing(_) | Step::Finished(_) => None,
})
.collect();
let finished: Vec<&Removed> = steps
.iter()
.filter_map(|step| match step {
Step::Finished(removed) => Some(removed),
Step::Freeing(_) | Step::Swept(_) => None,
})
.collect();
assert_eq!(swept.len(), 2, "{swept:?}");
assert!(
swept.contains(&&doomed) && swept.contains(&&survives),
"{swept:?}"
);
assert_eq!(finished.len(), 1);
assert_eq!(finished[0].path, survives);
assert_eq!(removal.removed.len(), 1);
assert_eq!(removal.removed[0].path, survives);
assert_eq!(removal.failures.len(), 1, "{:?}", removal.failures);
}
#[test]
fn a_watcher_is_told_when_a_target_was_only_partly_removed() {
let (_tmp, base) = fixture();
let target = base.join("checkout/node_modules");
write(&target.join("dep/index.js"), 1024);
mkdir(&target.join("inner/.git"));
let watched = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&watched);
let removal = Deleter::new()
.watching(move |step| {
if let Step::Finished(removed) = step {
sink.lock().unwrap().push(removed.clone());
}
})
.remove(&plan_for(&base, std::slice::from_ref(&target)));
assert!(target.exists());
let watched = watched.lock().unwrap();
assert_eq!(watched.len(), 1);
assert!(!watched[0].complete, "{:?}", watched[0]);
assert_eq!(removal.removed.len(), 1);
}
#[test]
fn a_watcher_is_told_the_path_it_asked_about_rather_than_the_one_that_was_unlinked() {
let (_tmp, base) = fixture();
let real = base.join("real");
let link = base.join("link");
mkdir(&real);
std::os::unix::fs::symlink(&real, &link).unwrap();
let asked_about = link.join("app/node_modules");
let unlinked = real.join("app/node_modules");
write(&asked_about.join("dep/index.js"), 4096);
let steps = Arc::new(Mutex::new(Vec::new()));
let sink = Arc::clone(&steps);
let removal = Deleter::new()
.watching(move |step| sink.lock().unwrap().push(step.clone()))
.remove(&plan_for(&link, std::slice::from_ref(&asked_about)));
assert!(!unlinked.exists());
let steps = steps.lock().unwrap();
let reported: Vec<&PathBuf> = steps
.iter()
.map(|step| match step {
Step::Freeing(freeing) => &freeing.path,
Step::Finished(removed) => &removed.path,
Step::Swept(path) => path,
})
.collect();
assert!(!reported.is_empty(), "nothing was reported");
assert!(
reported.iter().all(|path| **path == asked_about),
"the watcher was told {reported:?} rather than {asked_about:?}"
);
assert_eq!(removal.removed.len(), 1);
assert_eq!(removal.removed[0].path, asked_about);
}
fn git(at: &Path, args: &[&str]) {
let output = std::process::Command::new("git")
.arg("-C")
.arg(at)
.args(args)
.env("LC_ALL", "C")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null")
.output()
.unwrap();
assert!(
output.status.success(),
"git {args:?} in {}: {}",
at.display(),
String::from_utf8_lossy(&output.stderr)
);
}
fn worktree(main: &Path, args: &[&str]) {
let mut all = vec!["worktree", "add", "--quiet"];
all.extend_from_slice(args);
git(main, &all);
}
fn repo(at: &Path) {
fs::create_dir_all(at).unwrap();
git(at, &["init", "--quiet", "."]);
git(at, &["config", "user.email", "test@example.com"]);
git(at, &["config", "user.name", "test"]);
write(&at.join("tracked.txt"), 16);
git(at, &["add", "."]);
git(at, &["commit", "--quiet", "-m", "first"]);
}
#[test]
fn a_clean_linked_work_tree_is_removed_whole_and_its_history_survives() {
let (_tmp, base) = fixture();
let main = base.join("main");
repo(&main);
fs::write(main.join(".gitignore"), "node_modules/\n").unwrap();
git(&main, &["add", ".gitignore"]);
git(&main, &["commit", "--quiet", "-m", "ignore node_modules"]);
worktree(&main, &["../spent", "-b", "feature"]);
let spent = base.join("spent");
write(&spent.join("work.txt"), 32);
git(&spent, &["add", "."]);
git(&spent, &["commit", "--quiet", "-m", "in the work tree"]);
write(&spent.join("node_modules/dep/index.js"), 4096);
let removal = Deleter::new().remove(&plan_for(&base, std::slice::from_ref(&spent)));
assert!(removal.is_clean(), "{:?}", removal.failures);
assert!(!spent.exists(), "the work tree was left standing");
let shown = std::process::Command::new("git")
.arg("-C")
.arg(&main)
.args(["show", "feature:work.txt"])
.output()
.unwrap();
assert!(
shown.status.success(),
"the commit died with the directory: {}",
String::from_utf8_lossy(&shown.stderr)
);
}
#[test]
fn a_linked_work_tree_with_uncommitted_work_is_refused() {
let (_tmp, base) = fixture();
let main = base.join("main");
repo(&main);
worktree(&main, &["../busy", "-b", "feature"]);
let busy = base.join("busy");
write(&busy.join("notes.md"), 8);
let plan = plan_for(&base, std::slice::from_ref(&busy));
assert!(plan.targets().is_empty(), "{:?}", plan.targets());
assert_eq!(refusals(&plan), [(busy.clone(), Refusal::WorkTreeInUse)]);
assert!(busy.join("notes.md").exists());
}
#[test]
fn a_linked_work_tree_on_a_detached_head_is_refused() {
let (_tmp, base) = fixture();
let main = base.join("main");
repo(&main);
worktree(&main, &["--detach", "../loose"]);
let loose = base.join("loose");
let plan = plan_for(&base, std::slice::from_ref(&loose));
assert!(plan.targets().is_empty(), "{:?}", plan.targets());
assert_eq!(
refusals(&plan),
[(loose.clone(), Refusal::WorkTreeDetached)]
);
assert!(loose.exists());
}
#[test]
fn a_submodule_is_not_a_linked_work_tree_however_much_its_dot_git_looks_like_one() {
let (_tmp, base) = fixture();
let main = base.join("main");
let inner = base.join("inner");
repo(&main);
repo(&inner);
git(
&main,
&[
"-c",
"protocol.file.allow=always",
"submodule",
"--quiet",
"add",
inner.to_str().unwrap(),
"vendored",
],
);
git(&main, &["commit", "--quiet", "-m", "vendored"]);
let vendored = main.join("vendored");
let plan = plan_for(&base, std::slice::from_ref(&vendored));
assert!(plan.targets().is_empty(), "{:?}", plan.targets());
assert_eq!(
refusals(&plan),
[(vendored.clone(), Refusal::HoldsCheckout)]
);
assert!(vendored.join(".git").exists());
}
#[test]
fn the_permission_granted_to_one_work_tree_does_not_reach_a_checkout_inside_it() {
let (_tmp, base) = fixture();
let main = base.join("main");
repo(&main);
worktree(&main, &["../spent", "-b", "feature"]);
let spent = base.join("spent");
fs::write(spent.join(".gitignore"), "scratch/\n").unwrap();
git(&spent, &["add", ".gitignore"]);
git(&spent, &["commit", "--quiet", "-m", "ignore it"]);
let stowaway = spent.join("scratch/someone-elses-clone");
repo(&stowaway);
write(&stowaway.join("the-only-copy.txt"), 64);
let removal = Deleter::new().remove(&plan_for(&base, std::slice::from_ref(&spent)));
assert!(stowaway.join("the-only-copy.txt").exists());
assert!(stowaway.join(".git").exists());
assert!(spent.exists(), "the work tree was removed over a refusal");
assert!(
removal
.kept
.iter()
.any(|kept| kept.path == stowaway && kept.reason == Refusal::HoldsCheckout),
"{:?}",
removal.kept
);
assert!(
!spent.join("tracked.txt").exists(),
"the sweep never entered the work tree, so this proves nothing about where it stopped"
);
}
#[test]
fn a_plain_repository_is_still_refused_even_when_it_is_clean_and_idle() {
let (_tmp, base) = fixture();
let alone = base.join("alone");
repo(&alone);
let plan = plan_for(&base, std::slice::from_ref(&alone));
assert!(plan.targets().is_empty(), "{:?}", plan.targets());
assert_eq!(refusals(&plan), [(alone.clone(), Refusal::HoldsCheckout)]);
assert!(alone.join(".git").exists());
}
fn summarise(removed: &Removed) -> (PathBuf, u64, u64, bool) {
(
removed.path.clone(),
removed.bytes,
removed.entries,
removed.complete,
)
}