use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use repo::Repository;
const ADMIN_DIRS: &[&str] = &[".git", ".heddle"];
pub(crate) fn hydratable_ignored_dirs(repo: &Repository) -> Result<Vec<PathBuf>> {
let patterns = repo.ignore_patterns()?;
let root = repo.root();
let read = std::fs::read_dir(root)
.with_context(|| format!("read origin checkout root '{}'", root.display()))?;
let mut dirs = Vec::new();
for entry in read {
let entry = entry?;
let name = entry.file_name();
let name_str = name.to_string_lossy();
if ADMIN_DIRS.contains(&name_str.as_ref()) {
continue;
}
let file_type = entry.file_type()?;
let is_dir_like = file_type.is_dir() || (file_type.is_symlink() && entry.path().is_dir());
if !is_dir_like {
continue;
}
if objects::worktree::should_ignore(Path::new(name_str.as_ref()), &patterns) {
dirs.push(entry.path());
}
}
dirs.sort();
Ok(dirs)
}
pub(crate) fn plan_link(checkout: &Path, source: &Path) -> Option<(PathBuf, String)> {
let name = source.file_name()?;
let dest = checkout.join(name);
if dest.symlink_metadata().is_ok() {
return None;
}
Some((dest, name.to_string_lossy().into_owned()))
}
pub(crate) fn create_symlink(source: &Path, dest: &Path) -> Result<()> {
symlink_dir(source, dest)
.with_context(|| format!("hydrate '{}' -> '{}'", dest.display(), source.display()))
}
pub(crate) fn unlink_hydrated(checkout: &Path, name: &str) -> std::io::Result<()> {
match remove_symlink_dir(&checkout.join(name)) {
Ok(()) => Ok(()),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(err) => Err(err),
}
}
#[cfg(not(windows))]
fn remove_symlink_dir(link: &Path) -> std::io::Result<()> {
std::fs::remove_file(link)
}
#[cfg(windows)]
fn remove_symlink_dir(link: &Path) -> std::io::Result<()> {
std::fs::remove_dir(link)
}
pub(crate) fn hydrate_exclude_path(checkout: &Path) -> PathBuf {
checkout.join(".heddle").join("info").join("exclude")
}
pub(crate) fn preserve_hydrated_ignores(checkout: &Path, linked: &[String]) -> Result<()> {
if linked.is_empty() {
return Ok(());
}
let read_opt = |path: &Path| -> Result<Option<String>> {
match std::fs::read_to_string(path) {
Ok(contents) => Ok(Some(contents)),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => Err(err).with_context(|| format!("read '{}'", path.display())),
}
};
let active_patterns = |contents: &Option<String>| -> Vec<String> {
contents
.iter()
.flat_map(|c| c.lines())
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_string)
.collect()
};
let tracked = read_opt(&checkout.join(".heddleignore"))?;
let exclude_path = hydrate_exclude_path(checkout);
let existing_exclude = read_opt(&exclude_path)?;
let mut covered: Vec<String> = active_patterns(&tracked);
covered.extend(active_patterns(&existing_exclude));
let mut to_add: Vec<String> = Vec::new();
for name in linked {
let mut probe = covered.clone();
probe.extend(to_add.iter().cloned());
if objects::worktree::should_ignore(Path::new(name), &probe) {
continue;
}
to_add.push(format!("{name}/"));
}
if to_add.is_empty() {
return Ok(());
}
if let Some(parent) = exclude_path.parent() {
std::fs::create_dir_all(parent)
.with_context(|| format!("create '{}'", parent.display()))?;
}
let mut out = existing_exclude.unwrap_or_default();
if !out.is_empty() && !out.ends_with('\n') {
out.push('\n');
}
for line in &to_add {
out.push_str(line);
out.push('\n');
}
std::fs::write(&exclude_path, out)
.with_context(|| format!("write hydrate ignore rules to '{}'", exclude_path.display()))?;
Ok(())
}
#[cfg(unix)]
fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
objects::fault_inject::maybe_fail_at("hydrate_symlink_dir")?;
std::os::unix::fs::symlink(target, link)
}
#[cfg(not(unix))]
fn symlink_dir(target: &Path, link: &Path) -> std::io::Result<()> {
objects::fault_inject::maybe_fail_at("hydrate_symlink_dir")?;
std::os::windows::fs::symlink_dir(target, link)
}
#[cfg(test)]
mod tests {
use tempfile::TempDir;
use super::*;
fn link_one(checkout: &Path, source: &Path) -> Option<String> {
let (dest, name) = plan_link(checkout, source)?;
create_symlink(source, &dest).unwrap();
Some(name)
}
#[test]
fn plan_link_links_source_and_skips_existing() {
let temp = TempDir::new().unwrap();
let origin = temp.path().join("origin");
let checkout = temp.path().join("checkout");
std::fs::create_dir_all(origin.join("node_modules")).unwrap();
std::fs::create_dir_all(origin.join(".venv")).unwrap();
std::fs::create_dir_all(&checkout).unwrap();
std::fs::create_dir_all(checkout.join(".venv")).unwrap();
let linked_nm = link_one(&checkout, &origin.join("node_modules"));
let skipped_venv = plan_link(&checkout, &origin.join(".venv"));
assert_eq!(linked_nm.as_deref(), Some("node_modules"));
assert!(skipped_venv.is_none(), "a colliding source links nothing");
let nm = checkout.join("node_modules");
assert!(
std::fs::symlink_metadata(&nm)
.unwrap()
.file_type()
.is_symlink(),
"node_modules should be a symlink"
);
assert_eq!(
std::fs::read_link(&nm).unwrap(),
origin.join("node_modules")
);
assert!(
!std::fs::symlink_metadata(checkout.join(".venv"))
.unwrap()
.file_type()
.is_symlink(),
"pre-existing .venv must be preserved, not replaced by a link"
);
}
#[test]
fn unlink_hydrated_removes_link_and_is_idempotent() {
let temp = TempDir::new().unwrap();
let origin = temp.path().join("origin");
let checkout = temp.path().join("checkout");
std::fs::create_dir_all(origin.join("node_modules")).unwrap();
std::fs::create_dir_all(&checkout).unwrap();
link_one(&checkout, &origin.join("node_modules")).unwrap();
assert!(std::fs::symlink_metadata(checkout.join("node_modules")).is_ok());
unlink_hydrated(&checkout, "node_modules").unwrap();
assert!(
std::fs::symlink_metadata(checkout.join("node_modules")).is_err(),
"the hydrate link must be unlinked"
);
assert!(origin.join("node_modules").is_dir());
unlink_hydrated(&checkout, "node_modules").unwrap();
}
#[cfg(windows)]
#[test]
fn unlink_hydrated_removes_directory_symlink_on_windows() {
let temp = TempDir::new().unwrap();
let origin = temp.path().join("origin");
let checkout = temp.path().join("checkout");
std::fs::create_dir_all(origin.join("node_modules")).unwrap();
std::fs::create_dir_all(&checkout).unwrap();
symlink_dir(&origin.join("node_modules"), &checkout.join("node_modules")).unwrap();
assert!(std::fs::symlink_metadata(checkout.join("node_modules")).is_ok());
unlink_hydrated(&checkout, "node_modules").unwrap();
assert!(
std::fs::symlink_metadata(checkout.join("node_modules")).is_err(),
"a directory symlink must be removed on rollback (remove_dir, not remove_file)"
);
assert!(
origin.join("node_modules").is_dir(),
"the link target must survive"
);
}
}