Skip to content

Commit c3ffee5

Browse files
branchseerclaude
andcommitted
feat(cache): support .. prefix in input glob patterns
Glob patterns like `../shared/src/**` and `!../shared/dist/**` now work correctly by using wax's `partition()` to split the invariant base from the wildcard, then resolving `..` components with `path_clean`. This applies to both positive globs (in compute_globbed_inputs) and negative globs (in both compute_globbed_inputs and PostRunFingerprint::create). Also cleans fspy-tracked paths before negative glob matching, since fspy reports paths with literal `..` (e.g., `packages/sub-pkg/../shared/`). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent e11d11d commit c3ffee5

14 files changed

+333
-56
lines changed

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/vite_task/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ fspy = { workspace = true }
2323
futures-util = { workspace = true }
2424
once_cell = { workspace = true }
2525
owo-colors = { workspace = true }
26+
path-clean = { workspace = true }
2627
pty_terminal_test_client = { workspace = true }
2728
rayon = { workspace = true }
2829
rusqlite = { workspace = true, features = ["bundled"] }

crates/vite_task/src/session/execute/fingerprint.rs

Lines changed: 15 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,14 @@ use std::{
1313
use bincode::{Decode, Encode};
1414
use rayon::iter::{IntoParallelRefIterator, ParallelIterator};
1515
use serde::{Deserialize, Serialize};
16-
use vite_glob::GlobPatternSet;
1716
use vite_path::{AbsolutePath, RelativePathBuf};
1817
use vite_str::Str;
1918
use vite_task_graph::config::ResolvedInputConfig;
2019

21-
use super::spawn::PathRead;
20+
use super::{
21+
glob_inputs::{is_excluded, resolve_negative_globs},
22+
spawn::PathRead,
23+
};
2224
use crate::collections::HashMap;
2325

2426
/// Post-run fingerprint capturing file state after execution.
@@ -88,28 +90,21 @@ impl PostRunFingerprint {
8890
return Ok(Self { inferred_inputs: HashMap::default() });
8991
}
9092

91-
// Build negative pattern matcher for filtering inferred inputs
92-
let patterns: Vec<Str> = input_config.negative_globs.iter().cloned().collect();
93-
let negative_matcher =
94-
if patterns.is_empty() { None } else { Some(GlobPatternSet::new(&patterns)?) };
95-
96-
// Compute the package path relative to workspace root so we can convert
97-
// workspace-relative paths to package-relative paths for negative glob matching.
98-
// Negative globs like `!dist/**` are written relative to the package directory.
99-
let package_prefix = glob_base.strip_prefix(base_dir).ok().flatten();
93+
// Resolve negative globs relative to the package directory (glob_base),
94+
// handling `..` components so patterns like `!../shared/dist/**` work.
95+
let resolved_negatives = resolve_negative_globs(&input_config.negative_globs, glob_base)?;
10096

10197
let inferred_inputs = path_reads
10298
.par_iter()
10399
.filter(|(path, _)| {
104-
// Apply negative patterns to exclude from inferred inputs.
105-
// Paths must be made package-relative before matching, since negative
106-
// globs are relative to the package directory, not the workspace root.
107-
negative_matcher.as_ref().is_none_or(|matcher| {
108-
let path_to_match =
109-
package_prefix.as_ref().and_then(|prefix| path.strip_prefix(prefix));
110-
path_to_match
111-
.map_or(true, |pkg_relative| !matcher.is_match(pkg_relative.as_str()))
112-
})
100+
if resolved_negatives.is_empty() {
101+
return true;
102+
}
103+
// Convert workspace-relative path to absolute for negative glob matching.
104+
// Clean the path to resolve any `..` components from fspy-tracked paths
105+
// (e.g., `packages/sub-pkg/../shared/dist/output.js`).
106+
let absolute = path_clean::PathClean::clean(base_dir.join(path).as_path());
107+
!is_excluded(&absolute, &resolved_negatives)
113108
})
114109
.map(|(relative_path, path_read)| {
115110
let full_path = Arc::<AbsolutePath>::from(base_dir.join(relative_path));

crates/vite_task/src/session/execute/glob_inputs.rs

Lines changed: 137 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -10,15 +10,63 @@ use std::{
1010
io::{self, Read},
1111
};
1212

13-
use vite_glob::GlobPatternSet;
13+
use path_clean::PathClean;
1414
#[cfg(test)]
1515
use vite_path::AbsolutePathBuf;
1616
use vite_path::{AbsolutePath, RelativePathBuf};
1717
use vite_str::Str;
18-
use wax::{Glob, walk::Entry as _};
18+
use wax::{Glob, Program as _};
19+
20+
/// A negative glob pattern resolved to an absolute base directory and optional variant.
21+
///
22+
/// For example, `../shared/dist/**` relative to `/ws/packages/app` resolves to:
23+
/// - `resolved_base`: `/ws/packages/shared/dist`
24+
/// - `variant`: `Some(Glob("**"))`
25+
#[expect(clippy::disallowed_types, reason = "path_clean returns std::path::PathBuf")]
26+
pub struct ResolvedNegativeGlob {
27+
resolved_base: std::path::PathBuf,
28+
variant: Option<Glob<'static>>,
29+
}
30+
31+
/// Resolve negative globs relative to `base_dir`, handling `..` components.
32+
///
33+
/// Uses [`Glob::partition`] to split each pattern into an invariant base and an
34+
/// optional variant, then resolves the base with [`path_clean`] to normalize `..`.
35+
pub fn resolve_negative_globs(
36+
patterns: &std::collections::BTreeSet<Str>,
37+
base_dir: &AbsolutePath,
38+
) -> anyhow::Result<Vec<ResolvedNegativeGlob>> {
39+
patterns
40+
.iter()
41+
.map(|pattern| {
42+
let glob = Glob::new(pattern.as_str())?.into_owned();
43+
let (base_pathbuf, variant) = glob.partition();
44+
let base_str = base_pathbuf.to_str().unwrap_or(".");
45+
let resolved_base = if base_str.is_empty() {
46+
base_dir.as_path().to_path_buf()
47+
} else {
48+
base_dir.join(base_str).as_path().clean()
49+
};
50+
Ok(ResolvedNegativeGlob { resolved_base, variant: variant.map(Glob::into_owned) })
51+
})
52+
.collect()
53+
}
54+
55+
/// Check if an absolute path is excluded by any of the resolved negative globs.
56+
#[expect(clippy::disallowed_types, reason = "matching against std::path::Path from wax walker")]
57+
pub fn is_excluded(path: &std::path::Path, negatives: &[ResolvedNegativeGlob]) -> bool {
58+
negatives.iter().any(|neg| {
59+
path.strip_prefix(&neg.resolved_base).ok().is_some_and(|remainder| {
60+
neg.variant.as_ref().map_or(remainder.as_os_str().is_empty(), |v| v.is_match(remainder))
61+
})
62+
})
63+
}
1964

2065
/// Compute globbed inputs by walking positive glob patterns and filtering with negative patterns.
2166
///
67+
/// Glob patterns may contain `..` to reference files outside the package directory
68+
/// (e.g., `../shared/src/**` to include a sibling package's source files).
69+
///
2270
/// # Arguments
2371
/// * `base_dir` - The package directory where the task is defined (globs are relative to this)
2472
/// * `workspace_root` - The workspace root for computing relative paths in the result
@@ -40,10 +88,7 @@ use wax::{Glob, walk::Entry as _};
4088
/// )?;
4189
/// // Returns: { "packages/foo/src/index.ts" => 0x1234..., ... }
4290
/// ```
43-
#[expect(
44-
clippy::disallowed_methods,
45-
reason = "str::replace needed for path normalization; cow_replace unavailable in this crate"
46-
)]
91+
#[expect(clippy::disallowed_types, reason = "path_clean and wax walker return std::path types")]
4792
pub fn compute_globbed_inputs(
4893
base_dir: &AbsolutePath,
4994
workspace_root: &AbsolutePath,
@@ -55,44 +100,44 @@ pub fn compute_globbed_inputs(
55100
return Ok(BTreeMap::new());
56101
}
57102

58-
// Build negative pattern matcher if there are negative patterns
59-
let negative_patterns: Vec<Str> = negative_globs.iter().cloned().collect();
60-
let negative_matcher = if negative_patterns.is_empty() {
61-
None
62-
} else {
63-
Some(GlobPatternSet::new(&negative_patterns)?)
64-
};
103+
// Resolve negative globs, normalizing `..` components
104+
let resolved_negatives = resolve_negative_globs(negative_globs, base_dir)?;
65105

66106
let mut result = BTreeMap::new();
67107

68-
// Walk each positive glob pattern
108+
// Walk each positive glob pattern, resolving `..` via partition + path_clean
69109
for pattern in positive_globs {
70-
let glob = Glob::new(pattern.as_str())?;
71-
for entry in glob.walk(base_dir.as_path()) {
72-
let Ok(entry) = entry else {
73-
continue;
74-
};
110+
let glob = Glob::new(pattern.as_str())?.into_owned();
111+
let (base_pathbuf, variant) = glob.partition();
112+
let base_str = base_pathbuf.to_str().unwrap_or(".");
113+
let resolved_base = if base_str.is_empty() {
114+
base_dir.as_path().to_path_buf()
115+
} else {
116+
base_dir.join(base_str).as_path().clean()
117+
};
118+
119+
let entries: Box<dyn Iterator<Item = std::path::PathBuf>> = match variant {
120+
Some(variant_glob) => Box::new(
121+
variant_glob
122+
.walk(&resolved_base)
123+
.filter_map(Result::ok)
124+
.map(wax::walk::Entry::into_path),
125+
),
126+
None => {
127+
// No wildcard: exact file path
128+
Box::new(std::iter::once(resolved_base))
129+
}
130+
};
75131

132+
for absolute_path in entries {
76133
// Skip non-files
77-
if !entry.file_type().is_file() {
134+
if !absolute_path.is_file() {
78135
continue;
79136
}
80137

81-
let absolute_path = entry.path();
82-
83-
// Compute path relative to base_dir for negative pattern matching
84-
let Ok(relative_to_base) = absolute_path.strip_prefix(base_dir.as_path()) else {
85-
continue; // Skip if path is outside base_dir
86-
};
87-
88-
// Apply negative patterns (relative to base_dir)
89-
if let Some(ref matcher) = negative_matcher {
90-
// Convert to forward slashes for consistent matching
91-
let path_str = relative_to_base.to_string_lossy();
92-
let normalized = path_str.replace('\\', "/");
93-
if matcher.is_match(&normalized) {
94-
continue;
95-
}
138+
// Apply negative patterns
139+
if is_excluded(&absolute_path, &resolved_negatives) {
140+
continue;
96141
}
97142

98143
// Compute path relative to workspace_root for the result
@@ -105,7 +150,7 @@ pub fn compute_globbed_inputs(
105150
};
106151

107152
// Hash file content
108-
match hash_file_content(absolute_path) {
153+
match hash_file_content(&absolute_path) {
109154
Ok(hash) => {
110155
result.insert(relative_to_workspace, hash);
111156
}
@@ -376,6 +421,62 @@ mod tests {
376421
assert!(result.is_empty());
377422
}
378423

424+
/// Creates a workspace with a sibling package for testing `..` globs
425+
fn create_workspace_with_sibling() -> (TempDir, AbsolutePathBuf, AbsolutePathBuf) {
426+
let temp_dir = TempDir::new().unwrap();
427+
let workspace_root = AbsolutePathBuf::new(temp_dir.path().to_path_buf()).unwrap();
428+
429+
// Create sub-pkg
430+
let sub_pkg = workspace_root.join("packages/sub-pkg");
431+
fs::create_dir_all(sub_pkg.join("src")).unwrap();
432+
fs::write(sub_pkg.join("src/main.ts"), "export const sub = 1;").unwrap();
433+
434+
// Create sibling shared package
435+
let shared = workspace_root.join("packages/shared");
436+
fs::create_dir_all(shared.join("src")).unwrap();
437+
fs::create_dir_all(shared.join("dist")).unwrap();
438+
fs::write(shared.join("src/utils.ts"), "export const shared = 1;").unwrap();
439+
fs::write(shared.join("dist/output.js"), "// output").unwrap();
440+
441+
let sub_pkg_abs = AbsolutePathBuf::new(sub_pkg.into_path_buf()).unwrap();
442+
(temp_dir, workspace_root, sub_pkg_abs)
443+
}
444+
445+
#[test]
446+
fn test_dotdot_positive_glob_matches_sibling_package() {
447+
let (_temp, workspace, sub_pkg) = create_workspace_with_sibling();
448+
let positive: std::collections::BTreeSet<Str> =
449+
std::iter::once("../shared/src/**".into()).collect();
450+
let negative = std::collections::BTreeSet::new();
451+
452+
let result = compute_globbed_inputs(&sub_pkg, &workspace, &positive, &negative).unwrap();
453+
eprintln!("dotdot positive result: {result:#?}");
454+
assert!(
455+
result.contains_key(&RelativePathBuf::new("packages/shared/src/utils.ts").unwrap()),
456+
"should find sibling package file via ../shared/src/**"
457+
);
458+
}
459+
460+
#[test]
461+
fn test_dotdot_negative_glob_excludes_from_sibling() {
462+
let (_temp, workspace, sub_pkg) = create_workspace_with_sibling();
463+
let positive: std::collections::BTreeSet<Str> =
464+
std::iter::once("../shared/**".into()).collect();
465+
let negative: std::collections::BTreeSet<Str> =
466+
std::iter::once("../shared/dist/**".into()).collect();
467+
468+
let result = compute_globbed_inputs(&sub_pkg, &workspace, &positive, &negative).unwrap();
469+
eprintln!("dotdot negative result: {result:#?}");
470+
assert!(
471+
result.contains_key(&RelativePathBuf::new("packages/shared/src/utils.ts").unwrap()),
472+
"should include non-excluded sibling file"
473+
);
474+
assert!(
475+
!result.contains_key(&RelativePathBuf::new("packages/shared/dist/output.js").unwrap()),
476+
"should exclude dist via ../shared/dist/**"
477+
);
478+
}
479+
379480
#[test]
380481
fn test_overlapping_positive_globs_deduplicates() {
381482
let (_temp, workspace, package) = create_test_workspace();
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{
2+
"name": "shared",
3+
"private": true
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export const shared = 'initial';

crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/packages/sub-pkg/vite-task.json

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,21 @@
44
"command": "print-file src/main.ts dist/output.js",
55
"inputs": [{ "auto": true }, "!dist/**"],
66
"cache": true
7+
},
8+
"dotdot-positive": {
9+
"command": "print-file ../shared/src/utils.ts",
10+
"inputs": ["../shared/src/**"],
11+
"cache": true
12+
},
13+
"dotdot-positive-negative": {
14+
"command": "print-file ../shared/src/utils.ts ../shared/dist/output.js",
15+
"inputs": ["../shared/**", "!../shared/dist/**"],
16+
"cache": true
17+
},
18+
"dotdot-auto-negative": {
19+
"command": "print-file ../shared/src/utils.ts ../shared/dist/output.js",
20+
"inputs": [{ "auto": true }, "!../shared/dist/**"],
21+
"cache": true
722
}
823
}
924
}

crates/vite_task_bin/tests/e2e_snapshots/fixtures/inputs-negative-glob-subpackage/snapshots.toml

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,72 @@ steps = [
2727
# Cache miss: inferred input changed
2828
"vp run sub-pkg#auto-with-negative",
2929
]
30+
31+
# .. prefix positive globs: inputs: ["../shared/src/**"]
32+
# - Should find files in sibling package via ..
33+
# - Cache miss when sibling file changes, hit when unrelated file changes
34+
[[e2e]]
35+
name = "dotdot positive glob - miss on sibling file change"
36+
steps = [
37+
"vp run sub-pkg#dotdot-positive",
38+
# Modify a file that matches ../shared/src/**
39+
"replace-file-content packages/shared/src/utils.ts initial modified",
40+
# Cache miss: matched file changed
41+
"vp run sub-pkg#dotdot-positive",
42+
]
43+
44+
[[e2e]]
45+
name = "dotdot positive glob - hit on unmatched file change"
46+
steps = [
47+
"vp run sub-pkg#dotdot-positive",
48+
# Modify a file NOT matched by ../shared/src/**
49+
"replace-file-content packages/shared/dist/output.js initial modified",
50+
# Cache hit: file not in inputs
51+
"vp run sub-pkg#dotdot-positive",
52+
]
53+
54+
# .. prefix positive + negative globs: inputs: ["../shared/**", "!../shared/dist/**"]
55+
# - Positive glob matches sibling package files via ..
56+
# - Negative glob excludes sibling dist/ via ..
57+
[[e2e]]
58+
name = "dotdot positive negative - miss on non-excluded sibling file"
59+
steps = [
60+
"vp run sub-pkg#dotdot-positive-negative",
61+
# Modify file matching positive but NOT negative
62+
"replace-file-content packages/shared/src/utils.ts initial modified",
63+
# Cache miss: file changed
64+
"vp run sub-pkg#dotdot-positive-negative",
65+
]
66+
67+
[[e2e]]
68+
name = "dotdot positive negative - hit on excluded sibling file"
69+
steps = [
70+
"vp run sub-pkg#dotdot-positive-negative",
71+
# Modify file in excluded sibling dist/
72+
"replace-file-content packages/shared/dist/output.js initial modified",
73+
# Cache hit: excluded by !../shared/dist/**
74+
"vp run sub-pkg#dotdot-positive-negative",
75+
]
76+
77+
# .. prefix auto + negative: inputs: [{ "auto": true }, "!../shared/dist/**"]
78+
# - Auto-inferred files from sibling package are tracked
79+
# - Negative glob excludes sibling dist/ from inferred inputs
80+
[[e2e]]
81+
name = "dotdot auto negative - hit on excluded sibling inferred file"
82+
steps = [
83+
"vp run sub-pkg#dotdot-auto-negative",
84+
# Modify file in excluded sibling dist/
85+
"replace-file-content packages/shared/dist/output.js initial modified",
86+
# Cache hit: excluded by !../shared/dist/**
87+
"vp run sub-pkg#dotdot-auto-negative",
88+
]
89+
90+
[[e2e]]
91+
name = "dotdot auto negative - miss on non-excluded sibling inferred file"
92+
steps = [
93+
"vp run sub-pkg#dotdot-auto-negative",
94+
# Modify non-excluded sibling file
95+
"replace-file-content packages/shared/src/utils.ts initial modified",
96+
# Cache miss: inferred input changed
97+
"vp run sub-pkg#dotdot-auto-negative",
98+
]

0 commit comments

Comments
 (0)