From 603c9c9ad62dd8b6f4ddc31f23660e7e6b4bb898 Mon Sep 17 00:00:00 2001 From: Joao Antunes Date: Wed, 25 Feb 2026 11:40:09 -0800 Subject: [PATCH 1/3] Fix symlinked directory profile install Support profile installs when entries resolve to symlinked directories and track copied nested files in the lockfile. Also avoid a panic in walkdir path handling by returning a structured IO error, keeping failures recoverable. Adds regression tests for install and forced reinstall behavior. --- src/profiles/installer.rs | 96 +++++++++++++++++++--- tests/profiles/test_orchestrator.rs | 118 ++++++++++++++++++++++++++++ 2 files changed, 204 insertions(+), 10 deletions(-) diff --git a/src/profiles/installer.rs b/src/profiles/installer.rs index 79f1a136..a65adc47 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,76 @@ 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).map_err(|_| { + ProfileError::IoError(std::io::Error::other(format!( + "walkdir entry '{}' is outside source '{}'", + entry_path.display(), + source_dir.display() + ))) + })?; + + 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() + ); +} From 715724550c97e1f32d4a54f53d2699ffbcb98a87 Mon Sep 17 00:00:00 2001 From: Joao Antunes Date: Wed, 25 Feb 2026 12:53:12 -0800 Subject: [PATCH 2/3] Bump patch version to 0.9.15 Updates crate version from 0.9.14 to 0.9.15 in Cargo.toml and\nCargo.lock so this branch and its PR carry the next patch release. --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b61e8353..5c31d241 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -747,7 +747,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "codanna" -version = "0.9.14" +version = "0.9.15" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 25800e78..43a9d6b6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codanna" -version = "0.9.14" +version = "0.9.15" authors = ["Angel Bartolli "] edition = "2024" description = "Code Intelligence for Large Language Models" From 5fc5d07db2e7d0b491766aa9bf58fe284f338591 Mon Sep 17 00:00:00 2001 From: Angel Bartolli Date: Sat, 14 Mar 2026 19:39:29 -0400 Subject: [PATCH 3/3] fix: revert version bump, add path_owned_by_profile to check_all_conflicts --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/profiles/installer.rs | 4 ++++ 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5c31d241..b61e8353 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -747,7 +747,7 @@ checksum = "a1d728cc89cf3aee9ff92b05e62b19ee65a02b5702cff7d5a377e32c6ae29d8d" [[package]] name = "codanna" -version = "0.9.15" +version = "0.9.14" dependencies = [ "anyhow", "async-trait", diff --git a/Cargo.toml b/Cargo.toml index 43a9d6b6..25800e78 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codanna" -version = "0.9.15" +version = "0.9.14" authors = ["Angel Bartolli "] edition = "2024" description = "Code Intelligence for Large Language Models" diff --git a/src/profiles/installer.rs b/src/profiles/installer.rs index a65adc47..501a0eb8 100644 --- a/src/profiles/installer.rs +++ b/src/profiles/installer.rs @@ -79,6 +79,10 @@ pub fn check_all_conflicts( // Force enabled - will use sidecar } None => { + if path_owned_by_profile(lockfile, profile_name, file_path) { + // Directory path owned through tracked child files + continue; + } // File exists but unknown owner (user's file or orphaned) if !force { conflicts.push((file_path.clone(), "unknown".to_string()));