From 0d2732f2ca17668d1d5f74740b6aeac1cb65ced0 Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 24 Oct 2026 19:50:36 -0400 Subject: [PATCH 1/3] perf(graph): parse each cache glob pattern once Building the task graph parsed and partitioned every input and output pattern of every task with wax, which compiles a regular expression for each. The partition depends on the pattern alone, so keep it per distinct pattern; the join with each package's directory and the workspace check still run for every use. --- crates/vt_graph/src/config/mod.rs | 81 ++++++++++++++++++++++++++----- 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/crates/vt_graph/src/config/mod.rs b/crates/vt_graph/src/config/mod.rs index 967ef82a1..7e22df87a 100644 --- a/crates/vt_graph/src/config/mod.rs +++ b/crates/vt_graph/src/config/mod.rs @@ -1,8 +1,11 @@ pub mod user; -use std::{collections::BTreeSet, sync::Arc}; +use std::{ + collections::BTreeSet, + sync::{Arc, LazyLock, Mutex, PoisonError}, +}; -use rustc_hash::FxHashSet; +use rustc_hash::{FxHashMap, FxHashSet}; use serde::Serialize; pub use user::{ AutoTracking, Command, EnabledCacheConfig, GlobWithBase, InputBase, ResolvedGlobalCacheConfig, @@ -291,13 +294,9 @@ fn resolve_glob_to_workspace_relative( pattern }; - let glob = wax::Glob::new(pattern).map_err(|source| ResolveTaskConfigError::InvalidGlob { - pattern: Str::from(pattern), - source: Box::new(source), - })?; - let (invariant_prefix, variant) = glob.partition(); + let partitioned = partition_glob(pattern)?; - let joined = package_dir.join(&invariant_prefix).clean(); + let joined = package_dir.join(partitioned.invariant_prefix.as_str()).clean(); let stripped = joined.strip_prefix(workspace_root).map_err(|_| { ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } })?; @@ -309,10 +308,8 @@ fn resolve_glob_to_workspace_relative( let escaped_prefix = wax::escape(stripped.as_str()); - let result = match variant { - Some(variant_glob) if escaped_prefix.is_empty() => { - Str::from(variant_glob.to_string().as_str()) - } + let result = match &partitioned.variant { + Some(variant_glob) if escaped_prefix.is_empty() => variant_glob.clone(), Some(variant_glob) => vt_str::format!("{escaped_prefix}/{variant_glob}"), None if escaped_prefix.is_empty() => Str::from("**"), None => Str::from(escaped_prefix.as_ref()), @@ -321,6 +318,45 @@ fn resolve_glob_to_workspace_relative( Ok(result) } +/// A glob pattern split into its literal prefix and the glob that follows it. +struct PartitionedGlob { + invariant_prefix: Str, + variant: Option, +} + +/// Parse and partition `pattern`, at most once per distinct pattern in the process. +/// +/// `wax` compiles a regular expression both when it parses a glob and when it partitions one, +/// and only the partition's text is kept here. The result depends on the pattern alone, while +/// workspaces repeat the same patterns across packages and tasks, so loading a task graph would +/// otherwise compile the same expressions thousands of times. +fn partition_glob(pattern: &str) -> Result, ResolveTaskConfigError> { + static PARTITIONED: LazyLock>>> = + LazyLock::new(Mutex::default); + + if let Some(partitioned) = + PARTITIONED.lock().unwrap_or_else(PoisonError::into_inner).get(pattern) + { + return Ok(Arc::clone(partitioned)); + } + + let invalid = |source| ResolveTaskConfigError::InvalidGlob { + pattern: Str::from(pattern), + source: Box::new(source), + }; + let (invariant_prefix, variant) = wax::Glob::new(pattern).map_err(invalid)?.partition(); + let partitioned = Arc::new(PartitionedGlob { + // The prefix is made of the pattern's own literal components, so this is lossless. + invariant_prefix: Str::from(invariant_prefix.to_string_lossy().as_ref()), + variant: variant.map(|glob| Str::from(glob.to_string().as_str())), + }); + PARTITIONED + .lock() + .unwrap_or_else(PoisonError::into_inner) + .insert(Str::from(pattern), Arc::clone(&partitioned)); + Ok(partitioned) +} + #[derive(Debug, Clone, Serialize)] pub struct EnvConfig { /// environment variable names to be fingerprinted and passed to the task, with defaults populated @@ -624,6 +660,27 @@ mod tests { ); } + #[test] + fn test_resolved_input_config_same_pattern_in_two_packages() { + // A pattern is parsed once per process; each package still resolves it against its own + // directory, and the workspace check still applies to each use. + let (pkg, ws) = test_paths(); + let other = pkg.parent().unwrap().join("other-pkg"); + let user_inputs = vec![UserInputEntry::Glob("src/**".into())]; + let own = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let from_other = + ResolvedGlobConfig::from_user_config(Some(&user_inputs), &other, &ws).unwrap(); + assert!(own.positive_globs.contains("packages/my-pkg/src/**"), "{:?}", own.positive_globs); + assert!( + from_other.positive_globs.contains("packages/other-pkg/src/**"), + "{:?}", + from_other.positive_globs + ); + let user_inputs = vec![UserInputEntry::Glob("../../../outside/**".into())]; + assert!(ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).is_err()); + assert!(ResolvedGlobConfig::from_user_config(Some(&user_inputs), &other, &ws).is_err()); + } + #[test] fn test_resolved_input_config_outside_workspace_error() { let (pkg, ws) = test_paths(); From 4c94f73265e6510916abe4c2751d81c5dbe0499b Mon Sep 17 00:00:00 2001 From: Jason Kuhrt Date: Sat, 24 Oct 2026 19:51:05 -0400 Subject: [PATCH 2/3] docs(changelog): parse each cache glob pattern once --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26e2ea8f4..1da5fb9cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,6 @@ # Changelog +- **Perf** `vp run` starts faster in workspaces whose tasks share cache `input` and `output` patterns: each distinct pattern is now parsed once instead of once for every task that uses it. In a 138-package workspace, this saves about 2 seconds of CPU on every run ([#887](https://github.com/voidzero-dev/vite-task/pull/887)). - **Perf** When tasks miss the cache, `vp run` no longer waits for the disk after saving each task's result, so the run finishes sooner. If the computer loses power or the operating system crashes during `vp run`, the cache entries that the run saved can be lost, and those tasks run again next time instead of hitting the cache ([#871](https://github.com/voidzero-dev/vite-task/pull/871)). - **Fixed** Running `vp run` in a package that sits inside a workspace folder but isn't listed in its `pnpm-workspace.yaml` `packages` or `package.json` `workspaces` now runs that package's own tasks, instead of treating the enclosing workspace as its root. When both configurations exist, `pnpm-workspace.yaml` takes precedence ([#859](https://github.com/voidzero-dev/vite-task/pull/859)). - **Fixed** A mistyped or misplaced setting in a task now gets an error that names it. For example, `cache: { outputs: [...] }` now fails with an `unknown field` error that names `outputs` and lists the accepted settings, instead of `data did not match any variant of untagged enum UserTaskDefinition` ([vite-plus#2635](https://github.com/voidzero-dev/vite-plus/issues/2635), [#815](https://github.com/voidzero-dev/vite-task/pull/815)). From 4121e8a7fc4850f88c8fff4c04da57fea1cf65ba Mon Sep 17 00:00:00 2001 From: wan9chi Date: Mon, 26 Oct 2026 14:46:13 +0900 Subject: [PATCH 3/3] perf(graph): cache glob partitions in a GlobResolver instead of a global map Replace the process-wide `Mutex` with a `GlobResolver` that is passed by reference through the task config resolve functions. Graph loading creates one for all tasks, and each plan creates one shared by its nested contexts. It holds a `RefCell` and `Rc` values, so it is neither `Send` nor `Sync` and its cache needs no lock. Look patterns up with hashbrown's `entry_ref`, which hashes each pattern once and builds the owned key only for a pattern seen for the first time. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 1 + Cargo.toml | 1 + crates/vt_graph/Cargo.toml | 1 + crates/vt_graph/src/config/mod.rs | 249 ++++++++++++++++++------------ crates/vt_graph/src/lib.rs | 5 +- crates/vt_plan/src/context.rs | 16 +- crates/vt_plan/src/lib.rs | 3 +- crates/vt_plan/src/plan.rs | 22 ++- 8 files changed, 189 insertions(+), 109 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0d3c47b00..614eedb0a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5096,6 +5096,7 @@ version = "0.1.0" dependencies = [ "anyhow", "async-trait", + "hashbrown 0.16.1", "petgraph", "pretty_assertions", "rustc-hash", diff --git a/Cargo.toml b/Cargo.toml index a4dcde700..766df61b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -91,6 +91,7 @@ futures = "0.3.31" futures-util = "0.3.31" globset = "0.4.18" getrandom = "0.4.2" +hashbrown = { version = "0.16.1", default-features = false, features = ["inline-more"] } itoa = "1.0.17" jsonc-parser = "0.32.0" libc = "0.2.185" diff --git a/crates/vt_graph/Cargo.toml b/crates/vt_graph/Cargo.toml index ef0cb970d..8af6d4577 100644 --- a/crates/vt_graph/Cargo.toml +++ b/crates/vt_graph/Cargo.toml @@ -10,6 +10,7 @@ rust-version.workspace = true [dependencies] anyhow = { workspace = true } async-trait = { workspace = true } +hashbrown = { workspace = true } wincode = { workspace = true, features = ["derive"] } petgraph = { workspace = true } rustc-hash = { workspace = true } diff --git a/crates/vt_graph/src/config/mod.rs b/crates/vt_graph/src/config/mod.rs index 7e22df87a..1ef299541 100644 --- a/crates/vt_graph/src/config/mod.rs +++ b/crates/vt_graph/src/config/mod.rs @@ -1,11 +1,9 @@ pub mod user; -use std::{ - collections::BTreeSet, - sync::{Arc, LazyLock, Mutex, PoisonError}, -}; +use std::{cell::RefCell, collections::BTreeSet, rc::Rc, sync::Arc}; -use rustc_hash::{FxHashMap, FxHashSet}; +use hashbrown::hash_map::EntryRef; +use rustc_hash::{FxBuildHasher, FxHashSet}; use serde::Serialize; pub use user::{ AutoTracking, Command, EnabledCacheConfig, GlobWithBase, InputBase, ResolvedGlobalCacheConfig, @@ -60,6 +58,7 @@ impl ResolvedTaskOptions { user_options: UserTaskOptions, dir: &Arc, workspace_root: &AbsolutePath, + glob_resolver: &GlobResolver, ) -> Result { let cwd: Arc = match user_options.cwd_relative_to_package { Some(ref cwd) if !cwd.as_str().is_empty() => dir.join(cwd).into(), @@ -76,12 +75,14 @@ impl ResolvedTaskOptions { enabled_cache_config.input.as_ref(), dir, workspace_root, + glob_resolver, )?; let output_config = ResolvedGlobConfig::from_user_output_config( enabled_cache_config.output.as_ref(), dir, workspace_root, + glob_resolver, )?; Some(CacheConfig { @@ -157,6 +158,7 @@ impl ResolvedGlobConfig { user_inputs: Option<&UserInputsConfig>, package_dir: &AbsolutePath, workspace_root: &AbsolutePath, + glob_resolver: &GlobResolver, ) -> Result { let Some(entries) = user_inputs else { // None means default to auto-inference @@ -176,6 +178,7 @@ impl ResolvedGlobConfig { pattern.as_str(), package_dir, workspace_root, + glob_resolver, &mut positive_globs, &mut negative_globs, )?; @@ -189,6 +192,7 @@ impl ResolvedGlobConfig { pattern.as_str(), base_dir, workspace_root, + glob_resolver, &mut positive_globs, &mut negative_globs, )?; @@ -212,6 +216,7 @@ impl ResolvedGlobConfig { user_outputs: Option<&Vec>, package_dir: &AbsolutePath, workspace_root: &AbsolutePath, + glob_resolver: &GlobResolver, ) -> Result { let mut positive_globs = BTreeSet::new(); let mut negative_globs = BTreeSet::new(); @@ -228,6 +233,7 @@ impl ResolvedGlobConfig { pattern.as_str(), package_dir, workspace_root, + glob_resolver, &mut positive_globs, &mut negative_globs, )?; @@ -241,6 +247,7 @@ impl ResolvedGlobConfig { pattern.as_str(), base_dir, workspace_root, + glob_resolver, &mut positive_globs, &mut negative_globs, )?; @@ -259,104 +266,114 @@ impl ResolvedGlobConfig { pattern: &str, base_dir: &AbsolutePath, workspace_root: &AbsolutePath, + glob_resolver: &GlobResolver, positive_globs: &mut BTreeSet, negative_globs: &mut BTreeSet, ) -> Result<(), ResolveTaskConfigError> { if let Some(negated) = pattern.strip_prefix('!') { - let resolved = resolve_glob_to_workspace_relative(negated, base_dir, workspace_root)?; + let resolved = glob_resolver.resolve(negated, base_dir, workspace_root)?; negative_globs.insert(resolved); } else { - let resolved = resolve_glob_to_workspace_relative(pattern, base_dir, workspace_root)?; + let resolved = glob_resolver.resolve(pattern, base_dir, workspace_root)?; positive_globs.insert(resolved); } Ok(()) } } -/// Resolve a single glob pattern to be workspace-root-relative. +/// Resolves cache glob patterns to be workspace-root-relative, parsing each distinct pattern once. +/// +/// `wax` compiles a regular expression both when it parses a glob and when it partitions one, +/// and only the partition's text is kept here. The partition depends on the pattern alone, while +/// workspaces repeat the same patterns across packages and tasks, so loading a task graph would +/// otherwise compile the same expressions thousands of times. /// -/// The algorithm: -/// 1. Partition the glob into an invariant prefix and a variant part -/// 2. Join the invariant prefix with `package_dir` and clean the path -/// 3. Strip the `workspace_root` prefix from the cleaned path -/// 4. Re-escape the stripped prefix and rejoin with the variant -fn resolve_glob_to_workspace_relative( - pattern: &str, - package_dir: &AbsolutePath, - workspace_root: &AbsolutePath, -) -> Result { - // A trailing `/` is shorthand for all files under that directory - let expanded: Str; - let pattern = if pattern.ends_with('/') { - expanded = vt_str::format!("{pattern}**"); - expanded.as_str() - } else { - pattern - }; - - let partitioned = partition_glob(pattern)?; - - let joined = package_dir.join(partitioned.invariant_prefix.as_str()).clean(); - let stripped = joined.strip_prefix(workspace_root).map_err(|_| { - ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } - })?; - - // Re-escape the prefix path for use in a glob pattern - let stripped = stripped.ok_or_else(|| ResolveTaskConfigError::GlobOutsideWorkspace { - pattern: Str::from(pattern), - })?; - - let escaped_prefix = wax::escape(stripped.as_str()); - - let result = match &partitioned.variant { - Some(variant_glob) if escaped_prefix.is_empty() => variant_glob.clone(), - Some(variant_glob) => vt_str::format!("{escaped_prefix}/{variant_glob}"), - None if escaped_prefix.is_empty() => Str::from("**"), - None => Str::from(escaped_prefix.as_ref()), - }; - - Ok(result) +/// Create one for each task graph load or plan and pass it by reference. It is neither `Send` nor +/// `Sync`, so its cache needs no lock. +#[derive(Debug, Default)] +pub struct GlobResolver { + partitioned: RefCell, FxBuildHasher>>, +} + +impl GlobResolver { + /// Resolve a single glob pattern to be workspace-root-relative. + /// + /// The algorithm: + /// 1. Partition the glob into an invariant prefix and a variant part + /// 2. Join the invariant prefix with `package_dir` and clean the path + /// 3. Strip the `workspace_root` prefix from the cleaned path + /// 4. Re-escape the stripped prefix and rejoin with the variant + fn resolve( + &self, + pattern: &str, + package_dir: &AbsolutePath, + workspace_root: &AbsolutePath, + ) -> Result { + // A trailing `/` is shorthand for all files under that directory + let expanded: Str; + let pattern = if pattern.ends_with('/') { + expanded = vt_str::format!("{pattern}**"); + expanded.as_str() + } else { + pattern + }; + + let partitioned = self.partition(pattern)?; + + let joined = package_dir.join(partitioned.invariant_prefix.as_str()).clean(); + let stripped = joined.strip_prefix(workspace_root).map_err(|_| { + ResolveTaskConfigError::GlobOutsideWorkspace { pattern: Str::from(pattern) } + })?; + + // Re-escape the prefix path for use in a glob pattern + let stripped = stripped.ok_or_else(|| ResolveTaskConfigError::GlobOutsideWorkspace { + pattern: Str::from(pattern), + })?; + + let escaped_prefix = wax::escape(stripped.as_str()); + + let result = match &partitioned.variant { + Some(variant_glob) if escaped_prefix.is_empty() => variant_glob.clone(), + Some(variant_glob) => vt_str::format!("{escaped_prefix}/{variant_glob}"), + None if escaped_prefix.is_empty() => Str::from("**"), + None => Str::from(escaped_prefix.as_ref()), + }; + + Ok(result) + } + + /// Parse and partition `pattern`, at most once per distinct pattern. + fn partition(&self, pattern: &str) -> Result, ResolveTaskConfigError> { + let mut partitioned = self.partitioned.borrow_mut(); + // `entry_ref` hashes the pattern once and builds the owned key only for a new pattern. + let partition = match partitioned.entry_ref(pattern) { + EntryRef::Occupied(entry) => entry.into_mut(), + EntryRef::Vacant(entry) => { + let invalid = |source| ResolveTaskConfigError::InvalidGlob { + pattern: Str::from(pattern), + source: Box::new(source), + }; + let (invariant_prefix, variant) = + wax::Glob::new(pattern).map_err(invalid)?.partition(); + entry.insert(Rc::new(PartitionedGlob { + // The prefix is made of the pattern's own literal components, so this is + // lossless. + invariant_prefix: Str::from(invariant_prefix.to_string_lossy().as_ref()), + variant: variant.map(|glob| Str::from(glob.to_string().as_str())), + })) + } + }; + Ok(Rc::clone(partition)) + } } /// A glob pattern split into its literal prefix and the glob that follows it. +#[derive(Debug)] struct PartitionedGlob { invariant_prefix: Str, variant: Option, } -/// Parse and partition `pattern`, at most once per distinct pattern in the process. -/// -/// `wax` compiles a regular expression both when it parses a glob and when it partitions one, -/// and only the partition's text is kept here. The result depends on the pattern alone, while -/// workspaces repeat the same patterns across packages and tasks, so loading a task graph would -/// otherwise compile the same expressions thousands of times. -fn partition_glob(pattern: &str) -> Result, ResolveTaskConfigError> { - static PARTITIONED: LazyLock>>> = - LazyLock::new(Mutex::default); - - if let Some(partitioned) = - PARTITIONED.lock().unwrap_or_else(PoisonError::into_inner).get(pattern) - { - return Ok(Arc::clone(partitioned)); - } - - let invalid = |source| ResolveTaskConfigError::InvalidGlob { - pattern: Str::from(pattern), - source: Box::new(source), - }; - let (invariant_prefix, variant) = wax::Glob::new(pattern).map_err(invalid)?.partition(); - let partitioned = Arc::new(PartitionedGlob { - // The prefix is made of the pattern's own literal components, so this is lossless. - invariant_prefix: Str::from(invariant_prefix.to_string_lossy().as_ref()), - variant: variant.map(|glob| Str::from(glob.to_string().as_str())), - }); - PARTITIONED - .lock() - .unwrap_or_else(PoisonError::into_inner) - .insert(Str::from(pattern), Arc::clone(&partitioned)); - Ok(partitioned) -} - #[derive(Debug, Clone, Serialize)] pub struct EnvConfig { /// environment variable names to be fingerprinted and passed to the task, with defaults populated @@ -393,6 +410,7 @@ impl ResolvedTaskConfig { package_dir: &Arc, package_json_script: &str, workspace_root: &AbsolutePath, + glob_resolver: &GlobResolver, ) -> Result { Ok(Self { commands: vec![package_json_script.into()].into(), @@ -400,6 +418,7 @@ impl ResolvedTaskConfig { UserTaskOptions::default(), package_dir, workspace_root, + glob_resolver, )?, }) } @@ -413,6 +432,7 @@ impl ResolvedTaskConfig { user_config: UserTaskConfig, package_dir: &Arc, workspace_root: &AbsolutePath, + glob_resolver: &GlobResolver, ) -> Result { let UserTaskConfig { command, options, top_level_cache_fields: _ } = user_config; let commands = match command { @@ -421,7 +441,12 @@ impl ResolvedTaskConfig { }; Ok(Self { commands, - resolved_options: ResolvedTaskOptions::resolve(options, package_dir, workspace_root)?, + resolved_options: ResolvedTaskOptions::resolve( + options, + package_dir, + workspace_root, + glob_resolver, + )?, }) } } @@ -538,6 +563,20 @@ mod tests { } } + /// Resolves `user_inputs` with a fresh [`GlobResolver`]. + fn resolve_inputs( + user_inputs: Option<&UserInputsConfig>, + package_dir: &AbsolutePath, + workspace_root: &AbsolutePath, + ) -> Result { + ResolvedGlobConfig::from_user_config( + user_inputs, + package_dir, + workspace_root, + &GlobResolver::default(), + ) + } + #[test] fn test_resolved_input_config_default_auto() { let config = ResolvedGlobConfig::default_auto(); @@ -550,7 +589,7 @@ mod tests { fn test_resolved_input_config_from_none() { let (pkg, ws) = test_paths(); // None means default to auto-inference - let config = ResolvedGlobConfig::from_user_config(None, &pkg, &ws).unwrap(); + let config = resolve_inputs(None, &pkg, &ws).unwrap(); assert!(config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); @@ -561,7 +600,7 @@ mod tests { let (pkg, ws) = test_paths(); // Empty array means no inputs, inference disabled let user_inputs = vec![]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); @@ -571,7 +610,7 @@ mod tests { fn test_resolved_input_config_auto_only() { let (pkg, ws) = test_paths(); let user_inputs = vec![UserInputEntry::Auto(AutoTracking { auto: true })]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); @@ -581,7 +620,7 @@ mod tests { fn test_resolved_input_config_auto_false_ignored() { let (pkg, ws) = test_paths(); let user_inputs = vec![UserInputEntry::Auto(AutoTracking { auto: false })]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert!(config.positive_globs.is_empty()); assert!(config.negative_globs.is_empty()); @@ -595,7 +634,7 @@ mod tests { UserInputEntry::Glob("src/**/*.ts".into()), UserInputEntry::Glob("package.json".into()), ]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert_eq!(config.positive_globs.len(), 2); // Globs should now be workspace-root-relative @@ -611,7 +650,7 @@ mod tests { UserInputEntry::Glob("src/**".into()), UserInputEntry::Glob("!src/**/*.test.ts".into()), ]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert_eq!(config.positive_globs.len(), 1); assert!(config.positive_globs.contains("packages/my-pkg/src/**")); @@ -627,7 +666,7 @@ mod tests { UserInputEntry::Auto(AutoTracking { auto: true }), UserInputEntry::Glob("!node_modules/**".into()), ]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(config.includes_auto); assert_eq!(config.positive_globs.len(), 1); assert!(config.positive_globs.contains("packages/my-pkg/package.json")); @@ -643,7 +682,7 @@ mod tests { UserInputEntry::Glob("src/**/*.ts".into()), UserInputEntry::Auto(AutoTracking { auto: true }), ]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(config.includes_auto); } @@ -651,7 +690,7 @@ mod tests { fn test_resolved_input_config_dotdot_resolution() { let (pkg, ws) = test_paths(); let user_inputs = vec![UserInputEntry::Glob("../shared/src/**".into())]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert_eq!(config.positive_globs.len(), 1); assert!( config.positive_globs.contains("packages/shared/src/**"), @@ -662,14 +701,16 @@ mod tests { #[test] fn test_resolved_input_config_same_pattern_in_two_packages() { - // A pattern is parsed once per process; each package still resolves it against its own + // A resolver parses a pattern once; each package still resolves it against its own // directory, and the workspace check still applies to each use. let (pkg, ws) = test_paths(); let other = pkg.parent().unwrap().join("other-pkg"); + let globs = GlobResolver::default(); let user_inputs = vec![UserInputEntry::Glob("src/**".into())]; - let own = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let own = + ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws, &globs).unwrap(); let from_other = - ResolvedGlobConfig::from_user_config(Some(&user_inputs), &other, &ws).unwrap(); + ResolvedGlobConfig::from_user_config(Some(&user_inputs), &other, &ws, &globs).unwrap(); assert!(own.positive_globs.contains("packages/my-pkg/src/**"), "{:?}", own.positive_globs); assert!( from_other.positive_globs.contains("packages/other-pkg/src/**"), @@ -677,15 +718,19 @@ mod tests { from_other.positive_globs ); let user_inputs = vec![UserInputEntry::Glob("../../../outside/**".into())]; - assert!(ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).is_err()); - assert!(ResolvedGlobConfig::from_user_config(Some(&user_inputs), &other, &ws).is_err()); + assert!( + ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws, &globs).is_err() + ); + assert!( + ResolvedGlobConfig::from_user_config(Some(&user_inputs), &other, &ws, &globs).is_err() + ); } #[test] fn test_resolved_input_config_outside_workspace_error() { let (pkg, ws) = test_paths(); let user_inputs = vec![UserInputEntry::Glob("../../../outside/**".into())]; - let result = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws); + let result = resolve_inputs(Some(&user_inputs), &pkg, &ws); assert!(result.is_err()); assert!(matches!(result.unwrap_err(), ResolveTaskConfigError::GlobOutsideWorkspace { .. })); } @@ -697,7 +742,7 @@ mod tests { pattern: "configs/tsconfig.json".into(), base: InputBase::Workspace, })]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(!config.includes_auto); assert_eq!(config.positive_globs.len(), 1); // Workspace-base: should NOT have the package prefix @@ -715,7 +760,7 @@ mod tests { pattern: "!dist/**".into(), base: InputBase::Workspace, })]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert_eq!(config.negative_globs.len(), 1); assert!( config.negative_globs.contains("dist/**"), @@ -732,7 +777,7 @@ mod tests { pattern: "src/**/*.ts".into(), base: InputBase::Package, })]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert_eq!(config.positive_globs.len(), 1); assert!( config.positive_globs.contains("packages/my-pkg/src/**/*.ts"), @@ -756,7 +801,7 @@ mod tests { base: InputBase::Workspace, }), ]; - let config = ResolvedGlobConfig::from_user_config(Some(&user_inputs), &pkg, &ws).unwrap(); + let config = resolve_inputs(Some(&user_inputs), &pkg, &ws).unwrap(); assert!(config.includes_auto); assert_eq!(config.positive_globs.len(), 2); assert!(config.positive_globs.contains("packages/my-pkg/src/**")); diff --git a/crates/vt_graph/src/lib.rs b/crates/vt_graph/src/lib.rs index 7169a95b0..9f4323bff 100644 --- a/crates/vt_graph/src/lib.rs +++ b/crates/vt_graph/src/lib.rs @@ -7,7 +7,7 @@ mod specifier; use std::{convert::Infallible, sync::Arc}; use config::{ - ResolvedGlobalCacheConfig, ResolvedTaskConfig, UserRunConfig, UserTaskConfig, + GlobResolver, ResolvedGlobalCacheConfig, ResolvedTaskConfig, UserRunConfig, UserTaskConfig, UserTaskDefinition, user::TopLevelCacheFieldsCollector, }; use petgraph::{ @@ -319,6 +319,7 @@ impl IndexedTaskGraph { let resolved_global_cache = ResolvedGlobalCacheConfig::resolve_from(root_cache.as_ref()); let mut top_level_cache_fields = TopLevelCacheFieldsCollector::default(); + let glob_resolver = GlobResolver::default(); // Second pass: create task nodes (cache is NOT applied here; it's applied at plan time) for (package_index, package_dir, user_config) in package_configs { @@ -369,6 +370,7 @@ impl IndexedTaskGraph { task_user_config, &package_dir, &workspace_root.path, + &glob_resolver, ) .map_err(|err| TaskGraphLoadError::ResolveConfigError { error: err, @@ -402,6 +404,7 @@ impl IndexedTaskGraph { &package_dir, package_json_script, &workspace_root.path, + &glob_resolver, ) .map_err(|err| TaskGraphLoadError::ResolveConfigError { error: err, diff --git a/crates/vt_plan/src/context.rs b/crates/vt_plan/src/context.rs index f6c4526eb..21da89138 100644 --- a/crates/vt_plan/src/context.rs +++ b/crates/vt_plan/src/context.rs @@ -1,9 +1,11 @@ -use std::{env::JoinPathsError, ffi::OsStr, ops::Range, sync::Arc}; +use std::{env::JoinPathsError, ffi::OsStr, ops::Range, rc::Rc, sync::Arc}; use rustc_hash::FxHashMap; use vt_casefold::EnvName; use vt_graph::{ - IndexedTaskGraph, TaskNodeIndex, config::ResolvedGlobalCacheConfig, query::TaskQuery, + IndexedTaskGraph, TaskNodeIndex, + config::{GlobResolver, ResolvedGlobalCacheConfig}, + query::TaskQuery, }; use vt_path::AbsolutePath; use vt_str::Str; @@ -51,6 +53,10 @@ pub struct PlanContext<'a> { indexed_task_graph: &'a IndexedTaskGraph, + /// Shared with every nested context, so each glob in synthetic commands' cache configs is + /// parsed once per plan. + glob_resolver: Rc, + /// Final resolved global cache config, combining the graph's config with any CLI override. resolved_global_cache: ResolvedGlobalCacheConfig, @@ -91,6 +97,7 @@ impl<'a> PlanContext<'a> { callbacks, task_call_stack: Vec::new(), indexed_task_graph, + glob_resolver: Rc::default(), extra_args: Arc::default(), resolved_global_cache, resolved_remote_cache: None, @@ -123,6 +130,10 @@ impl<'a> PlanContext<'a> { self.workspace_path } + pub fn glob_resolver(&self) -> &GlobResolver { + &self.glob_resolver + } + /// Push a new frame onto the task call stack. pub fn push_stack_frame(&mut self, task_node_index: TaskNodeIndex, command_span: Range) { self.task_call_stack.push((task_node_index, command_span)); @@ -198,6 +209,7 @@ impl<'a> PlanContext<'a> { callbacks: self.callbacks, task_call_stack: self.task_call_stack.clone(), indexed_task_graph: self.indexed_task_graph, + glob_resolver: Rc::clone(&self.glob_resolver), extra_args: Arc::clone(&self.extra_args), resolved_global_cache: self.resolved_global_cache.clone(), resolved_remote_cache: self.resolved_remote_cache.clone(), diff --git a/crates/vt_plan/src/lib.rs b/crates/vt_plan/src/lib.rs index e58884c94..61d5f424f 100644 --- a/crates/vt_plan/src/lib.rs +++ b/crates/vt_plan/src/lib.rs @@ -23,7 +23,7 @@ use plan_request::{CacheOverride, PlanRequest, QueryPlanRequest, SyntheticPlanRe use rustc_hash::FxHashMap; use serde::{Serialize, ser::SerializeMap as _}; use vt_casefold::EnvName; -use vt_graph::{TaskGraphLoadError, display::TaskDisplay}; +use vt_graph::{TaskGraphLoadError, config::GlobResolver, display::TaskDisplay}; use vt_path::AbsolutePath; use vt_str::Str; @@ -269,5 +269,6 @@ pub fn plan_synthetic( cwd, ParentCacheConfig::None, None, + &GlobResolver::default(), ) } diff --git a/crates/vt_plan/src/plan.rs b/crates/vt_plan/src/plan.rs index c76ba93f8..2f3fb170f 100644 --- a/crates/vt_plan/src/plan.rs +++ b/crates/vt_plan/src/plan.rs @@ -18,8 +18,8 @@ use vt_casefold::EnvName; use vt_graph::{ TaskNodeIndex, TaskSource, config::{ - CacheConfig, EnabledCacheConfig, ResolvedGlobConfig, ResolvedGlobalCacheConfig, - ResolvedTaskOptions, + CacheConfig, EnabledCacheConfig, GlobResolver, ResolvedGlobConfig, + ResolvedGlobalCacheConfig, ResolvedTaskOptions, user::{UserCacheConfig, UserTaskOptions}, }, query::TaskQuery, @@ -307,6 +307,7 @@ async fn plan_task_as_execution_node( package_path, parent_cache_config, context.resolved_remote_cache(), + context.glob_resolver(), )?; ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn_execution)) } @@ -471,6 +472,7 @@ fn resolve_synthetic_cache_config( synthetic_cache_config: UserCacheConfig, package_dir: &AbsolutePath, workspace_path: &AbsolutePath, + glob_resolver: &GlobResolver, ) -> Result, Error> { match parent { ParentCacheConfig::None => { @@ -483,6 +485,7 @@ fn resolve_synthetic_cache_config( }, &package_dir.into(), workspace_path, + glob_resolver, ) .map_err(Error::ResolveTaskConfig)? .cache_config) @@ -508,6 +511,7 @@ fn resolve_synthetic_cache_config( Some(&input), package_dir, workspace_path, + glob_resolver, ) .map_err(Error::ResolveTaskConfig)?; // Merge globs but preserve the parent's includes_auto — @@ -528,6 +532,7 @@ fn resolve_synthetic_cache_config( Some(&output), package_dir, workspace_path, + glob_resolver, ) .map_err(Error::ResolveTaskConfig)?; parent_config @@ -560,6 +565,7 @@ pub fn plan_synthetic_request( package_dir: &AbsolutePath, parent_cache_config: ParentCacheConfig, resolved_remote_cache: Option<&ResolvedRemoteCacheConfig>, + glob_resolver: &GlobResolver, ) -> Result { let SyntheticPlanRequest { program, args, cache_config, envs } = synthetic_plan_request; @@ -571,6 +577,7 @@ pub fn plan_synthetic_request( cache_config, package_dir, workspace_path, + glob_resolver, )?; let resolved_options = ResolvedTaskOptions { cwd: Arc::clone(cwd), cache_config: resolved_cache_config }; @@ -955,7 +962,7 @@ mod tests { use rustc_hash::FxHashSet; use vt_graph::config::{ - CacheConfig, EnabledCacheConfig, EnvConfig, ResolvedGlobConfig, + CacheConfig, EnabledCacheConfig, EnvConfig, GlobResolver, ResolvedGlobConfig, user::{UserCacheConfig, UserInputEntry}, }; use vt_path::AbsolutePathBuf; @@ -1016,6 +1023,7 @@ mod tests { }), &pkg, &ws, + &GlobResolver::default(), ) .unwrap() .unwrap(); @@ -1039,6 +1047,7 @@ mod tests { }), &pkg, &ws, + &GlobResolver::default(), ) .unwrap() .unwrap(); @@ -1062,6 +1071,7 @@ mod tests { }), &pkg, &ws, + &GlobResolver::default(), ) .unwrap() .unwrap(); @@ -1091,6 +1101,7 @@ mod tests { }), &pkg, &ws, + &GlobResolver::default(), ) .unwrap() .unwrap(); @@ -1118,6 +1129,7 @@ mod tests { }), &pkg, &ws, + &GlobResolver::default(), ) .unwrap() .unwrap(); @@ -1143,6 +1155,7 @@ mod tests { }), &pkg, &ws, + &GlobResolver::default(), ) .unwrap(); assert!(result.is_none()); @@ -1157,6 +1170,7 @@ mod tests { UserCacheConfig::disabled(), &pkg, &ws, + &GlobResolver::default(), ) .unwrap(); assert!(result.is_none()); @@ -1177,6 +1191,7 @@ mod tests { }), &pkg, &ws, + &GlobResolver::default(), ) .unwrap() .unwrap(); @@ -1198,6 +1213,7 @@ mod tests { }), &pkg, &ws, + &GlobResolver::default(), ) .unwrap() .unwrap();