use super::*;
use crate::fs_beneath::handle_path;
use crate::test_support::{age, fixture, git, git_context, remove, repository};
use std::cell::RefCell;
use std::ffi::OsString;
use std::os::unix::fs::PermissionsExt;
type Hook = RefCell<Option<Box<dyn FnOnce()>>>;
struct Stand {
subvolumes: Vec<PathBuf>,
after_proof: Hook,
during_destroy: Hook,
}
impl Stand {
fn new(subvolumes: Vec<PathBuf>) -> Stand {
return Stand { subvolumes, after_proof: RefCell::new(None), during_destroy: RefCell::new(None) };
}
fn fire(hook: &Hook) {
if let Some(action) = hook.borrow_mut().take() {
action();
}
}
}
impl RemoveOperations for Stand {
fn is_subvolume_root(&self, path: &Path) -> bool {
return self.subvolumes.iter().any(|known: &PathBuf| { return known == path; });
}
fn removal_allowed(&self, _path: &Path) -> bool {
return true;
}
fn check_clean(&self, request: &RemoveRequest<'_>, admin: &Path, _common_dir: &Path) -> Result<(), String> {
let own_common_dir: &Path = admin.parent().and_then(Path::parent).ok_or_else(|| { return String::from("no common directory"); })?;
check_clean(request.context, request.plan.target.as_path(), admin, own_common_dir).map_err(|refusal| { return refusal.reason; })?;
Stand::fire(&self.after_proof);
return Ok(());
}
fn destroy(&self, held: &Held) -> Result<(), String> {
Stand::fire(&self.during_destroy);
return std::fs::remove_dir_all(handle_path(&held.parent).join(held.name.as_os_str())).map_err(|error: std::io::Error| { return error.to_string(); });
}
}
fn repository_with_file(root: &Path, name: &str) -> PathBuf {
let repo: PathBuf = repository(root, name);
std::fs::write(repo.join("tracked.txt"), b"tracked\n").expect("tracked");
git(repo.as_path(), &["add", "tracked.txt"]);
git(repo.as_path(), &["commit", "--quiet", "--message=tracked"]);
return repo;
}
fn clean_worktree(repo: &Path, directory: &Path, branch: &str) -> PathBuf {
git(repo, &["worktree", "add", "--quiet", "-b", branch, directory.to_str().expect("UTF-8")]);
let canonical: PathBuf = std::fs::canonicalize(directory).expect("canonical");
age(canonical.join("tracked.txt").as_path());
git(canonical.as_path(), &["update-index", "--refresh"]);
return canonical;
}
fn plan_named(target: &Path, operand: &str) -> RemovePlan {
return RemovePlan { target: target.to_path_buf(), operand: OsString::from(operand) };
}
fn run(stand: &Stand, repo: &Path, plan: &RemovePlan) -> RemoveOutcome {
let common_dir: PathBuf = std::fs::canonicalize(repo.join(".git")).expect("common directory");
return remove_with(stand, &RemoveRequest { context: &git_context(), plan, common_dir: common_dir.as_path() });
}
fn admin_of(repo: &Path, name: &str) -> PathBuf {
return std::fs::canonicalize(repo.join(".git/worktrees").join(name)).expect("administrative directory");
}
#[test]
fn a_worktree_of_another_repository_is_left_to_git() {
let root: PathBuf = fixture("remove-guard-other-repository");
let repo_a: PathBuf = repository_with_file(root.as_path(), "a");
let repo_b: PathBuf = repository_with_file(root.as_path(), "b");
let foreign: PathBuf = clean_worktree(repo_b.as_path(), root.join("wb").as_path(), "tb");
let stand: Stand = Stand::new(vec![foreign.clone()]);
assert_eq!(run(&stand, repo_a.as_path(), &plan_named(foreign.as_path(), "../wb")), RemoveOutcome::LeftToGit);
assert!(foreign.join("tracked.txt").is_file(), "the foreign worktree is untouched");
assert!(repo_b.join(".git/worktrees/wb").is_dir(), "its registration too");
assert_eq!(run(&stand, repo_b.as_path(), &plan_named(foreign.as_path(), "../wb")), RemoveOutcome::Removed);
remove(root.as_path());
}
#[test]
fn a_name_that_git_resolves_by_its_last_components_is_left_to_git() {
let root: PathBuf = fixture("remove-guard-suffix");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let other: PathBuf = clean_worktree(repo.as_path(), root.join("x/other").as_path(), "to");
let topic: PathBuf = clean_worktree(repo.as_path(), root.join("y/topic").as_path(), "tt");
std::os::unix::fs::symlink("../x/other", repo.join("topic")).expect("link");
let stand: Stand = Stand::new(vec![other.clone(), topic.clone()]);
assert_eq!(run(&stand, repo.as_path(), &plan_named(other.as_path(), "topic")), RemoveOutcome::LeftToGit);
assert!(other.join("tracked.txt").is_file() && topic.join("tracked.txt").is_file(), "both worktrees are untouched");
assert_eq!(run(&stand, repo.as_path(), &plan_named(other.as_path(), "other")), RemoveOutcome::Removed);
assert!(!other.exists());
assert_eq!(run(&stand, repo.as_path(), &plan_named(topic.as_path(), "../y/topic")), RemoveOutcome::Removed);
assert!(!topic.exists());
remove(root.as_path());
}
#[test]
fn a_lock_entry_that_cannot_be_looked_up_is_not_an_absent_lock() {
let root: PathBuf = fixture("remove-guard-lock-lookup");
std::fs::write(root.join("not-a-directory"), b"x").expect("file");
std::fs::create_dir(root.join("admin")).expect("admin");
assert!(lock_state(root.join("not-a-directory").as_path()).is_err(), "the lookup fails with ENOTDIR");
assert_eq!(lock_state(root.join("admin").as_path()), Ok(false));
std::fs::write(root.join("admin/locked"), b"reason\n").expect("locked");
assert_eq!(lock_state(root.join("admin").as_path()), Ok(true));
remove(root.as_path());
}
#[test]
fn a_lock_taken_after_the_proof_stops_the_removal() {
let root: PathBuf = fixture("remove-guard-late-lock");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("wt").as_path(), "topic");
let stand: Stand = Stand::new(vec![worktree.clone()]);
let admin: PathBuf = admin_of(repo.as_path(), "wt");
*stand.after_proof.borrow_mut() = Some(Box::new(move || { std::fs::write(admin.join("locked"), b"late\n").expect("lock"); }));
assert_eq!(run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../wt")), RemoveOutcome::LeftToGit);
assert!(worktree.join("tracked.txt").is_file(), "the locked worktree is untouched");
remove(root.as_path());
}
#[test]
fn a_directory_swapped_in_after_the_proof_is_not_destroyed() {
let root: PathBuf = fixture("remove-guard-swapped-target");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("wt").as_path(), "topic");
let stand: Stand = Stand::new(vec![worktree.clone()]);
let (moved, at) = (root.join("wt.moved"), worktree.clone());
*stand.after_proof.borrow_mut() = Some(Box::new(move || {
std::fs::rename(at.as_path(), moved.as_path()).expect("move the proven directory away");
std::fs::create_dir(at.as_path()).expect("replacement");
std::fs::write(at.join("precious.txt"), b"unsaved work\n").expect("replacement file");
}));
assert_eq!(run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../wt")), RemoveOutcome::LeftToGit);
assert_eq!(std::fs::read(worktree.join("precious.txt")).expect("the replacement is kept"), b"unsaved work\n");
assert!(root.join("wt.moved/tracked.txt").is_file(), "the proven directory is untouched");
remove(root.as_path());
}
#[test]
fn an_ancestor_replaced_by_a_link_after_the_proof_does_not_redirect_the_destroy() {
let root: PathBuf = fixture("remove-guard-swapped-ancestor");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("sub/wt").as_path(), "topic");
let stand: Stand = Stand::new(vec![worktree.clone()]);
let (sub, moved, elsewhere) = (root.join("sub"), root.join("sub.moved"), root.join("elsewhere"));
*stand.after_proof.borrow_mut() = Some(Box::new(move || {
std::fs::rename(sub.as_path(), moved.as_path()).expect("move the ancestor away");
std::fs::create_dir_all(elsewhere.join("wt")).expect("replacement");
std::fs::write(elsewhere.join("wt/precious.txt"), b"unsaved work\n").expect("replacement file");
std::os::unix::fs::symlink(elsewhere.as_path(), sub.as_path()).expect("link");
}));
assert_eq!(run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../sub/wt")), RemoveOutcome::LeftToGit);
assert_eq!(std::fs::read(root.join("elsewhere/wt/precious.txt")).expect("the directory behind the link is kept"), b"unsaved work\n");
assert!(root.join("sub.moved/wt/tracked.txt").is_file(), "the proven directory is untouched too");
remove(root.as_path());
}
#[test]
fn an_ancestor_replaced_by_a_directory_after_the_proof_stops_the_removal() {
let root: PathBuf = fixture("remove-guard-replaced-ancestor");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("sub/wt").as_path(), "topic");
let stand: Stand = Stand::new(vec![worktree.clone()]);
let (sub, moved) = (root.join("sub"), root.join("sub.moved"));
*stand.after_proof.borrow_mut() = Some(Box::new(move || {
std::fs::rename(sub.as_path(), moved.as_path()).expect("move the ancestor away");
std::fs::create_dir(sub.as_path()).expect("another directory at the same path");
}));
assert_eq!(run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../sub/wt")), RemoveOutcome::LeftToGit);
assert!(root.join("sub.moved/wt/tracked.txt").is_file(), "the proven directory is untouched");
remove(root.as_path());
}
#[test]
fn a_worktree_with_a_populated_submodule_is_left_to_git() {
let root: PathBuf = fixture("remove-guard-submodule");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let library: PathBuf = repository_with_file(root.as_path(), "library");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("wt").as_path(), "topic");
git(worktree.as_path(), &["-c", "protocol.file.allow=always", "submodule", "add", "--quiet", library.to_str().expect("UTF-8"), "lib"]);
git(worktree.as_path(), &["commit", "--quiet", "--message=submodule"]);
for name in ["tracked.txt", ".gitmodules"] {
age(worktree.join(name).as_path());
}
git(worktree.as_path(), &["update-index", "--refresh"]);
let common_dir: PathBuf = std::fs::canonicalize(repo.join(".git")).expect("common directory");
let proof = check_clean(&git_context(), worktree.as_path(), admin_of(repo.as_path(), "wt").as_path(), common_dir.as_path());
assert_eq!(proof.map_err(|refusal| { return refusal.reason; }), Err(String::from("lib is a submodule")));
let stand: Stand = Stand::new(vec![worktree.clone()]);
assert_eq!(run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../wt")), RemoveOutcome::LeftToGit);
assert!(worktree.join("lib/tracked.txt").is_file(), "the submodule's files are untouched");
remove(root.as_path());
}
#[test]
fn a_registration_that_cannot_be_removed_after_the_destroy_is_an_incomplete_removal() {
let root: PathBuf = fixture("remove-guard-incomplete");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("wt").as_path(), "topic");
let stand: Stand = Stand::new(vec![worktree.clone()]);
let admin: PathBuf = admin_of(repo.as_path(), "wt");
let worktrees: PathBuf = repo.join(".git/worktrees");
std::fs::set_permissions(worktrees.as_path(), std::fs::Permissions::from_mode(0o555)).expect("read-only registrations");
let outcome: RemoveOutcome = run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../wt"));
std::fs::set_permissions(worktrees.as_path(), std::fs::Permissions::from_mode(0o755)).expect("restore");
let RemoveOutcome::Incomplete(report) = outcome else { panic!("a leftover registration is not a success: {outcome:?}") };
assert!(!worktree.exists(), "the files are gone");
assert!(report.contains(admin.to_str().expect("UTF-8")), "the report names the leftover: {report}");
assert!(report.contains(worktree.to_str().expect("UTF-8")), "and the removed worktree: {report}");
assert!(report.contains(format!("git -C {} worktree prune", repo.display()).as_str()), "and the command that finishes: {report}");
remove(root.as_path());
}
#[test]
fn an_administrative_directory_replaced_after_the_proof_is_kept() {
let root: PathBuf = fixture("remove-guard-swapped-admin");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("wt").as_path(), "topic");
let stand: Stand = Stand::new(vec![worktree.clone()]);
let admin: PathBuf = admin_of(repo.as_path(), "wt");
let moved: PathBuf = repo.join(".git/worktrees/wt.moved");
let at: PathBuf = admin.clone();
*stand.after_proof.borrow_mut() = Some(Box::new(move || {
std::fs::rename(at.as_path(), moved.as_path()).expect("move the registration away");
std::fs::create_dir(at.as_path()).expect("replacement registration");
std::fs::write(at.join("gitdir"), b"/elsewhere/.git\n").expect("replacement gitdir");
}));
assert_eq!(run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../wt")), RemoveOutcome::LeftToGit);
assert_eq!(std::fs::read(admin.join("gitdir")).expect("the replacement registration is kept"), b"/elsewhere/.git\n");
assert!(worktree.join("tracked.txt").is_file(), "nothing was destroyed");
remove(root.as_path());
}
#[test]
fn a_registration_rewritten_after_the_proof_stops_the_removal() {
let root: PathBuf = fixture("remove-guard-rewritten-gitdir");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("wt").as_path(), "topic");
let stand: Stand = Stand::new(vec![worktree.clone()]);
let admin: PathBuf = admin_of(repo.as_path(), "wt");
let pointer: Vec<u8> = format!("{}\n", worktree.join(".git").display()).into_bytes();
*stand.after_proof.borrow_mut() = Some(Box::new(move || { std::fs::write(admin.join("gitdir"), [pointer.as_slice(), b" \n"].concat()).expect("rewrite"); }));
assert_eq!(run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../wt")), RemoveOutcome::LeftToGit);
assert!(worktree.join("tracked.txt").is_file(), "nothing was destroyed");
remove(root.as_path());
}
#[test]
fn an_administrative_directory_replaced_during_the_destroy_is_kept_and_reported() {
let root: PathBuf = fixture("remove-guard-swapped-admin-late");
let repo: PathBuf = repository_with_file(root.as_path(), "repo");
let worktree: PathBuf = clean_worktree(repo.as_path(), root.join("wt").as_path(), "topic");
let stand: Stand = Stand::new(vec![worktree.clone()]);
let admin: PathBuf = admin_of(repo.as_path(), "wt");
let moved: PathBuf = repo.join(".git/worktrees/wt.moved");
let at: PathBuf = admin.clone();
*stand.during_destroy.borrow_mut() = Some(Box::new(move || {
std::fs::rename(at.as_path(), moved.as_path()).expect("move the registration away");
std::fs::create_dir(at.as_path()).expect("replacement registration");
std::fs::write(at.join("gitdir"), b"/elsewhere/.git\n").expect("replacement gitdir");
}));
let outcome: RemoveOutcome = run(&stand, repo.as_path(), &plan_named(worktree.as_path(), "../wt"));
let RemoveOutcome::Incomplete(report) = outcome else { panic!("a registration that was not removed is not a success: {outcome:?}") };
assert!(report.contains(admin.to_str().expect("UTF-8")), "{report}");
assert_eq!(std::fs::read(admin.join("gitdir")).expect("the replacement registration is kept"), b"/elsewhere/.git\n");
assert!(repo.join(".git/worktrees/wt.moved/gitdir").is_file(), "the registration that was moved away is not emptied under its new name");
remove(root.as_path());
}