use anyhow::Result;
use ignore::gitignore::GitignoreBuilder;
use std::fs;
use std::path::Path;
use walkdir::WalkDir;
pub fn remove_symlinks(dir: &Path) -> Result<()> {
for entry in WalkDir::new(dir)
.min_depth(1)
.into_iter()
.filter_map(Result::ok)
{
if entry.file_type().is_symlink() {
fs::remove_file(entry.path())?;
}
}
Ok(())
}
pub fn delete_gitignored_files(repo_path: &Path, patterns: &[String]) -> Result<()> {
let mut builder = GitignoreBuilder::new(repo_path);
for pattern in patterns {
if let Err(error) = builder.add_line(None, pattern) {
tracing::warn!(pattern, error = %error, "skipping an exclusion git cannot compile");
}
}
let gitignore = builder.build()?;
for entry in WalkDir::new(repo_path) {
let entry = entry?;
let path = entry.path();
let Ok(rel) = path.strip_prefix(repo_path) else {
continue;
};
let rel_str = rel.to_string_lossy();
if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
continue;
}
if rel_str.is_empty() {
continue;
}
if !entry.file_type().is_dir()
&& gitignore
.matched_path_or_any_parents(rel, false)
.is_ignore()
{
let _ = fs::remove_file(path);
}
}
remove_empty_dirs(repo_path)?;
Ok(())
}
pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
fs::create_dir_all(dst)?;
for entry in fs::read_dir(src)? {
let entry = entry?;
let ty = entry.file_type()?;
let dst_path = dst.join(entry.file_name());
if ty.is_dir() {
copy_dir_all(&entry.path(), &dst_path)?;
} else {
fs::copy(entry.path(), &dst_path)?;
copy_modified(&entry.path(), &dst_path);
}
}
Ok(())
}
fn copy_modified(src: &Path, dst: &Path) {
let Ok(modified) = fs::metadata(src).and_then(|meta| meta.modified()) else {
return;
};
if let Ok(file) = fs::File::open(dst) {
let _ = file.set_modified(modified);
}
}
fn remove_empty_dirs(root: &Path) -> Result<()> {
let mut dirs: Vec<_> = Vec::new();
for entry in WalkDir::new(root) {
let entry = entry?;
let path = entry.path();
let Ok(rel) = path.strip_prefix(root) else {
continue;
};
let rel_str = rel.to_string_lossy();
if rel_str == ".git" || rel_str.starts_with(".git/") || rel_str.starts_with(".git\\") {
continue;
}
if entry.file_type().is_dir() && path != root {
dirs.push(path.to_path_buf());
}
}
dirs.sort();
dirs.reverse();
for dir in dirs {
if let Ok(entries) = fs::read_dir(&dir) {
if entries.count() == 0 {
let _ = fs::remove_dir(&dir);
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::{copy_dir_all, delete_gitignored_files, remove_symlinks};
use std::time::{Duration, SystemTime};
#[test]
fn a_directory_pattern_deletes_what_is_under_it() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::create_dir_all(repo.join("docs/sub")).unwrap();
std::fs::create_dir_all(repo.join(".git")).unwrap();
std::fs::write(repo.join("docs/a.md"), b"out").unwrap();
std::fs::write(repo.join("docs/sub/b.md"), b"out").unwrap();
std::fs::write(repo.join("keep.py"), b"in").unwrap();
std::fs::write(repo.join(".git/config"), b"[core]").unwrap();
delete_gitignored_files(repo, &["docs".to_owned()]).unwrap();
assert!(!repo.join("docs/a.md").exists());
assert!(!repo.join("docs/sub/b.md").exists());
assert!(repo.join("keep.py").exists());
assert!(repo.join(".git/config").exists());
}
#[test]
fn an_exclusion_git_cannot_compile_does_not_disable_the_others() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
std::fs::create_dir_all(repo.join("docs")).unwrap();
std::fs::write(repo.join("docs/a.md"), b"out").unwrap();
std::fs::write(repo.join("keep.py"), b"in").unwrap();
delete_gitignored_files(repo, &["docs".to_owned(), "src/{main".to_owned()]).unwrap();
assert!(!repo.join("docs/a.md").exists());
assert!(repo.join("keep.py").exists());
}
#[test]
fn a_copied_tree_keeps_the_mode_and_the_modification_time() {
let dir = tempfile::tempdir().unwrap();
let src = dir.path().join("src/objects");
std::fs::create_dir_all(&src).unwrap();
let object = src.join("a1b2c3");
std::fs::write(&object, b"blob").unwrap();
let modified = SystemTime::UNIX_EPOCH
.checked_add(Duration::from_secs(1_700_000_000))
.unwrap();
std::fs::File::open(&object)
.unwrap()
.set_modified(modified)
.unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&object, std::fs::Permissions::from_mode(0o444)).unwrap();
}
let dst = dir.path().join("dst");
copy_dir_all(&dir.path().join("src"), &dst).unwrap();
let copied = dst.join("objects/a1b2c3");
let meta = std::fs::metadata(&copied).unwrap();
assert_eq!(meta.modified().unwrap(), modified);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
assert_eq!(meta.permissions().mode() & 0o777, 0o444);
}
}
#[test]
#[cfg(unix)]
fn an_unreadable_directory_does_not_stop_the_removal() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let target = dir.path().join("target.txt");
std::fs::write(&target, b"content").unwrap();
let blocked = dir.path().join("b_blocked");
std::fs::create_dir(&blocked).unwrap();
let late = dir.path().join("c_late.lnk");
std::os::unix::fs::symlink(&target, &late).unwrap();
std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o000)).unwrap();
remove_symlinks(dir.path()).unwrap();
std::fs::set_permissions(&blocked, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(!late.is_symlink());
assert!(target.exists());
}
#[test]
#[cfg(unix)]
fn a_symlink_that_cannot_be_removed_is_reported() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::tempdir().unwrap();
let sealed = dir.path().join("sealed");
std::fs::create_dir(&sealed).unwrap();
let link = sealed.join("link.lnk");
std::os::unix::fs::symlink(dir.path().join("target.txt"), &link).unwrap();
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o500)).unwrap();
if std::fs::write(sealed.join("probe"), b"").is_ok() {
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
return;
}
let result = remove_symlinks(dir.path());
std::fs::set_permissions(&sealed, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(result.is_err());
assert!(link.is_symlink());
}
#[test]
#[cfg(unix)]
fn the_directory_itself_is_never_removed() {
let dir = tempfile::tempdir().unwrap();
let real = dir.path().join("real");
std::fs::create_dir(&real).unwrap();
let alias = dir.path().join("alias");
std::os::unix::fs::symlink(&real, &alias).unwrap();
remove_symlinks(&alias).unwrap();
assert!(alias.is_symlink());
}
}