From 1a666dbd0e1f8476ef7c1d13aa7344df17680353 Mon Sep 17 00:00:00 2001 From: erishforG Date: Mon, 4 May 2026 09:07:41 +0900 Subject: [PATCH] [207] feat: selective build cache sharing between worktrees Add `[worktree]` config section so `parsec start` can reuse build artifacts from the main repo instead of forcing a cold rebuild. [worktree] shared_cache = ["target", "node_modules", ".venv"] cache_strategy = "symlink" # "symlink" | "copy" For each entry, the source `/` is shared into the new worktree. Missing sources and pre-existing destinations are skipped. Sharing failures are logged but never fail the worktree itself. Default is an empty list, preserving prior behavior. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 9 ++ README.md | 11 ++ schema/parsec-config.schema.json | 19 +++ src/config/mod.rs | 1 + src/config/settings.rs | 102 +++++++++++++ src/worktree/cache_share.rs | 244 +++++++++++++++++++++++++++++++ src/worktree/manager.rs | 9 ++ src/worktree/mod.rs | 1 + tests/cli_tests.rs | 141 +++++++++++++++++- 9 files changed, 536 insertions(+), 1 deletion(-) create mode 100644 src/worktree/cache_share.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b326ae4..c7d7b6d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,15 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added +- `[worktree]` config section with `shared_cache` and `cache_strategy` for + selective build cache sharing between worktrees. New worktrees can now reuse + `target/`, `node_modules/`, `.venv/`, etc. from the main repo via symlink + (default) or recursive copy, eliminating cold-build cost on `parsec start` + (#207). + ## [0.3.3] - 2026-04-22 ### Added diff --git a/README.md b/README.md index 32a3ee5..2f3500d 100644 --- a/README.md +++ b/README.md @@ -1281,6 +1281,17 @@ layout = "sibling" base_dir = ".parsec/workspaces" branch_prefix = "feature/" +[worktree] +# Directories to share from the main repo into new worktrees so that +# `parsec start` doesn't trigger a cold rebuild. Default is empty (no sharing). +shared_cache = ["target", "node_modules", ".venv"] +# "symlink" (default): fast, zero-disk overhead. All worktrees and the main +# repo share one cache — running parallel builds of the +# same artifact may race. +# "copy": full copy at start time. Each worktree gets an independent cache, +# no race risk, but uses more disk and the initial copy takes time. +cache_strategy = "symlink" + [tracker] # "jira" | "github" | "gitlab" | "none" provider = "jira" diff --git a/schema/parsec-config.schema.json b/schema/parsec-config.schema.json index d6b6956..e5ddf70 100644 --- a/schema/parsec-config.schema.json +++ b/schema/parsec-config.schema.json @@ -37,6 +37,25 @@ }, "additionalProperties": false }, + "worktree": { + "type": "object", + "description": "Build cache sharing for new worktrees", + "properties": { + "shared_cache": { + "type": "array", + "items": { "type": "string" }, + "default": [], + "description": "Directories to share from the main repo into new worktrees (e.g. target, node_modules, .venv)" + }, + "cache_strategy": { + "type": "string", + "enum": ["symlink", "copy"], + "default": "symlink", + "description": "How to share cache directories: symlink (fast, shared state) or copy (independent state)" + } + }, + "additionalProperties": false + }, "tracker": { "type": "object", "description": "Issue tracker integration settings", diff --git a/src/config/mod.rs b/src/config/mod.rs index 8fc4636..9e4a140 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,5 +1,6 @@ mod settings; +pub use settings::CacheStrategy; pub use settings::ParsecConfig; pub use settings::TrackerProvider; pub use settings::WorktreeLayout; diff --git a/src/config/settings.rs b/src/config/settings.rs index 90fab68..14dda07 100644 --- a/src/config/settings.rs +++ b/src/config/settings.rs @@ -84,6 +84,44 @@ impl std::fmt::Display for WorktreeLayout { } } +// --------------------------------------------------------------------------- +// CacheStrategy +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +#[derive(Default)] +pub enum CacheStrategy { + #[default] + Symlink, + Copy, +} + +impl std::fmt::Display for CacheStrategy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CacheStrategy::Symlink => write!(f, "symlink"), + CacheStrategy::Copy => write!(f, "copy"), + } + } +} + +// --------------------------------------------------------------------------- +// WorktreeConfig +// --------------------------------------------------------------------------- + +/// Build cache sharing settings for new worktrees. +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct WorktreeConfig { + /// Directories to share from the main repo into new worktrees + /// (e.g. ["target", "node_modules", ".venv"]). + #[serde(default)] + pub shared_cache: Vec, + /// How to share the directories: symlink (default) or copy. + #[serde(default)] + pub cache_strategy: CacheStrategy, +} + // --------------------------------------------------------------------------- // WorkspaceConfig // --------------------------------------------------------------------------- @@ -356,6 +394,8 @@ pub struct ParsecConfig { #[serde(default)] pub workspace: WorkspaceConfig, #[serde(default)] + pub worktree: WorktreeConfig, + #[serde(default)] pub tracker: TrackerConfig, #[serde(default)] pub ship: ShipConfig, @@ -586,3 +626,65 @@ impl ParsecConfig { Ok(config) } } + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn worktree_config_defaults_when_section_missing() { + let config: ParsecConfig = toml::from_str("").unwrap(); + assert!(config.worktree.shared_cache.is_empty()); + assert_eq!(config.worktree.cache_strategy, CacheStrategy::Symlink); + } + + #[test] + fn worktree_config_parses_full_section() { + let toml_str = r#" +[worktree] +shared_cache = ["target", ".venv"] +cache_strategy = "copy" +"#; + let config: ParsecConfig = toml::from_str(toml_str).unwrap(); + assert_eq!( + config.worktree.shared_cache, + vec!["target".to_string(), ".venv".to_string()] + ); + assert_eq!(config.worktree.cache_strategy, CacheStrategy::Copy); + } + + #[test] + fn worktree_config_partial_fields_take_defaults() { + let toml_str = r#" +[worktree] +shared_cache = ["target"] +"#; + let config: ParsecConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(config.worktree.shared_cache, vec!["target".to_string()]); + assert_eq!(config.worktree.cache_strategy, CacheStrategy::Symlink); + } + + #[test] + fn worktree_config_unknown_strategy_is_error() { + let toml_str = r#" +[worktree] +cache_strategy = "hardlink" +"#; + let err = toml::from_str::(toml_str).unwrap_err(); + let msg = err.to_string(); + assert!( + msg.contains("hardlink") || msg.to_lowercase().contains("variant"), + "expected error to mention bad variant, got: {msg}" + ); + } + + #[test] + fn cache_strategy_symlink_is_default() { + let strategy = CacheStrategy::default(); + assert_eq!(strategy, CacheStrategy::Symlink); + } +} diff --git a/src/worktree/cache_share.rs b/src/worktree/cache_share.rs new file mode 100644 index 0000000..a6e4c75 --- /dev/null +++ b/src/worktree/cache_share.rs @@ -0,0 +1,244 @@ +use std::path::Path; + +use crate::config::CacheStrategy; + +/// Share build-cache directories from the main repo into a freshly created +/// worktree. Each entry is processed independently; failures are logged but +/// never propagated, so a flaky cache share never breaks `parsec start`. +/// +/// - Source path is `/`. Missing → skip. +/// - Destination path is `/`. Already exists → skip. +/// - `Symlink` creates a symlink to the absolute source path; `Copy` does a +/// recursive copy using stdlib only (no extra dependency). +pub fn share_cache( + repo_root: &Path, + worktree_path: &Path, + entries: &[String], + strategy: CacheStrategy, +) { + if entries.is_empty() { + return; + } + + for entry in entries { + if entry.is_empty() || entry.contains("..") { + eprintln!("warning: skipping invalid shared_cache entry {:?}", entry); + continue; + } + + let src = repo_root.join(entry); + let dest = worktree_path.join(entry); + + if !src.exists() { + eprintln!( + "info: shared_cache: source '{}' does not exist in main repo, skipping", + entry + ); + continue; + } + + if dest.exists() || dest.symlink_metadata().is_ok() { + eprintln!( + "info: shared_cache: destination '{}' already exists in worktree, skipping", + entry + ); + continue; + } + + // Ensure dest's parent exists (for nested entries like "a/b/target"). + if let Some(parent) = dest.parent() { + if !parent.exists() { + if let Err(e) = std::fs::create_dir_all(parent) { + eprintln!( + "warning: shared_cache: failed to create parent for '{}': {e}", + entry + ); + continue; + } + } + } + + let abs_src = match dunce::canonicalize(&src) { + Ok(p) => p, + Err(e) => { + eprintln!( + "warning: shared_cache: cannot resolve source '{}': {e}", + entry + ); + continue; + } + }; + + let result = match strategy { + CacheStrategy::Symlink => create_symlink(&abs_src, &dest), + CacheStrategy::Copy => copy_recursive(&abs_src, &dest), + }; + + match result { + Ok(()) => { + eprintln!( + "info: shared_cache: {} '{}' from {} -> {}", + strategy, + entry, + abs_src.display(), + dest.display() + ); + } + Err(e) => { + eprintln!( + "warning: shared_cache: failed to share '{}' ({}): {e}", + entry, strategy + ); + } + } + } +} + +#[cfg(unix)] +fn create_symlink(src: &Path, dest: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(src, dest) +} + +#[cfg(windows)] +fn create_symlink(src: &Path, dest: &Path) -> std::io::Result<()> { + if src.is_dir() { + std::os::windows::fs::symlink_dir(src, dest) + } else { + std::os::windows::fs::symlink_file(src, dest) + } +} + +fn copy_recursive(src: &Path, dest: &Path) -> std::io::Result<()> { + let metadata = std::fs::symlink_metadata(src)?; + let file_type = metadata.file_type(); + + if file_type.is_symlink() { + // Follow symlinks during copy (resolving once); fall back to plain copy. + let target = std::fs::read_link(src)?; + let resolved = if target.is_absolute() { + target + } else { + src.parent().unwrap_or(Path::new(".")).join(target) + }; + return copy_recursive(&resolved, dest); + } + + if file_type.is_dir() { + std::fs::create_dir_all(dest)?; + for entry in std::fs::read_dir(src)? { + let entry = entry?; + let child_src = entry.path(); + let child_dest = dest.join(entry.file_name()); + copy_recursive(&child_src, &child_dest)?; + } + Ok(()) + } else { + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::copy(src, dest).map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::TempDir; + + fn read_file(p: &Path) -> String { + fs::read_to_string(p).unwrap() + } + + fn make_dirs() -> (TempDir, std::path::PathBuf, std::path::PathBuf) { + let tmp = TempDir::new().unwrap(); + let repo = tmp.path().join("repo"); + let wt = tmp.path().join("worktree"); + fs::create_dir_all(&repo).unwrap(); + fs::create_dir_all(&wt).unwrap(); + (tmp, repo, wt) + } + + #[test] + fn symlink_strategy_links_existing_dir() { + let (_tmp, repo, wt) = make_dirs(); + fs::create_dir_all(repo.join("target")).unwrap(); + fs::write(repo.join("target/build.txt"), "hello").unwrap(); + + share_cache(&repo, &wt, &["target".to_string()], CacheStrategy::Symlink); + + let dest = wt.join("target"); + assert!(dest.exists()); + let meta = fs::symlink_metadata(&dest).unwrap(); + assert!(meta.file_type().is_symlink(), "should be a symlink"); + assert_eq!(read_file(&dest.join("build.txt")), "hello"); + } + + #[test] + fn copy_strategy_creates_real_dir() { + let (_tmp, repo, wt) = make_dirs(); + fs::create_dir_all(repo.join("target/nested")).unwrap(); + fs::write(repo.join("target/a.txt"), "alpha").unwrap(); + fs::write(repo.join("target/nested/b.txt"), "beta").unwrap(); + + share_cache(&repo, &wt, &["target".to_string()], CacheStrategy::Copy); + + let dest = wt.join("target"); + assert!(dest.exists()); + let meta = fs::symlink_metadata(&dest).unwrap(); + assert!(!meta.file_type().is_symlink(), "must not be a symlink"); + assert!(meta.is_dir()); + assert_eq!(read_file(&dest.join("a.txt")), "alpha"); + assert_eq!(read_file(&dest.join("nested/b.txt")), "beta"); + } + + #[test] + fn missing_entry_is_skipped_silently() { + let (_tmp, repo, wt) = make_dirs(); + + share_cache( + &repo, + &wt, + &["does-not-exist".to_string()], + CacheStrategy::Symlink, + ); + + assert!(!wt.join("does-not-exist").exists()); + } + + #[test] + fn existing_dest_is_not_overwritten() { + let (_tmp, repo, wt) = make_dirs(); + fs::create_dir_all(repo.join("target")).unwrap(); + fs::write(repo.join("target/from_repo.txt"), "repo").unwrap(); + fs::create_dir_all(wt.join("target")).unwrap(); + fs::write(wt.join("target/preexisting.txt"), "keep").unwrap(); + + share_cache(&repo, &wt, &["target".to_string()], CacheStrategy::Copy); + + // Pre-existing content untouched, repo content not copied in. + assert!(wt.join("target/preexisting.txt").exists()); + assert!(!wt.join("target/from_repo.txt").exists()); + } + + #[test] + fn empty_list_is_noop() { + let (_tmp, repo, wt) = make_dirs(); + share_cache(&repo, &wt, &[], CacheStrategy::Symlink); + // Just verify nothing was created in the worktree. + let entries: Vec<_> = fs::read_dir(&wt).unwrap().collect(); + assert!(entries.is_empty()); + } + + #[test] + fn path_traversal_entries_rejected() { + let (_tmp, repo, wt) = make_dirs(); + fs::create_dir_all(repo.join("evil")).unwrap(); + + share_cache(&repo, &wt, &["../evil".to_string()], CacheStrategy::Symlink); + + // Nothing should have been created. + let entries: Vec<_> = fs::read_dir(&wt).unwrap().collect(); + assert!(entries.is_empty()); + } +} diff --git a/src/worktree/manager.rs b/src/worktree/manager.rs index 4783dc5..bde0ffd 100644 --- a/src/worktree/manager.rs +++ b/src/worktree/manager.rs @@ -131,6 +131,15 @@ impl WorktreeManager { .save(&self.repo_root) .context("failed to save parsec state")?; + // Share build-cache directories from the main repo into the new worktree. + // Failures are logged but never propagated — the worktree itself succeeded. + super::cache_share::share_cache( + &self.repo_root, + &worktree_path, + &self.config.worktree.shared_cache, + self.config.worktree.cache_strategy, + ); + // Run post-create hooks if !self.config.hooks.post_create.is_empty() { let skip_prompt = std::env::var("PARSEC_YES") diff --git a/src/worktree/mod.rs b/src/worktree/mod.rs index 9501760..f204a09 100644 --- a/src/worktree/mod.rs +++ b/src/worktree/mod.rs @@ -1,3 +1,4 @@ +mod cache_share; mod lifecycle; mod manager; diff --git a/tests/cli_tests.rs b/tests/cli_tests.rs index f8b2a63..3bc79ee 100644 --- a/tests/cli_tests.rs +++ b/tests/cli_tests.rs @@ -1027,6 +1027,145 @@ fn test_start_with_existing_branch() { assert!(contents.contains("my-existing-branch")); } +// --------------------------------------------------------------------------- +// shared_cache (issue #207) +// --------------------------------------------------------------------------- + +/// Build a custom config dir containing a config.toml with the given body and +/// return its path. Caller must keep the TempDir alive. +fn write_config_toml(body: &str) -> TempDir { + let dir = TempDir::new().unwrap(); + std::fs::write(dir.path().join("config.toml"), body).unwrap(); + dir +} + +#[test] +fn test_shared_cache_symlink_creates_link() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path(); + + // Pre-populate a `target/` directory in the main repo with a build artifact. + std::fs::create_dir_all(repo_path.join("target")).unwrap(); + std::fs::write(repo_path.join("target").join("artifact.txt"), "pre-built").unwrap(); + + let config_dir = write_config_toml( + r#" +[worktree] +shared_cache = ["target"] +cache_strategy = "symlink" +"#, + ); + + let mut cmd = Command::cargo_bin("parsec").unwrap(); + cmd.env("PARSEC_CONFIG_DIR", config_dir.path()) + .args(["start", "CACHE-1", "--repo", repo_path.to_str().unwrap()]) + .assert() + .success(); + + // Worktree path follows sibling layout: /.CACHE-1 + let repo_name = repo_path.file_name().unwrap().to_string_lossy().to_string(); + let wt_path = repo_path + .parent() + .unwrap() + .join(format!("{}.CACHE-1", repo_name)); + let dest = wt_path.join("target"); + + assert!(dest.exists(), "worktree should have shared target/"); + let meta = std::fs::symlink_metadata(&dest).unwrap(); + assert!( + meta.file_type().is_symlink(), + "symlink strategy must produce a symlink, got: {:?}", + meta.file_type() + ); + let contents = std::fs::read_to_string(dest.join("artifact.txt")).unwrap(); + assert_eq!(contents, "pre-built"); +} + +#[test] +fn test_shared_cache_copy_creates_real_dir() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path(); + + std::fs::create_dir_all(repo_path.join("target").join("nested")).unwrap(); + std::fs::write(repo_path.join("target").join("a.txt"), "alpha").unwrap(); + std::fs::write( + repo_path.join("target").join("nested").join("b.txt"), + "beta", + ) + .unwrap(); + + let config_dir = write_config_toml( + r#" +[worktree] +shared_cache = ["target"] +cache_strategy = "copy" +"#, + ); + + let mut cmd = Command::cargo_bin("parsec").unwrap(); + cmd.env("PARSEC_CONFIG_DIR", config_dir.path()) + .args(["start", "CACHE-2", "--repo", repo_path.to_str().unwrap()]) + .assert() + .success(); + + let repo_name = repo_path.file_name().unwrap().to_string_lossy().to_string(); + let wt_path = repo_path + .parent() + .unwrap() + .join(format!("{}.CACHE-2", repo_name)); + let dest = wt_path.join("target"); + + assert!(dest.exists()); + let meta = std::fs::symlink_metadata(&dest).unwrap(); + assert!( + !meta.file_type().is_symlink(), + "copy strategy must NOT produce a symlink" + ); + assert!(meta.is_dir()); + assert_eq!( + std::fs::read_to_string(dest.join("a.txt")).unwrap(), + "alpha" + ); + assert_eq!( + std::fs::read_to_string(dest.join("nested").join("b.txt")).unwrap(), + "beta" + ); +} + +#[test] +fn test_shared_cache_missing_entry_skipped() { + let (repo, _bare) = setup_repo_with_remote(); + let repo_path = repo.path(); + + // Don't pre-create `.venv` in the main repo. + let config_dir = write_config_toml( + r#" +[worktree] +shared_cache = [".venv"] +cache_strategy = "symlink" +"#, + ); + + let mut cmd = Command::cargo_bin("parsec").unwrap(); + cmd.env("PARSEC_CONFIG_DIR", config_dir.path()) + .args(["start", "CACHE-3", "--repo", repo_path.to_str().unwrap()]) + .assert() + .success(); + + let repo_name = repo_path.file_name().unwrap().to_string_lossy().to_string(); + let wt_path = repo_path + .parent() + .unwrap() + .join(format!("{}.CACHE-3", repo_name)); + + // Worktree was created (start succeeded), but `.venv` was simply skipped. + assert!(wt_path.exists(), "worktree should still be created"); + assert!( + !wt_path.join(".venv").exists(), + "missing source should NOT be linked into worktree" + ); +} + // --------------------------------------------------------------------------- // JSON error format // --------------------------------------------------------------------------- @@ -1050,7 +1189,7 @@ fn test_json_error_format() { let stdout = String::from_utf8(output.stdout).unwrap(); let parsed: serde_json::Value = serde_json::from_str(&stdout).expect("JSON error output must be parseable"); - assert_eq!(parsed["error"].as_bool().unwrap(), true); + assert!(parsed["error"].as_bool().unwrap()); assert!(parsed.get("code").is_some()); assert!(parsed.get("message").is_some()); }