//! Recursive removal of a linked worktree and its administrative directory. use std::{ convert::Infallible, fs, io, path::{Path, PathBuf}, sync::atomic::Ordering, }; use gix_features::{ parallel::{self, Reduce}, progress::{NestedProgress, Progress}, }; /// A failure while scanning or deleting one of the removal roots. #[derive(Debug, thiserror::Error)] #[error("Could not remove '{path}': {source}")] pub struct DirectoryError { /// The path whose scan or deletion failed. pub path: PathBuf, /// The underlying filesystem error. #[source] pub source: io::Error, } /// The error returned by [`remove()`]. /// #[derive(Debug, thiserror::Error)] pub enum Error { /// The checkout could not be fully removed, but its private Git directory was removed. #[error("Could not fully remove the linked-worktree checkout")] Worktree(#[source] DirectoryError), /// The private Git directory could not be fully removed, but its checkout was removed. #[error("Could not fully remove the linked-worktree administration")] GitDir(#[source] DirectoryError), /// Neither the checkout nor its private Git directory could be fully removed. #[error("Could not fully remove the checkout ({worktree}) or its administration ({git_dir})")] Both { /// The first checkout-removal error. worktree: DirectoryError, /// The first administrative-removal error. git_dir: DirectoryError, }, } /// Recursively remove `work_dir` and its private `git_dir` without following symbolic links. /// /// Traversal and leaf deletion use all available threads when the `parallel` feature is enabled. /// Traversal skips optional metadata, except for directory checks that protect Unix mount boundaries. /// The private Git directory is removed even if removing the checkout fails. A missing root is /// considered removed successfully, and an empty parent `worktrees` directory is removed as well. /// Directory-not-empty races are retried while each pass finds fewer entries, up to three passes. pub fn remove( work_dir: impl AsRef, git_dir: impl AsRef, mut progress: impl NestedProgress, ) -> Result<(), Error> { let work_dir = work_dir.as_ref(); let git_dir = git_dir.as_ref(); let worktree = remove_root( work_dir, progress.add_child("scan worktree"), progress.add_child("remove worktree"), ) .err(); let git_dir_error = remove_root( git_dir, progress.add_child("scan administration"), progress.add_child("remove administration"), ) .err(); if git_dir_error.is_none() && let Some(worktrees_dir) = git_dir .parent() .filter(|dir| dir.file_name().is_some_and(|name| name == "worktrees")) { // This is intentionally best-effort, like Git: a non-empty directory merely means other // linked worktrees remain. let _ = fs::remove_dir(worktrees_dir); } match (worktree, git_dir_error) { (None, None) => Ok(()), (Some(err), None) => Err(Error::Worktree(err)), (None, Some(err)) => Err(Error::GitDir(err)), (Some(worktree), Some(git_dir)) => Err(Error::Both { worktree, git_dir }), } } fn remove_root(root: &Path, scan: impl Progress, remove: impl Progress) -> Result<(), DirectoryError> { remove_root_with_after_scan(root, scan, remove, || {}) } fn remove_root_with_after_scan( root: &Path, mut scan: impl Progress, mut remove: impl Progress, mut after_scan: impl FnMut(), ) -> Result<(), DirectoryError> { let root = absolute_root(root)?; let root = root.as_path(); scan.init(None, gix_features::progress::count("entries")); #[cfg(unix)] let containing_device = root .parent() .and_then(|parent| fs::metadata(parent).ok()) .map(|metadata| std::os::unix::fs::MetadataExt::dev(&metadata)); #[cfg(unix)] let root_is_filesystem_root = root.parent().is_none(); #[cfg(unix)] let descend = move |entry: &dua_core::Entry| { // Failed checks are reported when entries are collected below. may_descend(containing_device, root_is_filesystem_root, entry).unwrap_or(false) }; #[cfg(not(unix))] let descend = |_: &dua_core::Entry| true; let mut walk = dua_core::walk( root, parallel::num_threads(None), dua_core::Order::Completion, dua_core::Options::default().skip_metadata(), descend, ); let mut previous_entry_count = None; for attempt in 0..3 { let mut leaves = Vec::new(); let mut directories = Vec::new(); #[cfg(unix)] let mut retained_mounts = Vec::new(); let mut first_error = None; let mut entry_count = 0; for entry in walk.by_ref() { entry_count += 1; scan.inc(); match entry { Ok(entry) => { let path = entry.path(); if entry.file_type.is_dir() { #[cfg(unix)] match may_descend(containing_device, root_is_filesystem_root, &entry) { Ok(true) => {} Ok(false) => retained_mounts.push(path.clone()), Err(source) => { if !gix_fs::io_err::is_not_found(source.kind(), source.raw_os_error()) { first_error.get_or_insert(DirectoryError { path, source }); } continue; } } directories.push((entry.depth, path)); } else { leaves.push((path, entry.file_type.is_symlink())); } } Err(source) if gix_fs::io_err::is_not_found(source.kind(), source.raw_os_error()) => {} Err(source) => { first_error.get_or_insert_with(|| DirectoryError { path: root.to_owned(), source, }); } } } after_scan(); remove.init( Some(leaves.len() + directories.len()), gix_features::progress::count("entries"), ); let counter = remove.counter(); let leaf_error = parallel::in_parallel( leaves.into_iter(), None, |_| (), { let counter = counter.clone(); move |(path, is_symlink), _| { let result = remove_leaf(&path, is_symlink); counter.fetch_add(1, Ordering::Relaxed); result .err() .filter(|err| !gix_fs::io_err::is_not_found(err.kind(), err.raw_os_error())) .map(|source| DirectoryError { path, source }) } }, FirstError::default(), ) .unwrap_or_else(|never| match never {}); if first_error.is_none() { first_error = leaf_error; } directories.sort_unstable_by_key(|(depth, _)| std::cmp::Reverse(*depth)); for (_, path) in directories { #[cfg(unix)] if let Some(mount) = retained_mount(&path, &retained_mounts) { // ponytail: mount points are rare; index their ancestors if this scan ever becomes measurable. if first_error.is_none() && mount == path { first_error = Some(DirectoryError { path: path.clone(), source: io::Error::other("refusing to remove a mounted filesystem"), }); } remove.inc(); continue; } let result = fs::remove_dir(&path); remove.inc(); if first_error.is_none() && let Err(source) = result && !gix_fs::io_err::is_not_found(source.kind(), source.raw_os_error()) { first_error = Some(DirectoryError { path, source }); } } let Some(err) = first_error else { return Ok(()); }; if err.source.kind() != io::ErrorKind::DirectoryNotEmpty || attempt == 2 || previous_entry_count.is_some_and(|previous| entry_count >= previous) || !walk.restart() { return Err(err); } previous_entry_count = Some(entry_count); } unreachable!("the final deletion attempt always returns") } fn absolute_root(root: &Path) -> Result { // Trailing separators make `symlink_metadata()` follow the final symlink. let absolute: PathBuf = std::path::absolute(root) .map(|path| path.components().collect()) .map_err(|source| DirectoryError { path: root.to_owned(), source, })?; let resolved = match fs::symlink_metadata(&absolute) { // Keep file and symlink leaves intact; only directories need root and mount checks. Ok(metadata) if !metadata.is_dir() => return Ok(absolute), Ok(_) => fs::canonicalize(&absolute), Err(source) => Err(source), }; match resolved { Ok(path) => Ok(path), Err(source) if gix_fs::io_err::is_not_found(source.kind(), source.raw_os_error()) => Ok(absolute), Err(source) => Err(DirectoryError { path: absolute, source }), } } fn remove_leaf(path: &Path, is_symlink: bool) -> io::Result<()> { let result = if is_symlink { gix_fs::symlink::remove(path) } else { fs::remove_file(path) }; #[cfg(windows)] if !is_symlink && result .as_ref() .is_err_and(|err| err.kind() == io::ErrorKind::PermissionDenied) { let mut permissions = fs::metadata(path)?.permissions(); permissions.set_readonly(false); fs::set_permissions(path, permissions)?; return fs::remove_file(path); } result } #[cfg(unix)] fn may_descend( containing_device: Option, root_is_filesystem_root: bool, entry: &dua_core::Entry, ) -> io::Result { if root_is_filesystem_root { return Ok(false); } let Some(containing_device) = containing_device else { return Ok(true); }; fs::symlink_metadata(entry.path()) .map(|metadata| std::os::unix::fs::MetadataExt::dev(&metadata) == containing_device) } #[cfg(unix)] fn retained_mount<'a>(path: &Path, mounts: &'a [PathBuf]) -> Option<&'a Path> { mounts .iter() .find(|mount| mount.starts_with(path)) .map(PathBuf::as_path) } #[derive(Default)] struct FirstError(Option); impl Reduce for FirstError { type Input = Option; type FeedProduce = (); type Output = Option; type Error = Infallible; fn feed(&mut self, error: Self::Input) -> Result { if self.0.is_none() { self.0 = error; } Ok(()) } fn finalize(self) -> Result { Ok(self.0) } } #[cfg(test)] mod tests { #[cfg(unix)] #[test] fn relative_roots_are_normalized_before_finding_the_containing_device() { let root = super::absolute_root(std::path::Path::new("one-component")) .expect("the current directory can be made absolute"); assert!(root.is_absolute()); assert!( root.parent() .and_then(|parent| std::fs::metadata(parent).ok()) .is_some(), "a relative root still has a containing filesystem" ); } #[cfg(unix)] #[test] fn mount_boundaries_are_checked_without_traversal_metadata() -> std::io::Result<()> { let tmp = gix_testtools::tempfile::tempdir()?; let entry = dua_core::Entry::from_path(tmp.path(), dua_core::Options::default().skip_metadata())?; assert!(entry.metadata.is_none(), "removal walks omit entry metadata"); let device = std::os::unix::fs::MetadataExt::dev(&std::fs::symlink_metadata(tmp.path())?); assert!( super::may_descend(Some(device), false, &entry)?, "the same filesystem is traversed" ); assert!( !super::may_descend(Some(device.wrapping_add(1)), false, &entry)?, "mounted filesystems are not traversed" ); let mount = tmp.path().join("checkout/mount"); assert_eq!( super::retained_mount(&mount, std::slice::from_ref(&mount)), Some(mount.as_path()), "the mount itself records the removal error" ); assert_eq!( super::retained_mount(tmp.path(), std::slice::from_ref(&mount)), Some(mount.as_path()), "mount ancestors are retained too" ); tmp.close()?; assert_eq!( super::may_descend(Some(device), false, &entry) .expect_err("a failed metadata check cannot allow descent") .kind(), std::io::ErrorKind::NotFound, "directory metadata errors remain available to the removal loop" ); Ok(()) } #[cfg(unix)] #[test] fn filesystem_root_aliases_are_not_traversed() -> gix_testtools::Result { let tmp = gix_testtools::tempfile::tempdir()?; let root_link = tmp.path().join("root-link"); gix_fs::symlink::create(std::path::Path::new("/"), &root_link)?; // Only inspect descent decisions; never invoke removal on the filesystem root. for root in [ std::path::PathBuf::from("/"), std::path::PathBuf::from("/.."), root_link.join(".."), ] { let root = super::absolute_root(&root)?; let entry = dua_core::Entry::from_path(&root, dua_core::Options::default().skip_metadata())?; let device = std::os::unix::fs::MetadataExt::dev(&std::fs::symlink_metadata(&root)?); assert!( !super::may_descend(Some(device), root.parent().is_none(), &entry)?, "the filesystem root cannot be traversed via {}", root.display() ); } Ok(()) } #[test] fn retries_when_an_entry_appears_after_scanning() { let tmp = gix_testtools::tempfile::tempdir().expect("temporary directory can be created"); let root = tmp.path().join("worktree"); std::fs::create_dir(&root).expect("removal root can be created"); let mut scans = 0; super::remove_root_with_after_scan( &root, gix_features::progress::Discard, gix_features::progress::Discard, || { scans += 1; if scans == 1 { std::fs::write(root.join("late"), b"late").expect("late file can be created"); } }, ) .expect("a later pass removes the late file"); assert_eq!(scans, 2, "one retry was needed"); assert!(!root.exists(), "the root was removed"); } #[test] fn stops_retrying_if_the_workload_does_not_shrink() { for equal_size in [false, true] { let tmp = gix_testtools::tempfile::tempdir().expect("temporary directory can be created"); let root = tmp.path().join("worktree"); std::fs::create_dir(&root).expect("removal root can be created"); let root = std::fs::canonicalize(&root).expect("the removal root exists"); if equal_size { std::fs::write(root.join("initial"), b"initial").expect("initial file can be created"); } else { std::fs::create_dir(root.join("nested")).expect("initial directory can be created"); } let mut scans = 0; let err = super::remove_root_with_after_scan( &root, gix_features::progress::Discard, gix_features::progress::Discard, || { let parent = if !equal_size && scans == 0 { root.join("nested") } else { root.clone() }; std::fs::write(parent.join(format!("late-{scans}")), b"late").expect("late file can be created"); scans += 1; }, ) .expect_err("a non-shrinking root is left to the concurrent writer"); assert_eq!(err.source.kind(), std::io::ErrorKind::DirectoryNotEmpty); assert_eq!(err.path, root, "the latest attempt's error is returned"); assert_eq!(scans, 2, "an equal or growing workload stops after one retry"); } } #[test] fn stops_after_three_improving_deletion_passes() { let tmp = gix_testtools::tempfile::tempdir().expect("temporary directory can be created"); let root = tmp.path().join("worktree"); std::fs::create_dir(&root).expect("removal root can be created"); for idx in 0..4 { std::fs::write(root.join(format!("initial-{idx}")), b"initial").expect("initial file can be created"); } let mut scans = 0; let err = super::remove_root_with_after_scan( &root, gix_features::progress::Discard, gix_features::progress::Discard, || { match scans { 0 => std::fs::create_dir_all(root.join("late/child")).expect("late directories can be created"), 1 => std::fs::write(root.join("late-file"), b"late").expect("late file can be created"), 2 => std::fs::write(root.join("last-file"), b"late").expect("last file can be created"), _ => unreachable!("only three scans are allowed"), } scans += 1; }, ) .expect_err("three passes cap retries despite continued progress"); assert_eq!(err.source.kind(), std::io::ErrorKind::DirectoryNotEmpty); assert_eq!(scans, 3, "deletion is capped at three passes"); } }