From d0064d5d60c5b59dbf63ce3c326291b4935bcb15 Mon Sep 17 00:00:00 2001 From: Joao Antunes Date: Wed, 25 Feb 2026 10:34:18 -0800 Subject: [PATCH] Support directory symlinks in profiles Allow profile installation to copy symlinked directories recursively instead of failing through std::fs::copy. Track copied files under the target directory in the lockfile and preserve overwrite semantics for owned directory entries during forced reinstall. Add regression tests covering initial install and forced reinstall for symlinked directories. --- src/profiles/installer.rs | 92 +++++++++++++++++++--- tests/profiles/test_orchestrator.rs | 118 ++++++++++++++++++++++++++++ 2 files changed, 200 insertions(+), 10 deletions(-) diff --git a/src/profiles/installer.rs b/src/profiles/installer.rs index 79f1a136..817c5a4f 100644 --- a/src/profiles/installer.rs +++ b/src/profiles/installer.rs @@ -5,6 +5,7 @@ use super::error::{ProfileError, ProfileResult}; use super::lockfile::ProfileLockfile; use std::path::{Path, PathBuf}; +use walkdir::WalkDir; /// Installation result: (installed files, sidecar files) /// - installed: List of relative file paths that were installed @@ -204,15 +205,19 @@ impl ProfileInstaller { true } None => { - // Unknown owner - if !force { + if path_owned_by_profile(lockfile, profile_name, file_path) { + // Directory path owned through tracked child files. + false + } else if !force { + // Unknown owner return Err(ProfileError::FileConflict { path: file_path.clone(), owner: "unknown".to_string(), }); + } else { + // Force enabled - use sidecar + true } - // Force enabled - use sidecar - true } } } else { @@ -233,15 +238,13 @@ impl ProfileInstaller { std::fs::create_dir_all(parent)?; } - // Copy file - std::fs::copy(&source_path, &final_path)?; + let copied_files = + copy_source_entry(&source_path, &final_path, Path::new(&relative_path))?; if use_sidecar { - sidecars.push((file_path.clone(), relative_path.clone())); - installed.push(relative_path); - } else { - installed.push(file_path.clone()); + sidecars.push((file_path.clone(), relative_path)); } + installed.extend(copied_files); } Ok((installed, sidecars)) @@ -253,3 +256,72 @@ impl Default for ProfileInstaller { Self::new() } } + +fn path_owned_by_profile(lockfile: &ProfileLockfile, profile_name: &str, path: &str) -> bool { + let Some(entry) = lockfile.get_profile(profile_name) else { + return false; + }; + + let prefix = format!("{}/", path.trim_end_matches('/')); + entry + .files + .iter() + .any(|tracked| tracked == path || tracked.starts_with(&prefix)) +} + +fn copy_source_entry( + source_path: &Path, + dest_path: &Path, + relative_base: &Path, +) -> ProfileResult> { + if source_path.is_dir() { + return copy_directory_contents(source_path, dest_path, relative_base); + } + + std::fs::copy(source_path, dest_path)?; + Ok(vec![normalize_path(relative_base)]) +} + +fn copy_directory_contents( + source_dir: &Path, + dest_dir: &Path, + relative_base: &Path, +) -> ProfileResult> { + let mut copied_files = Vec::new(); + std::fs::create_dir_all(dest_dir)?; + + for entry in WalkDir::new(source_dir).follow_links(true) { + let entry = entry.map_err(|e| ProfileError::IoError(std::io::Error::other(e)))?; + let entry_path = entry.path(); + let relative = entry_path + .strip_prefix(source_dir) + .expect("walkdir entry should be under source_dir"); + + if relative.as_os_str().is_empty() { + continue; + } + + if relative.components().any(|c| c.as_os_str() == ".git") { + continue; + } + + let destination = dest_dir.join(relative); + if entry.file_type().is_dir() { + std::fs::create_dir_all(&destination)?; + continue; + } + + if let Some(parent) = destination.parent() { + std::fs::create_dir_all(parent)?; + } + + std::fs::copy(entry_path, &destination)?; + copied_files.push(normalize_path(&relative_base.join(relative))); + } + + Ok(copied_files) +} + +fn normalize_path(path: &Path) -> String { + path.to_string_lossy().replace('\\', "/") +} diff --git a/tests/profiles/test_orchestrator.rs b/tests/profiles/test_orchestrator.rs index 3dba98e1..11200206 100644 --- a/tests/profiles/test_orchestrator.rs +++ b/tests/profiles/test_orchestrator.rs @@ -263,3 +263,121 @@ fn test_install_profile_conflict_creates_sidecar() { let content_b = fs::read_to_string(&sidecar_path).unwrap(); assert_eq!(content_b, "# Profile B"); } + +#[cfg(unix)] +#[test] +fn test_install_profile_supports_symlinked_directory() { + let temp = tempdir().unwrap(); + + let profiles_dir = temp.path().join("profiles"); + let profile_dir = profiles_dir.join("symlink-profile"); + fs::create_dir_all(profile_dir.join("skills")).unwrap(); + + fs::write( + profile_dir.join("profile.json"), + r#"{"name": "symlink-profile", "version": "1.0.0", "files": []}"#, + ) + .unwrap(); + + let shared_skill_dir = temp.path().join("shared-skills").join("sample-skill"); + fs::create_dir_all(&shared_skill_dir).unwrap(); + fs::write(shared_skill_dir.join("SKILL.md"), "# Sample Skill").unwrap(); + + std::os::unix::fs::symlink( + &shared_skill_dir, + profile_dir.join("skills").join("sample-skill"), + ) + .unwrap(); + + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + + install_profile( + "symlink-profile", + &profiles_dir, + &workspace, + false, + None, + None, + None, + ) + .unwrap(); + + let installed_skill = workspace.join("skills/sample-skill/SKILL.md"); + assert!(installed_skill.exists()); + assert_eq!( + fs::read_to_string(installed_skill).unwrap(), + "# Sample Skill" + ); + + let lockfile = ProfileLockfile::load(&workspace.join(".codanna/profiles.lock.json")).unwrap(); + let entry = lockfile.get_profile("symlink-profile").unwrap(); + assert!( + entry + .files + .contains(&"skills/sample-skill/SKILL.md".to_string()) + ); +} + +#[cfg(unix)] +#[test] +fn test_force_reinstall_symlinked_directory_overwrites_owned_files() { + let temp = tempdir().unwrap(); + + let profiles_dir = temp.path().join("profiles"); + let profile_dir = profiles_dir.join("symlink-profile"); + fs::create_dir_all(profile_dir.join("skills")).unwrap(); + + fs::write( + profile_dir.join("profile.json"), + r#"{"name": "symlink-profile", "version": "1.0.0", "files": []}"#, + ) + .unwrap(); + + let shared_skill_dir = temp.path().join("shared-skills").join("sample-skill"); + fs::create_dir_all(&shared_skill_dir).unwrap(); + let skill_file = shared_skill_dir.join("SKILL.md"); + fs::write(&skill_file, "# V1").unwrap(); + + std::os::unix::fs::symlink( + &shared_skill_dir, + profile_dir.join("skills").join("sample-skill"), + ) + .unwrap(); + + let workspace = temp.path().join("workspace"); + fs::create_dir_all(&workspace).unwrap(); + + install_profile( + "symlink-profile", + &profiles_dir, + &workspace, + false, + None, + None, + None, + ) + .unwrap(); + + fs::write(&skill_file, "# V2").unwrap(); + install_profile( + "symlink-profile", + &profiles_dir, + &workspace, + true, + None, + None, + None, + ) + .unwrap(); + + assert_eq!( + fs::read_to_string(workspace.join("skills/sample-skill/SKILL.md")).unwrap(), + "# V2" + ); + assert!( + !workspace + .join("skills/sample-skill.symlink-profile/SKILL.md") + .exists() + ); +}