Skip to content

Commit cc70fae

Browse files
authored
feat: improve cache miss messages with specific input change kinds (#209)
## Summary Cache miss messages now tell you **what changed** instead of a generic "content of input changed": - **Modified**: `cache miss: 'src/main.ts' modified` - **Added**: `cache miss: 'new-file.ts' added in src` - **Removed**: `cache miss: 'old-file.ts' removed from src` Before: ``` ✗ cache miss: content of input 'src/main.ts' changed, executing ``` After: ``` ✗ cache miss: 'src/main.ts' modified, executing ✗ cache miss: 'new-file.ts' added in 'src', executing ✗ cache miss: 'old-file.ts' removed from 'src', executing ``` Works for both explicit glob inputs and inferred (fspy-tracked) inputs. Files at the workspace root show "added in workspace root" / "removed from workspace root". ## Test plan - [x] E2E tests for all three change kinds (modified/added/removed) for both glob and inferred inputs - [x] All existing e2e snapshot tests updated and passing - [x] `cargo test`, `just lint` 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 09f1343 commit cc70fae

File tree

31 files changed

+266
-116
lines changed

31 files changed

+266
-116
lines changed

crates/vite_task/src/session/cache/display.rs

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
88
use vite_str::Str;
99
use vite_task_plan::cache_metadata::SpawnFingerprint;
1010

11-
use super::{CacheMiss, FingerprintMismatch};
11+
use super::{CacheMiss, FingerprintMismatch, InputChangeKind, split_path};
1212
use crate::session::event::CacheStatus;
1313

1414
/// Describes a single atomic change between two spawn fingerprints.
@@ -174,24 +174,34 @@ pub fn format_cache_status_inline(cache_status: &CacheStatus) -> Option<Str> {
174174
}
175175
}
176176
FingerprintMismatch::InputConfig => "inputs configuration changed",
177-
FingerprintMismatch::GlobbedInput { path } => {
178-
return Some(vite_str::format!(
179-
"✗ cache miss: content of input '{path}' changed, executing"
180-
));
181-
}
182-
FingerprintMismatch::PostRunFingerprint(diff) => {
183-
use crate::session::execute::fingerprint::PostRunFingerprintMismatch;
184-
match diff {
185-
PostRunFingerprintMismatch::InputContentChanged { path } => {
186-
return Some(vite_str::format!(
187-
"✗ cache miss: content of input '{path}' changed, executing"
188-
));
189-
}
190-
}
177+
FingerprintMismatch::InputChanged { kind, path } => {
178+
let desc = format_input_change_str(*kind, path.as_str());
179+
return Some(vite_str::format!("✗ cache miss: {desc}, executing"));
191180
}
192181
};
193182
Some(vite_str::format!("✗ cache miss: {reason}, executing"))
194183
}
195184
CacheStatus::Disabled(_) => Some(Str::from("⊘ cache disabled")),
196185
}
197186
}
187+
188+
/// Format an input change as a [`Str`] for inline display.
189+
pub fn format_input_change_str(kind: InputChangeKind, path: &str) -> Str {
190+
match kind {
191+
InputChangeKind::ContentModified => vite_str::format!("'{path}' modified"),
192+
InputChangeKind::Added => {
193+
let (dir, filename) = split_path(path);
194+
dir.map_or_else(
195+
|| vite_str::format!("'{filename}' added in workspace root"),
196+
|dir| vite_str::format!("'{filename}' added in '{dir}'"),
197+
)
198+
}
199+
InputChangeKind::Removed => {
200+
let (dir, filename) = split_path(path);
201+
dir.map_or_else(
202+
|| vite_str::format!("'{filename}' removed from workspace root"),
203+
|dir| vite_str::format!("'{filename}' removed from '{dir}'"),
204+
)
205+
}
206+
}
207+
}

crates/vite_task/src/session/cache/mod.rs

