From 3eeb41b77c2a12d4eea69899e9b4bd02fabf2c92 Mon Sep 17 00:00:00 2001 From: elkowar Date: Sun, 5 Jan 2025 20:12:53 +0100 Subject: [PATCH 1/3] wip --- src/yolk.rs | 29 +++++++++++++++++++++++------ 1 file changed, 23 insertions(+), 6 deletions(-) diff --git a/src/yolk.rs b/src/yolk.rs index 008e613..78ec70d 100644 --- a/src/yolk.rs +++ b/src/yolk.rs @@ -38,16 +38,17 @@ impl Yolk { #[tracing::instrument(skip_all, fields(egg = ?egg.name()))] fn deploy_egg( &self, + created_symlinks: &mut Vec<(PathBuf, PathBuf)>, egg: &Egg, mappings: &HashMap, ) -> Result<(), MultiError> { let mut errs = Vec::new(); for (in_egg, deployed) in mappings { - let deploy_mapping = || -> miette::Result<()> { + let mut deploy_mapping = || -> miette::Result<()> { match egg.config().strategy { DeploymentStrategy::Merge => { cov_mark::hit!(deploy_merge); - symlink_recursive(egg.path(), in_egg, &deployed)?; + symlink_recursive(created_symlinks, egg.path(), in_egg, &deployed)?; } DeploymentStrategy::Put => { cov_mark::hit!(deploy_put); @@ -302,6 +303,8 @@ impl Yolk { /// Set up a symlink from the given `link_path` to the given `actual_path`, recursively. /// Also takes the `egg_root` dir, to ensure we can safely delete any stale symlinks on the way there. /// +/// Also takes a mutable list that new (actual_path, link_path) pairs will be added to when created or encountered. +/// /// Requires all paths to be absolute. /// /// This means: @@ -311,17 +314,23 @@ impl Yolk { /// - If `actual_path` is a directory that does not exist in `link_path`, symlink it. /// - If `actual_path` is a directory that already exists in `link_path`, recurse into it and `symlink_recursive` `actual_path`s children. fn symlink_recursive( + created_symlinks: &mut Vec<(PathBuf, PathBuf)>, egg_root: impl AsRef, actual_path: impl AsRef, link_path: &impl AsRef, ) -> Result<()> { - fn inner(egg_root: PathBuf, actual_path: PathBuf, link_path: PathBuf) -> Result<()> { + fn inner( + created_symlinks: &mut Vec<(PathBuf, PathBuf)>, + egg_root: PathBuf, + actual_path: PathBuf, + link_path: PathBuf, + ) -> Result<()> { let actual_path = actual_path.normalize(); let link_path = link_path.normalize(); let egg_root = egg_root.normalize(); assert!( link_path.is_absolute(), - "link_ path must be absolute, but was {}", + "link_path must be absolute, but was {}", link_path.display() ); assert!( @@ -335,7 +344,7 @@ fn symlink_recursive( actual_path.display(), egg_root.display(), ); - tracing::debug!( + tracing::trace!( "symlink_recursive({}, {})", actual_path.abbr(), link_path.abbr() @@ -353,6 +362,7 @@ fn symlink_recursive( link_target.abbr() ); if link_target == actual_path { + created_symlinks.push((actual_path, link_path)); return Ok(()); } else if link_target.exists() { miette::bail!( @@ -380,7 +390,12 @@ fn symlink_recursive( if link_path.is_dir() && actual_path.is_dir() { for entry in actual_path.fs_err_read_dir().into_diagnostic()? { let entry = entry.into_diagnostic()?; - symlink_recursive(&egg_root, entry.path(), &link_path.join(entry.file_name()))?; + symlink_recursive( + created_symlinks, + &egg_root, + entry.path(), + &link_path.join(entry.file_name()), + )?; } return Ok(()); } else if link_path.is_dir() || actual_path.is_dir() { @@ -397,9 +412,11 @@ fn symlink_recursive( link_path.abbr(), actual_path.abbr(), ); + created_symlinks.push((actual_path, link_path)); Ok(()) } inner( + created_symlinks, egg_root.as_ref().to_path_buf(), actual_path.as_ref().to_path_buf(), link_path.as_ref().to_path_buf(), From 92e5d5c21306c72365e1b87d533ea8e8133a8ae3 Mon Sep 17 00:00:00 2001 From: elkowar Date: Sun, 5 Jan 2025 22:26:07 +0100 Subject: [PATCH 2/3] feat: Clean up stale symlinks by caching deployment targets --- src/yolk.rs | 127 +++++++++++++++++++++++++++++++++++++++++++--- src/yolk_paths.rs | 49 ++++++++++++++++++ 2 files changed, 169 insertions(+), 7 deletions(-) diff --git a/src/yolk.rs b/src/yolk.rs index 78ec70d..ab53d29 100644 --- a/src/yolk.rs +++ b/src/yolk.rs @@ -38,7 +38,7 @@ impl Yolk { #[tracing::instrument(skip_all, fields(egg = ?egg.name()))] fn deploy_egg( &self, - created_symlinks: &mut Vec<(PathBuf, PathBuf)>, + created_symlinks: &mut Vec, egg: &Egg, mappings: &HashMap, ) -> Result<(), MultiError> { @@ -69,6 +69,7 @@ impl Yolk { })?; } util::create_symlink(in_egg, deployed)?; + created_symlinks.push(deployed.clone()); } } Result::Ok(()) @@ -116,6 +117,7 @@ impl Yolk { /// Deploy or undeploy the given egg, depending on the current system state and the given Egg data. /// Returns true if the egg is now deployed, false if it is not. + #[tracing::instrument(skip_all, fields(egg.name = %egg.name()))] pub fn sync_egg_deployment(&self, egg: &Egg) -> Result { let deployed = egg .is_deployed() @@ -130,12 +132,19 @@ impl Yolk { .config() .targets_expanded(self.yolk_paths.home_path(), egg.path()) .context("Failed to expand targets config for egg")?; + if egg.config().enabled && !deployed { tracing::debug!("Deploying egg {}", egg.name()); - let result = self.deploy_egg(egg, &mappings); + + let mut deployed_symlinks = Vec::new(); + let result = self.deploy_egg(&mut deployed_symlinks, egg, &mappings); if result.is_ok() { tracing::info!("Successfully deployed egg {}", egg.name()); } + + if let Err(e) = self.cleanup_stale_symlinks_for(egg.name(), &deployed_symlinks) { + tracing::error!("{e:?}"); + } result.map(|()| true) } else if !egg.config().enabled && deployed { cov_mark::hit!(undeploy); @@ -144,12 +153,60 @@ impl Yolk { if result.is_ok() { tracing::info!("Successfully undeployed egg {}", egg.name()); } + + if let Err(e) = self.cleanup_stale_symlinks_for(egg.name(), &[]) { + tracing::error!("{e:?}"); + } + result.map(|()| false) } else { Ok(deployed) } } + /// Check through the old symlinks from the cache file of a given egg, + /// and remove any that are not included in the `deployed_symlinks` list. + pub fn cleanup_stale_symlinks_for( + &self, + egg_name: &str, + deployed_symlinks: &[PathBuf], + ) -> Result<(), MultiError> { + let mut errs = Vec::new(); + let old_symlinks_db = self.yolk_paths.previous_egg_deployment_locations_db()?; + let old_symlinks = old_symlinks_db.read(egg_name)?; + + for old_symlink in old_symlinks { + // TODO: This is very,.... reliant on the fact that paths are normalized. + // Should be the case, but can we enforce this somehow? + if !deployed_symlinks.contains(&old_symlink) { + let is_symlink_to_egg = if old_symlink.exists() && old_symlink.is_symlink() { + match old_symlink.fs_err_read_link() { + Ok(x) => x.starts_with(self.paths().egg_path(egg_name)), + Err(e) => { + errs.push(miette::Report::from_err(e)); + false + } + } + } else { + false + }; + if is_symlink_to_egg { + tracing::info!("Removing stale symlink at {}", old_symlink.abbr()); + cov_mark::hit!(delete_stale_symlink); + if let Err(e) = util::remove_symlink(&old_symlink) { + errs.push(e.wrap_err(format!( + "Failed to remove old symlink {}", + old_symlink.abbr() + ))); + } + } + } + } + old_symlinks_db.write(egg_name, deployed_symlinks)?; + + Ok(()) + } + /// fetch the `eggs` variable from a given EvalCtx. pub fn load_egg_configs(&self, eval_ctx: &mut EvalCtx) -> Result> { let eggs_map = eval_ctx @@ -303,7 +360,7 @@ impl Yolk { /// Set up a symlink from the given `link_path` to the given `actual_path`, recursively. /// Also takes the `egg_root` dir, to ensure we can safely delete any stale symlinks on the way there. /// -/// Also takes a mutable list that new (actual_path, link_path) pairs will be added to when created or encountered. +/// Also takes a mutable list that all encountered or created symlinks will be added to. /// /// Requires all paths to be absolute. /// @@ -314,13 +371,13 @@ impl Yolk { /// - If `actual_path` is a directory that does not exist in `link_path`, symlink it. /// - If `actual_path` is a directory that already exists in `link_path`, recurse into it and `symlink_recursive` `actual_path`s children. fn symlink_recursive( - created_symlinks: &mut Vec<(PathBuf, PathBuf)>, + created_symlinks: &mut Vec, egg_root: impl AsRef, actual_path: impl AsRef, link_path: &impl AsRef, ) -> Result<()> { fn inner( - created_symlinks: &mut Vec<(PathBuf, PathBuf)>, + created_symlinks: &mut Vec, egg_root: PathBuf, actual_path: PathBuf, link_path: PathBuf, @@ -362,7 +419,7 @@ fn symlink_recursive( link_target.abbr() ); if link_target == actual_path { - created_symlinks.push((actual_path, link_path)); + created_symlinks.push(link_path); return Ok(()); } else if link_target.exists() { miette::bail!( @@ -412,7 +469,7 @@ fn symlink_recursive( link_path.abbr(), actual_path.abbr(), ); - created_symlinks.push((actual_path, link_path)); + created_symlinks.push(link_path); Ok(()) } inner( @@ -549,6 +606,62 @@ mod test { Ok(()) } + #[test] + fn test_moving_put_deploy_cleans_up_old_symlinks() -> TestResult { + cov_mark::check_count!(delete_stale_symlink, 2); + let (home, yolk, eggs) = setup_and_init_test_yolk()?; + eggs.child("foo/foo.toml").write_str("")?; + let mut egg = Egg::open( + home.to_path_buf(), + eggs.child("foo").to_path_buf(), + EggConfig::new("foo.toml", home.child("foo.toml")), + )?; + yolk.sync_egg_deployment(&egg)?; + home.child("foo.toml").assert(is_symlink()); + + // now we sync again, to a different location + *egg.config_mut() = EggConfig::new("foo.toml", home.child("bar.toml")); + yolk.sync_egg_deployment(&egg)?; + home.child("bar.toml").assert(is_symlink()); + home.child("foo.toml").assert(exists().not()); + + // and back, just to be sure + *egg.config_mut() = EggConfig::new("foo.toml", home.child("foo.toml")); + yolk.sync_egg_deployment(&egg)?; + home.child("foo.toml").assert(is_symlink()); + home.child("bar.toml").assert(exists().not()); + Ok(()) + } + + #[test] + fn test_moving_merge_deploy_cleans_up_old_symlinks() -> TestResult { + cov_mark::check_count!(delete_stale_symlink, 2); + let (home, yolk, eggs) = setup_and_init_test_yolk()?; + home.child("a").create_dir_all()?; + home.child("b").create_dir_all()?; + eggs.child("foo/foo/foo.toml").write_str("")?; + let mut egg = Egg::open( + home.to_path_buf(), + eggs.child("foo").to_path_buf(), + EggConfig::new_merge(".", home.child("a")), + )?; + yolk.sync_egg_deployment(&egg)?; + home.child("a/foo").assert(is_symlink()); + + // now we sync again, to a different location + *egg.config_mut() = EggConfig::new_merge(".", home.child("b")); + yolk.sync_egg_deployment(&egg)?; + home.child("b/foo").assert(is_symlink()); + home.child("a/foo").assert(exists().not()); + + // and back, just to be sure + *egg.config_mut() = EggConfig::new_merge(".", home.child("a")); + yolk.sync_egg_deployment(&egg)?; + home.child("a/foo").assert(is_symlink()); + home.child("b/foo").assert(exists().not()); + Ok(()) + } + #[test] fn test_deploy_outside_of_home() -> TestResult { let (home, yolk, eggs) = setup_and_init_test_yolk()?; diff --git a/src/yolk_paths.rs b/src/yolk_paths.rs index 38a8742..2b18413 100644 --- a/src/yolk_paths.rs +++ b/src/yolk_paths.rs @@ -184,6 +184,54 @@ impl YolkPaths { pub fn get_egg(&self, name: &str, config: EggConfig) -> Result { Egg::open(self.home.clone(), self.egg_path(name), config) } + + pub fn previous_egg_deployment_locations_db_path(&self) -> PathBuf { + self.root_path.join(".previous_deployment_targets") + } + + pub fn previous_egg_deployment_locations_db(&self) -> Result { + PreviousEggDeploymentLocationsDb::open(self.root_path.join(".deployed_cache")) + } +} + +pub struct PreviousEggDeploymentLocationsDb { + path: PathBuf, +} + +impl PreviousEggDeploymentLocationsDb { + fn open(path: PathBuf) -> Result { + fs_err::create_dir_all(&path).into_diagnostic()?; + Ok(Self { path }) + } + + pub fn egg_data_path(&self, egg_name: &str) -> PathBuf { + self.path.join(egg_name) + } + + pub fn read(&self, egg_name: &str) -> Result> { + let cache_path = self.egg_data_path(egg_name); + if cache_path.exists() { + Ok(fs_err::read_to_string(cache_path) + .into_diagnostic()? + .lines() + .map(PathBuf::from) + .collect()) + } else { + Ok(Vec::new()) + } + } + + pub fn write(&self, egg_name: &str, symlinks: &[PathBuf]) -> Result<()> { + let cache_path = self.egg_data_path(egg_name); + let content = symlinks + .iter() + .map(|x| x.to_string_lossy()) + .collect::>() + .join("\n"); + fs_err::write(cache_path, content) + .into_diagnostic() + .with_context(|| format!("Failed to update egg deployment cache for egg {egg_name}")) + } } #[derive(Debug)] @@ -213,6 +261,7 @@ impl Egg { } /// Check if the egg is _fully_ deployed (-> All contained entries have corresponding symlinks) + #[tracing::instrument(skip_all, fields(egg.name = self.name()))] pub fn is_deployed(&self) -> Result { for x in self.find_deployed_symlinks()? { if x.context("Got error while iterating through deployed files or egg")? From b4c6bd15df97549ba09700a61860e0b306d3a81f Mon Sep 17 00:00:00 2001 From: ElKowar Date: Sun, 5 Jan 2025 22:47:00 +0100 Subject: [PATCH 3/3] Release v0.0.17 --- CHANGELOG.md | 14 ++++++++++++++ Cargo.lock | 2 +- Cargo.toml | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 15c4386..d455b98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.17](https://github.com/elkowar/yolk/compare/v0.0.16...v0.0.17) - 2025-01-05 + +### Added + +- Clean up stale symlinks by caching deployment targets +- Allow for both .config and standard ~/Library/... dir on mac + +### Fixed + +- Fix windows symlink deletion again +- simplify multi error output +- inconsistent tests, failing symlink deletion on windows +- compile error on windows + ## [0.0.16](https://github.com/elkowar/yolk/compare/v0.0.15...v0.0.16) - 2024-12-22 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 483d24a..4f6bff3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1995,7 +1995,7 @@ checksum = "cfe53a6657fd280eaa890a3bc59152892ffa3e30101319d168b781ed6529b049" [[package]] name = "yolk_dots" -version = "0.0.16" +version = "0.0.17" dependencies = [ "arbitrary", "assert_fs", diff --git a/Cargo.toml b/Cargo.toml index 13f2800..60f79db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -2,7 +2,7 @@ name = "yolk_dots" authors = ["ElKowar "] description = "Templated dotfile management without template files" -version = "0.0.16" +version = "0.0.17" edition = "2021" repository = "https://github.com/elkowar/yolk" homepage = "https://elkowar.github.io/yolk"