@@ -20,7 +20,10 @@ use crate::session::{
2020 CacheMiss , FingerprintMismatch , InputChangeKind , SpawnFingerprintChange ,
2121 detect_spawn_fingerprint_changes, format_input_change_str, format_spawn_change,
2222 } ,
23- event:: { CacheDisabledReason , CacheErrorKind , CacheStatus , ExecutionError } ,
23+ event:: {
24+ CacheDisabledReason , CacheErrorKind , CacheNotUpdatedReason , CacheStatus , CacheUpdateStatus ,
25+ ExecutionError ,
26+ } ,
2427} ;
2528
2629// ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -94,7 +97,12 @@ pub enum SpawnOutcome {
9497 /// Process exited successfully (exit code 0).
9598 /// May have a post-execution infrastructure error (cache update or fingerprint failed).
9699 /// These only run after exit 0, so this field only exists on the success path.
97- Success { infra_error : Option < SavedExecutionError > } ,
100+ Success {
101+ infra_error : Option < SavedExecutionError > ,
102+ /// First path that was both read and written, causing cache to be skipped.
103+ /// Only set when fspy detected a read-write overlap.
104+ input_modified_path : Option < Str > ,
105+ } ,
98106
99107 /// Process exited with non-zero status.
100108 /// [`NonZeroI32`] enforces that exit code 0 is unrepresentable here.
@@ -147,6 +155,8 @@ struct SummaryStats {
147155 cache_disabled : usize ,
148156 failed : usize ,
149157 total_saved : Duration ,
158+ /// Display names of tasks that were not cached due to read-write overlap.
159+ input_modified_task_names : Vec < Str > ,
150160}
151161
152162impl SummaryStats {
@@ -158,6 +168,7 @@ impl SummaryStats {
158168 cache_disabled : 0 ,
159169 failed : 0 ,
160170 total_saved : Duration :: ZERO ,
171+ input_modified_task_names : Vec :: new ( ) ,
161172 } ;
162173
163174 for task in tasks {
@@ -175,10 +186,13 @@ impl SummaryStats {
175186 SpawnedCacheStatus :: Disabled => stats. cache_disabled += 1 ,
176187 }
177188 match outcome {
178- SpawnOutcome :: Success { infra_error : Some ( _) }
189+ SpawnOutcome :: Success { infra_error : Some ( _) , .. }
179190 | SpawnOutcome :: Failed { .. }
180191 | SpawnOutcome :: SpawnError ( _) => stats. failed += 1 ,
181- SpawnOutcome :: Success { infra_error : None } => { }
192+ SpawnOutcome :: Success { input_modified_path : Some ( _) , .. } => {
193+ stats. input_modified_task_names . push ( task. format_task_display ( ) ) ;
194+ }
195+ SpawnOutcome :: Success { .. } => { }
182196 }
183197 }
184198 }
@@ -255,25 +269,42 @@ impl TaskResult {
255269 /// `cache_status`: the cache status determined at `start()` time.
256270 /// `exit_status`: the process exit status, or `None` for cache hit / in-process.
257271 /// `saved_error`: an optional pre-converted execution error.
272+ /// `cache_update_status`: the post-execution cache update result.
258273 pub fn from_execution (
259274 cache_status : & CacheStatus ,
260275 exit_status : Option < std:: process:: ExitStatus > ,
261276 saved_error : Option < & SavedExecutionError > ,
277+ cache_update_status : & CacheUpdateStatus ,
262278 ) -> Self {
279+ let input_modified_path = match cache_update_status {
280+ CacheUpdateStatus :: NotUpdated ( CacheNotUpdatedReason :: InputModified { path } ) => {
281+ Some ( Str :: from ( path. as_str ( ) ) )
282+ }
283+ _ => None ,
284+ } ;
285+
263286 match cache_status {
264287 CacheStatus :: Hit { replayed_duration } => {
265288 Self :: CacheHit { saved_duration_ms : duration_to_ms ( * replayed_duration) }
266289 }
267290 CacheStatus :: Disabled ( CacheDisabledReason :: InProcessExecution ) => Self :: InProcess ,
268291 CacheStatus :: Disabled ( CacheDisabledReason :: NoCacheMetadata ) => Self :: Spawned {
269292 cache_status : SpawnedCacheStatus :: Disabled ,
270- outcome : spawn_outcome_from_execution ( exit_status, saved_error) ,
293+ outcome : spawn_outcome_from_execution (
294+ exit_status,
295+ saved_error,
296+ input_modified_path,
297+ ) ,
271298 } ,
272299 CacheStatus :: Miss ( cache_miss) => Self :: Spawned {
273300 cache_status : SpawnedCacheStatus :: Miss ( SavedCacheMissReason :: from_cache_miss (
274301 cache_miss,
275302 ) ) ,
276- outcome : spawn_outcome_from_execution ( exit_status, saved_error) ,
303+ outcome : spawn_outcome_from_execution (
304+ exit_status,
305+ saved_error,
306+ input_modified_path,
307+ ) ,
277308 } ,
278309 }
279310 }
@@ -283,13 +314,14 @@ impl TaskResult {
283314fn spawn_outcome_from_execution (
284315 exit_status : Option < std:: process:: ExitStatus > ,
285316 saved_error : Option < & SavedExecutionError > ,
317+ input_modified_path : Option < Str > ,
286318) -> SpawnOutcome {
287319 match ( exit_status, saved_error) {
288320 // Spawn error — process never ran
289321 ( None , Some ( err) ) => SpawnOutcome :: SpawnError ( err. clone ( ) ) ,
290322 // Process exited successfully, possible infra error
291323 ( Some ( status) , _) if status. success ( ) => {
292- SpawnOutcome :: Success { infra_error : saved_error. cloned ( ) }
324+ SpawnOutcome :: Success { infra_error : saved_error. cloned ( ) , input_modified_path }
293325 }
294326 // Process exited with non-zero code
295327 ( Some ( status) , _) => {
@@ -304,7 +336,7 @@ fn spawn_outcome_from_execution(
304336 // No exit status, no error — this is the cache hit / in-process path,
305337 // handled by TaskResult::CacheHit / InProcess before reaching here.
306338 // If we somehow get here, treat as success.
307- ( None , None ) => SpawnOutcome :: Success { infra_error : None } ,
339+ ( None , None ) => SpawnOutcome :: Success { infra_error : None , input_modified_path : None } ,
308340 }
309341}
310342
@@ -415,6 +447,15 @@ impl TaskResult {
415447 /// - "→ Cache miss: no previous cache entry found"
416448 /// - "→ Cache disabled in task configuration"
417449 fn format_cache_detail ( & self ) -> Str {
450+ // Check for input modification first — it overrides the cache miss reason
451+ if let Self :: Spawned {
452+ outcome : SpawnOutcome :: Success { input_modified_path : Some ( path) , .. } ,
453+ ..
454+ } = self
455+ {
456+ return vite_str:: format!( "→ Not cached: read and wrote '{path}'" ) ;
457+ }
458+
418459 match self {
419460 Self :: CacheHit { saved_duration_ms } => {
420461 let d = Duration :: from_millis ( * saved_duration_ms) ;
@@ -467,7 +508,7 @@ impl TaskResult {
467508 match self {
468509 Self :: CacheHit { .. } | Self :: InProcess => None ,
469510 Self :: Spawned { outcome, .. } => match outcome {
470- SpawnOutcome :: Success { infra_error } => infra_error. as_ref ( ) ,
511+ SpawnOutcome :: Success { infra_error, .. } => infra_error. as_ref ( ) ,
471512 SpawnOutcome :: Failed { .. } => None ,
472513 SpawnOutcome :: SpawnError ( err) => Some ( err) ,
473514 } ,
@@ -671,8 +712,8 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V
671712
672713 let is_single_task = summary. tasks . len ( ) == 1 ;
673714
674- // Single task + not cache hit → no summary
675- if is_single_task && stats. cache_hits == 0 {
715+ // Single task + not cache hit + no input modification → no summary
716+ if is_single_task && stats. cache_hits == 0 && stats . input_modified_task_names . is_empty ( ) {
676717 return Vec :: new ( ) ;
677718 }
678719
@@ -682,16 +723,18 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V
682723 let _ = writeln ! ( buf, "{}" , "---" . style( Style :: new( ) . bright_black( ) ) ) ;
683724
684725 let run_label = vite_str:: format!( "{program_name} run:" ) ;
685- if is_single_task {
686- // Single task cache hit
726+ let mut show_last_details_hint = true ;
727+ if is_single_task && stats. cache_hits > 0 {
728+ // Single task cache hit — no need for --last-details hint
687729 let formatted_total_saved = format_summary_duration ( stats. total_saved ) ;
688- let _ = writeln ! (
730+ let _ = write ! (
689731 buf,
690732 "{} cache hit, {} saved." ,
691733 run_label. as_str( ) . style( Style :: new( ) . blue( ) . bold( ) ) ,
692734 formatted_total_saved. style( Style :: new( ) . green( ) . bold( ) ) ,
693735 ) ;
694- } else {
736+ show_last_details_hint = false ;
737+ } else if !is_single_task {
695738 // Multi-task
696739 let total = stats. total ;
697740 let hits = stats. cache_hits ;
@@ -727,12 +770,42 @@ pub fn format_compact_summary(summary: &LastRunSummary, program_name: &str) -> V
727770 let _ = write ! ( buf, ", {} failed" , n. style( Style :: new( ) . red( ) ) ) ;
728771 }
729772
773+ let _ = write ! ( buf, "." ) ;
774+ } else {
775+ // Single task, no cache hit — only shown when input_modified is non-empty
776+ let _ = write ! ( buf, "{}" , run_label. as_str( ) . style( Style :: new( ) . blue( ) . bold( ) ) ) ;
777+ }
778+
779+ // Inline input-modified notice before the --last-details hint
780+ if !stats. input_modified_task_names . is_empty ( ) {
781+ format_input_modified_notice ( & mut buf, & stats. input_modified_task_names ) ;
782+ }
783+
784+ if show_last_details_hint {
730785 let last_details_cmd = vite_str:: format!( "`{program_name} run --last-details`" ) ;
731- let _ = write ! ( buf, ". {}" , "(Run " . style( Style :: new( ) . bright_black( ) ) ) ;
786+ let _ = write ! ( buf, " {}" , "(Run " . style( Style :: new( ) . bright_black( ) ) ) ;
732787 let _ = write ! ( buf, "{}" , last_details_cmd. as_str( ) . style( COMMAND_STYLE ) ) ;
733788 let _ = write ! ( buf, "{}" , " for full details)" . style( Style :: new( ) . bright_black( ) ) ) ;
734- let _ = writeln ! ( buf) ;
735789 }
790+ let _ = writeln ! ( buf) ;
736791
737792 buf
738793}
794+
795+ /// Write the "not cached because it modified its input" notice inline.
796+ fn format_input_modified_notice ( buf : & mut Vec < u8 > , task_names : & [ Str ] ) {
797+ let _ = write ! ( buf, " " ) ;
798+
799+ let first = & task_names[ 0 ] ;
800+ let _ = write ! ( buf, "{}" , first. as_str( ) . style( Style :: new( ) . bold( ) ) ) ;
801+ let remaining = task_names. len ( ) - 1 ;
802+ if remaining > 0 {
803+ let _ = write ! ( buf, " (and {remaining} more)" ) ;
804+ }
805+
806+ if task_names. len ( ) == 1 {
807+ let _ = write ! ( buf, " not cached because it modified its input." ) ;
808+ } else {
809+ let _ = write ! ( buf, " not cached because they modified their inputs." ) ;
810+ }
811+ }
0 commit comments