use std::collections::HashSet;
use std::path::{Path, PathBuf};
use crate::error::{Error, Result};
pub fn is_supported(dir: &Path) -> bool {
let src = dir.join(".wt-reflink-probe");
let dst = dir.join(".wt-reflink-probe-clone");
let cleanup = || {
let _ = std::fs::remove_file(&src);
let _ = std::fs::remove_file(&dst);
};
cleanup();
if std::fs::write(&src, b"wt").is_err() {
cleanup();
return false;
}
let ok = reflink_copy::reflink(&src, &dst).is_ok();
cleanup();
ok
}
pub fn clone_file(src: &Path, dst: &Path) -> Result<()> {
if let Some(parent) = dst.parent() {
std::fs::create_dir_all(parent)?;
}
reflink_copy::reflink(src, dst).map_err(|e| {
Error::operation(format!(
"could not reflink {} to {}: {e}",
src.display(),
dst.display()
))
})
}
pub fn clone_tree(src: &Path, dst: &Path, skip: &HashSet<PathBuf>) -> Result<()> {
clone_into(src, dst, Path::new(""), skip)
}
fn clone_into(src: &Path, dst: &Path, rel: &Path, skip: &HashSet<PathBuf>) -> Result<()> {
std::fs::create_dir_all(dst)?;
for entry in std::fs::read_dir(src)? {
let entry = entry?;
let name = entry.file_name();
let child_rel = rel.join(&name);
if skip.contains(&child_rel) {
continue;
}
let from = entry.path();
let to = dst.join(&name);
let file_type = entry.file_type()?;
if file_type.is_symlink() {
let target = std::fs::read_link(&from)?;
symlink(&target, &to)?;
} else if file_type.is_dir() {
clone_into(&from, &to, &child_rel, skip)?;
} else if file_type.is_file() {
clone_file(&from, &to)?;
}
}
Ok(())
}
#[cfg(unix)]
fn symlink(target: &Path, link: &Path) -> Result<()> {
std::os::unix::fs::symlink(target, link)?;
Ok(())
}
#[cfg(windows)]
fn symlink(target: &Path, link: &Path) -> Result<()> {
if target.is_dir() {
std::os::windows::fs::symlink_dir(target, link)?;
} else {
std::os::windows::fs::symlink_file(target, link)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn cow_dir() -> Option<tempfile::TempDir> {
if let Ok(dir) = tempfile::tempdir()
&& is_supported(dir.path())
{
return Some(dir);
}
let target = Path::new(env!("CARGO_MANIFEST_DIR")).join("target");
std::fs::create_dir_all(&target).ok()?;
let dir = tempfile::tempdir_in(&target).ok()?;
is_supported(dir.path()).then_some(dir)
}
#[test]
fn probe_leaves_no_files_behind() {
let dir = tempfile::tempdir().unwrap();
let _ = is_supported(dir.path());
assert_eq!(std::fs::read_dir(dir.path()).unwrap().count(), 0);
}
#[test]
fn probe_is_false_for_a_missing_directory() {
assert!(!is_supported(Path::new("/definitely/not/here")));
}
#[test]
fn clone_file_reports_an_error_instead_of_copying() {
let dir = tempfile::tempdir().unwrap();
let err = clone_file(&dir.path().join("nope"), &dir.path().join("out")).unwrap_err();
assert!(matches!(err, Error::Operation(_)));
}
#[test]
fn clones_a_tree_with_contents_symlinks_and_skips() {
let Some(dir) = cow_dir() else {
return;
};
let src = dir.path().join("src");
std::fs::create_dir_all(src.join("nested")).unwrap();
std::fs::create_dir_all(src.join(".git")).unwrap();
std::fs::write(src.join("a.txt"), "alpha").unwrap();
std::fs::write(src.join("nested/b.txt"), "beta").unwrap();
std::fs::write(src.join(".git/config"), "secret").unwrap();
#[cfg(unix)]
std::os::unix::fs::symlink("a.txt", src.join("link")).unwrap();
let dst = dir.path().join("dst");
clone_tree(&src, &dst, &HashSet::from([PathBuf::from(".git")])).unwrap();
assert_eq!(std::fs::read_to_string(dst.join("a.txt")).unwrap(), "alpha");
assert_eq!(
std::fs::read_to_string(dst.join("nested/b.txt")).unwrap(),
"beta"
);
assert!(!dst.join(".git").exists(), "skip list was not honoured");
#[cfg(unix)]
{
let meta = std::fs::symlink_metadata(dst.join("link")).unwrap();
assert!(meta.file_type().is_symlink(), "symlink was followed");
assert_eq!(
std::fs::read_link(dst.join("link")).unwrap(),
Path::new("a.txt")
);
}
}
#[test]
fn cloned_files_do_not_share_later_writes() {
let Some(dir) = cow_dir() else {
return;
};
let src = dir.path().join("one.txt");
let dst = dir.path().join("two.txt");
std::fs::write(&src, "original").unwrap();
clone_file(&src, &dst).unwrap();
std::fs::write(&dst, "changed").unwrap();
assert_eq!(std::fs::read_to_string(&src).unwrap(), "original");
}
#[test]
fn skips_nested_paths_and_whole_subtrees() {
let Some(dir) = cow_dir() else {
return;
};
let src = dir.path().join("src");
std::fs::create_dir_all(src.join("keep")).unwrap();
std::fs::create_dir_all(src.join("drop/deep")).unwrap();
std::fs::write(src.join("keep/yes.txt"), "yes").unwrap();
std::fs::write(src.join("keep/no.txt"), "no").unwrap();
std::fs::write(src.join("drop/deep/gone.txt"), "gone").unwrap();
let dst = dir.path().join("dst");
let skip = HashSet::from([PathBuf::from("keep/no.txt"), PathBuf::from("drop")]);
clone_tree(&src, &dst, &skip).unwrap();
assert!(dst.join("keep/yes.txt").exists());
assert!(
!dst.join("keep/no.txt").exists(),
"a nested skip was ignored"
);
assert!(!dst.join("drop").exists(), "a skipped subtree was cloned");
}
#[test]
fn skip_list_applies_only_at_the_top_level() {
let Some(dir) = cow_dir() else {
return;
};
let src = dir.path().join("src");
std::fs::create_dir_all(src.join("sub/.git")).unwrap();
std::fs::write(src.join("sub/.git/keep"), "nested").unwrap();
let dst = dir.path().join("dst");
clone_tree(&src, &dst, &HashSet::from([PathBuf::from(".git")])).unwrap();
assert!(dst.join("sub/.git/keep").exists());
}
}