Lines changed: 56 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,18 +7,18 @@ use std::{collections::BTreeMap, fmt::Display, fs::File, io::Write, sync::Arc, t
77
use bincode::{Decode, Encode, decode_from_slice, encode_to_vec};
88
// Re-export display functions for convenience
99
pub use display::format_cache_status_inline;
10-
pub use display::{SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_spawn_change};
10+
pub use display::{
11+
SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str,
12+
format_spawn_change,
13+
};
1114
use rusqlite::{Connection, OptionalExtension as _, config::DbConfig};
1215
use serde::{Deserialize, Serialize};
1316
use tokio::sync::Mutex;
1417
use vite_path::{AbsolutePath, RelativePathBuf};
1518
use vite_task_graph::config::ResolvedInputConfig;
1619
use vite_task_plan::cache_metadata::{CacheMetadata, ExecutionCacheKey, SpawnFingerprint};
1720

18-
use super::execute::{
19-
fingerprint::{PostRunFingerprint, PostRunFingerprintMismatch},
20-
spawn::StdOutput,
21-
};
21+
use super::execute::{fingerprint::PostRunFingerprint, spawn::StdOutput};
2222

2323
/// Cache lookup key identifying a task's execution configuration.
2424
///
@@ -83,6 +83,16 @@ pub enum CacheMiss {
8383
FingerprintMismatch(FingerprintMismatch),
8484
}
8585

86+
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
87+
pub enum InputChangeKind {
88+
/// File content changed but path is the same
89+
ContentModified,
90+
/// New file or folder added
91+
Added,
92+
/// Existing file or folder removed
93+
Removed,
94+
}
95+
8696
#[derive(Debug, Serialize, Deserialize)]
8797
pub enum FingerprintMismatch {
8898
/// Found a previous cache entry key for the same task, but the spawn fingerprint differs.
@@ -95,10 +105,11 @@ pub enum FingerprintMismatch {
95105
},
96106
/// Found a previous cache entry key for the same task, but `input_config` differs.
97107
InputConfig,
98-
/// Found the cache entry with the same spawn fingerprint, but an explicit globbed input changed
99-
GlobbedInput { path: RelativePathBuf },
100-
/// Found the cache entry with the same spawn fingerprint, but the post-run fingerprint mismatches
101-
PostRunFingerprint(PostRunFingerprintMismatch),
108+
109+
InputChanged {
110+
kind: InputChangeKind,
111+
path: RelativePathBuf,
112+
},
102113
}
103114

104115
impl Display for FingerprintMismatch {
@@ -110,14 +121,22 @@ impl Display for FingerprintMismatch {
110121
Self::InputConfig => {
111122
write!(f, "inputs configuration changed")
112123
}
113-
Self::GlobbedInput { path } => {
114-
write!(f, "content of input '{path}' changed")
124+
Self::InputChanged { kind, path } => {
125+
write!(f, "{}", display::format_input_change_str(*kind, path.as_str()))
115126
}
116-
Self::PostRunFingerprint(diff) => Display::fmt(diff, f),
117127
}
118128
}
119129
}
120130

131+
/// Split a relative path into `(parent_dir, filename)`.
132+
/// Returns `None` for the parent if the path has no `/` separator.
133+
pub fn split_path(path: &str) -> (Option<&str>, &str) {
134+
match path.rsplit_once('/') {
135+
Some((parent, filename)) => (Some(parent), filename),
136+
None => (None, path),
137+
}
138+
}
139+
121140
impl ExecutionCache {
122141
#[tracing::instrument(level = "debug", skip_all)]
123142
pub fn load_from_path(path: &AbsolutePath) -> anyhow::Result<Self> {
@@ -197,11 +216,9 @@ impl ExecutionCache {
197216
}
198217

199218
// Validate post-run fingerprint (inferred inputs from fspy)
200-
if let Some(post_run_fingerprint_mismatch) =
201-
cache_value.post_run_fingerprint.validate(workspace_root)?
202-
{
219+
if let Some((kind, path)) = cache_value.post_run_fingerprint.validate(workspace_root)? {
203220
return Ok(Err(CacheMiss::FingerprintMismatch(
204-
FingerprintMismatch::PostRunFingerprint(post_run_fingerprint_mismatch),
221+
FingerprintMismatch::InputChanged { kind, path },
205222
)));
206223
}
207224
// Associate the execution key to the cache entry key if not already,
@@ -264,22 +281,40 @@ fn detect_globbed_input_change(
264281
loop {
265282
match (s, c) {
266283
(None, None) => return None,
267-
(Some((path, _)), None) | (None, Some((path, _))) => {
268-
return Some(FingerprintMismatch::GlobbedInput { path: path.clone() });
284+
(Some((sp, _)), None) => {
285+
return Some(FingerprintMismatch::InputChanged {
286+
kind: InputChangeKind::Removed,
287+
path: sp.clone(),
288+
});
289+
}
290+
(None, Some((cp, _))) => {
291+
return Some(FingerprintMismatch::InputChanged {
292+
kind: InputChangeKind::Added,
293+
path: cp.clone(),
294+
});
269295
}
270296
(Some((sp, sh)), Some((cp, ch))) => match sp.cmp(cp) {
271297
std::cmp::Ordering::Equal => {
272298
if sh != ch {
273-
return Some(FingerprintMismatch::GlobbedInput { path: sp.clone() });
299+
return Some(FingerprintMismatch::InputChanged {
300+
kind: InputChangeKind::ContentModified,
301+
path: sp.clone(),
302+
});
274303
}
275304
s = stored_iter.next();
276305
c = current_iter.next();
277306
}
278307
std::cmp::Ordering::Less => {
279-
return Some(FingerprintMismatch::GlobbedInput { path: sp.clone() });
308+
return Some(FingerprintMismatch::InputChanged {
309+
kind: InputChangeKind::Removed,
310+
path: sp.clone(),
311+
});
280312
}
281313
std::cmp::Ordering::Greater => {
282-
return Some(FingerprintMismatch::GlobbedInput { path: cp.clone() });
314+
return Some(FingerprintMismatch::InputChanged {
315+
kind: InputChangeKind::Added,
316+
path: cp.clone(),
317+
});
283318
}
284319
},
285320
}

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

Lines changed: 80 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
//! fingerprints of file system state after task execution.
55
66
use std::{
7+
collections::BTreeMap,
78
fs::File,
89
hash::Hasher as _,
910
io::{self, BufRead, Read},
@@ -17,7 +18,7 @@ use vite_path::{AbsolutePath, RelativePathBuf};
1718
use vite_str::Str;
1819

1920
use super::spawn::PathRead;
20-
use crate::collections::HashMap;
21+
use crate::{collections::HashMap, session::cache::InputChangeKind};
2122

2223
/// Post-run fingerprint capturing file state after execution.
2324
/// Used to validate whether cached outputs are still valid.
@@ -38,8 +39,8 @@ pub enum PathFingerprint {
3839
/// Directory with optional entry listing.
3940
/// `Folder(None)` means the directory was opened but entries were not read
4041
/// (e.g., for `openat` calls).
41-
/// `Folder(Some(_))` contains the directory entries.
42-
Folder(Option<HashMap<Str, DirEntryKind>>),
42+
/// `Folder(Some(_))` contains the directory entries sorted by name.
43+
Folder(Option<BTreeMap<Str, DirEntryKind>>),
4344
}
4445

4546
/// Kind of directory entry
@@ -50,22 +51,6 @@ pub enum DirEntryKind {
5051
Symlink,
5152
}
5253

53-
/// Describes why the post-run fingerprint validation failed
54-
#[derive(Debug, Serialize, Deserialize, Clone)]
55-
pub enum PostRunFingerprintMismatch {
56-
InputContentChanged { path: RelativePathBuf },
57-
}
58-
59-
impl std::fmt::Display for PostRunFingerprintMismatch {
60-
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61-
match self {
62-
Self::InputContentChanged { path } => {
63-
write!(f, "{path} content changed")
64-
}
65-
}
66-
}
67-
}
68-
6954
impl PostRunFingerprint {
7055
/// Creates a new fingerprint from path accesses after task execution.
7156
///
@@ -94,12 +79,12 @@ impl PostRunFingerprint {
9479
}
9580

9681
/// Validates the fingerprint against current filesystem state.
97-
/// Returns `Some(mismatch)` if validation fails, `None` if valid.
82+
/// Returns `Some((kind, path))` if an input changed, `None` if all valid.
9883
#[tracing::instrument(level = "debug", skip_all, name = "validate_post_run_fingerprint")]
9984
pub fn validate(
10085
&self,
10186
base_dir: &AbsolutePath,
102-
) -> anyhow::Result<Option<PostRunFingerprintMismatch>> {
87+
) -> anyhow::Result<Option<(InputChangeKind, RelativePathBuf)>> {
10388
let input_mismatch = self.inferred_inputs.par_iter().find_map_any(
10489
|(input_relative_path, path_fingerprint)| {
10590
let input_full_path = Arc::<AbsolutePath>::from(base_dir.join(input_relative_path));
@@ -113,16 +98,85 @@ impl PostRunFingerprint {
11398
if path_fingerprint == &current_path_fingerprint {
11499
None
115100
} else {
116-
Some(Ok(PostRunFingerprintMismatch::InputContentChanged {
117-
path: input_relative_path.clone(),
118-
}))
101+
let (kind, entry_name) =
102+
determine_change_kind(path_fingerprint, &current_path_fingerprint);
103+
let path = if let Some(name) = entry_name {
104+
// For folder changes, build `dir/entry` path
105+
let entry = match RelativePathBuf::new(name.as_str()) {
106+
Ok(p) => p,
107+
Err(e) => return Some(Err(e.into())),
108+
};
109+
input_relative_path.as_relative_path().join(entry)
110+
} else {
111+
input_relative_path.clone()
112+
};
113+
Some(Ok((kind, path)))
119114
}
120115
},
121116
);
122117
input_mismatch.transpose()
123118
}
124119
}
125120

121+
/// Determine the kind of change between two differing path fingerprints.
122+
/// Caller guarantees `stored != current`.
123+
///
124+
/// Returns `(kind, entry_name)` where `entry_name` is `Some` for folder changes
125+
/// when a specific added/removed entry can be identified.
126+
fn determine_change_kind<'a>(
127+
stored: &'a PathFingerprint,
128+
current: &'a PathFingerprint,
129+
) -> (InputChangeKind, Option<&'a Str>) {
130+
match (stored, current) {
131+
(PathFingerprint::NotFound, _) => (InputChangeKind::Added, None),
132+
(_, PathFingerprint::NotFound) => (InputChangeKind::Removed, None),
133+
(PathFingerprint::FileContentHash(_), PathFingerprint::FileContentHash(_)) => {
134+
(InputChangeKind::ContentModified, None)
135+
}
136+
(PathFingerprint::Folder(old), PathFingerprint::Folder(new)) => {
137+
determine_folder_change_kind(old.as_ref(), new.as_ref())
138+
}
139+
// Type changed (file ↔ folder)
140+
_ => (InputChangeKind::Added, None),
141+
}
142+
}
143+
144+
/// Determine whether a folder change is an addition or removal by comparing entries.
145+
/// Both maps are `BTreeMap` so we iterate them in sorted lockstep.
146+
/// Returns the specific entry name that was added or removed, if identifiable.
147+
fn determine_folder_change_kind<'a>(
148+
old: Option<&'a BTreeMap<Str, DirEntryKind>>,
149+
new: Option<&'a BTreeMap<Str, DirEntryKind>>,
150+
) -> (InputChangeKind, Option<&'a Str>) {
151+
let (Some(old_entries), Some(new_entries)) = (old, new) else {
152+
return (InputChangeKind::Added, None);
153+
};
154+
155+
let mut old_iter = old_entries.iter();
156+
let mut new_iter = new_entries.iter();
157+
let mut o = old_iter.next();
158+
let mut n = new_iter.next();
159+
160+
loop {
161+
match (o, n) {
162+
(None, None) => return (InputChangeKind::Added, None),
163+
(Some((name, _)), None) => return (InputChangeKind::Removed, Some(name)),
164+
(None, Some((name, _))) => return (InputChangeKind::Added, Some(name)),
165+
(Some((ok, ov)), Some((nk, nv))) => match ok.cmp(nk) {
166+
std::cmp::Ordering::Equal => {
167+
if ov != nv {
168+
return (InputChangeKind::Added, Some(ok));
169+
}
170+
o = old_iter.next();
171+
n = new_iter.next();
172+
}
173+
std::cmp::Ordering::Less => return (InputChangeKind::Removed, Some(ok)),
174+
std::cmp::Ordering::Greater => return (InputChangeKind::Added, Some(nk)),
175+
},
176+
}
177+
}
178+
}
179+
126180
/// Hash file content using `xxHash3_64`
127181
fn hash_content(mut stream: impl Read) -> io::Result<u64> {
128182
let mut hasher = twox_hash::XxHash3_64::default();
@@ -206,7 +260,7 @@ fn process_directory(
206260
return Ok(PathFingerprint::Folder(None));
207261
}
208262

209-
let mut entries = HashMap::new();
263+
let mut entries = BTreeMap::new();
210264
for entry in std::fs::read_dir(path)? {
211265
let entry = entry?;
212266
let name = entry.file_name();
@@ -244,7 +298,7 @@ fn process_directory_unix(file: &File, path_read: PathRead) -> anyhow::Result<Pa
244298
let fd = file.as_fd();
245299
let mut dir = nix::dir::Dir::from_fd(fd.try_clone_to_owned()?)?;
246300

247-
let mut entries = HashMap::new();
301+
let mut entries = BTreeMap::new();
248302
for entry in dir.iter() {
249303
let entry = entry?;
250304
let name = entry.file_name().to_bytes();

0 commit comments

Comments
 (0)