Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Changelog

- **Added** `vp run --dry-run` shows whether each task would hit the local cache, or why it would miss, without running anything ([#792](https://github.com/voidzero-dev/vite-task/pull/792)).
- **Changed** When a task isn't cached because it wrote a file it also read, `vp run --last-details` now says the task read and wrote the file, and shows the `cache: { input, output }` exclusions that let the task be cached ([#784](https://github.com/voidzero-dev/vite-task/pull/784)).
- **Changed** The run summary now says a task that wrote a file it also read was `not cached because it modified its inputs`, and the statistics in `vp run --verbose` and `vp run --last-details` use the singular for a count of one, e.g. `1 task • 1 cache miss` ([#783](https://github.com/voidzero-dev/vite-task/pull/783)).
- **Fixed** An invalid glob in `--filter` no longer shows its error message twice ([#763](https://github.com/voidzero-dev/vite-task/pull/763)).
Expand Down
14 changes: 13 additions & 1 deletion crates/vt/src/cli/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,10 @@ pub struct RunCommand {
#[clap(long, exclusive = true)]
pub(crate) last_details: bool,

/// Show whether each task would hit the local cache, without running anything.
#[clap(long)]
pub(crate) dry_run: bool,

#[clap(
allow_hyphen_values = true,
value_names = ["TASK_SPECIFIER", "ADDITIONAL_ARGS"],
Expand Down Expand Up @@ -200,6 +204,9 @@ pub struct ResolvedRunCommand {

pub flags: RunFlags,

/// Report predicted cache results instead of running tasks (`--dry-run`).
pub dry_run: bool,

/// Additional arguments to pass to the tasks.
pub additional_args: Vec<Str>,
}
Expand All @@ -214,7 +221,12 @@ impl RunCommand {
let mut iter = self.task_and_args.into_iter();
let task_specifier = iter.next();
let additional_args: Vec<Str> = iter.collect();
ResolvedRunCommand { task_specifier, flags: self.flags, additional_args }
ResolvedRunCommand {
task_specifier,
flags: self.flags,
dry_run: self.dry_run,
additional_args,
}
}
}

Expand Down
45 changes: 40 additions & 5 deletions crates/vt/src/session/cache/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ pub use display::{
SpawnFingerprintChange, detect_spawn_fingerprint_changes, format_input_change_str,
format_spawn_change,
};
use rusqlite::{Connection, OptionalExtension as _};
use rusqlite::{Connection, OpenFlags, OptionalExtension as _};
use serde::{Deserialize, Serialize};
use tokio::sync::Mutex;
use tokio_util::sync::CancellationToken;
Expand Down Expand Up @@ -306,6 +306,20 @@ impl ExecutionCache {
Ok(Self { conn: Mutex::new(conn), remote_clients: RemoteClients::default() })
}

/// Open the cache database at `path` read-only, for `--dry-run`. Returns
/// `None` if there is no database yet. Nothing is created or written.
pub fn open_read_only(path: &AbsolutePath) -> anyhow::Result<Option<Self>> {
let db_path = path.join("cache.db");
if !db_path.as_path().exists() {
return Ok(None);
}
let conn = Connection::open_with_flags(
db_path.as_path(),
OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
)?;
Ok(Some(Self { conn: Mutex::new(conn), remote_clients: RemoteClients::default() }))
}

#[tracing::instrument]
pub async fn save(self) -> anyhow::Result<()> {
// do some cleanup in the future
Expand Down Expand Up @@ -336,7 +350,13 @@ impl ExecutionCache {
.try_hit_local(cache_metadata, &cache_key, globbed_inputs, workspace_root)
.await?
{
Ok(value) => return Ok(Ok(CacheHit { value, source: CacheHitSource::Local })),
Ok(value) => {
// Associate the execution key to the cache entry key if not already,
// so that next time we can find it and report what changed
self.upsert_task_fingerprint(&cache_metadata.execution_cache_key, &cache_key)
.await?;
return Ok(Ok(CacheHit { value, source: CacheHitSource::Local }));
}
Err(miss) => miss,
};
#[expect(
Expand Down Expand Up @@ -371,6 +391,24 @@ impl ExecutionCache {
}))
}

/// Look up the task in the local cache without writing to it, for
/// `--dry-run`. Returns `Ok(Ok(_))` on a hit and `Ok(Err(miss))` otherwise.
/// The remote cache is never queried.
pub async fn peek_local(
&self,
cache_metadata: &CacheMetadata,
globbed_inputs: &BTreeMap<RelativePathBuf, u64>,
workspace_root: &AbsolutePath,
) -> anyhow::Result<Result<(), CacheMiss>> {
let cache_key = CacheEntryKey::from_metadata(cache_metadata);
Ok(self
.try_hit_local(cache_metadata, &cache_key, globbed_inputs, workspace_root)
.await?
.map(drop))
}

/// Look up the task in the local cache. Read-only: on a hit, the caller
/// decides whether to record the execution key.
async fn try_hit_local(
&self,
cache_metadata: &CacheMetadata,
Expand All @@ -389,9 +427,6 @@ impl ExecutionCache {
)? {
return Ok(Err(CacheMiss::FingerprintMismatch(mismatch)));
}
// Associate the execution key to the cache entry key if not already,
// so that next time we can find it and report what changed
self.upsert_task_fingerprint(execution_cache_key, cache_key).await?;
return Ok(Ok(cache_value));
}

Expand Down
148 changes: 148 additions & 0 deletions crates/vt/src/session/execute/dry_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
//! `vp run --dry-run`: walk the planned graph in dependency order and predict
//! each task's cache result from the local cache, without running anything or
//! writing to the cache.

use std::{io::Write as _, sync::Arc};

use rustc_hash::FxHashMap;
use vt_path::AbsolutePath;
use vt_plan::{
ExecutionGraph, ExecutionItemDisplay, ExecutionItemKind, LeafExecutionKind, SpawnExecution,
execution_graph::ExecutionNodeIndex,
};
use vt_str::Str;

use super::glob::compute_globbed_inputs;
use crate::{
Session,
session::{
cache::ExecutionCache,
reporter::{
dry_run::{DryRunPrediction, format_dry_run_line, format_remote_not_checked},
summary::SavedCacheMissReason,
},
},
};

struct DryRun<'a> {
/// `None` when there is no local cache yet, so every lookup is a miss.
cache: Option<&'a ExecutionCache>,
workspace_root: &'a Arc<AbsolutePath>,
out: Vec<u8>,
/// Set when a task has a remote cache configured. It isn't queried.
remote_cache_configured: bool,
}

impl DryRun<'_> {
/// Predict every task in `graph`, dependencies first. `runs_after` names a
/// task outside `graph` that runs first and isn't a cache hit.
///
/// Returns whether every task in `graph` is a predicted hit.
async fn walk_graph(
&mut self,
graph: &ExecutionGraph,
runs_after: Option<&Str>,
) -> anyhow::Result<bool> {
// Tasks that aren't predicted hits, by node. Edge A→B means A depends
// on B, so reversing the topological order puts dependencies first.
let mut not_hit: FxHashMap<ExecutionNodeIndex, Str> = FxHashMap::default();
for node_ix in graph.graph.compute_topological_order().into_iter().rev() {
let task = &graph.graph[node_ix];
let mut blocker: Option<Str> = runs_after.cloned().or_else(|| {
graph.graph.neighbors(node_ix).find_map(|dep| not_hit.get(&dep).cloned())
});
let mut task_hit = true;
for item in &task.items {
let display = &item.execution_item_display;
let item_hit = match &item.kind {
ExecutionItemKind::Leaf(LeafExecutionKind::InProcess(_)) => {
// Built-ins don't change files, so they don't block later parts.
self.write(display, &DryRunPrediction::BuiltIn);
true
}
ExecutionItemKind::Leaf(LeafExecutionKind::Spawn(spawn)) => {
self.spawn_leaf(display, spawn, blocker.as_ref()).await?
}
ExecutionItemKind::Expanded(nested) => {
Box::pin(self.walk_graph(nested, blocker.as_ref())).await?
}
};
if !item_hit {
task_hit = false;
// Later `&&` parts run after this one.
blocker.get_or_insert_with(|| display.command.clone());
}
}
if !task_hit {
not_hit.insert(node_ix, vt_str::format!("{}", task.task_display));
}
}
Ok(not_hit.is_empty())
}

/// Predict one spawned process and write its line. Returns whether it's a
/// predicted hit.
async fn spawn_leaf(
&mut self,
display: &ExecutionItemDisplay,
spawn: &SpawnExecution,
runs_after: Option<&Str>,
) -> anyhow::Result<bool> {
let Some(metadata) = &spawn.cache_metadata else {
self.write(display, &DryRunPrediction::Disabled);
return Ok(false);
};
self.remote_cache_configured |= metadata.remote_cache.is_some();

let prediction = if let Some(runs_after) = runs_after {
DryRunPrediction::Unknown { runs_after: runs_after.clone() }
} else {
let globbed_inputs = compute_globbed_inputs(
self.workspace_root,
&metadata.input_config.positive_globs,
&metadata.input_config.negative_globs,
)?;
let lookup = match self.cache {
Some(cache) => {
cache.peek_local(metadata, &globbed_inputs, self.workspace_root).await?
}
None => Err(crate::session::CacheMiss::NotFound),
};
match lookup {
Ok(()) => DryRunPrediction::Hit,
Err(miss) => DryRunPrediction::Miss(SavedCacheMissReason::from_cache_miss(&miss)),
}
};
let hit = matches!(prediction, DryRunPrediction::Hit);
self.write(display, &prediction);
Ok(hit)
}

fn write(&mut self, display: &ExecutionItemDisplay, prediction: &DryRunPrediction) {
self.out.extend_from_slice(
format_dry_run_line(display, self.workspace_root, prediction).as_bytes(),
);
}
}

impl Session<'_> {
/// Report what running `graph` would do with the local cache, without
/// running any task. Nothing is written to the cache directory.
pub(crate) async fn dry_run(&self, graph: &ExecutionGraph) -> anyhow::Result<()> {
let cache = ExecutionCache::open_read_only(&self.cache_path)?;
let mut dry_run = DryRun {
cache: cache.as_ref(),
workspace_root: &self.workspace_path,
out: Vec::new(),
remote_cache_configured: false,
};
dry_run.walk_graph(graph, None).await?;
if dry_run.remote_cache_configured {
dry_run.out.extend_from_slice(format_remote_not_checked().as_bytes());
}
let mut stdout = std::io::stdout().lock();
stdout.write_all(&dry_run.out)?;
stdout.flush()?;
Ok(())
}
}
1 change: 1 addition & 0 deletions crates/vt/src/session/execute/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod cache_update;
mod dry_run;
pub mod fingerprint;
pub mod glob;
mod hash;
Expand Down
11 changes: 11 additions & 0 deletions crates/vt/src/session/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ impl vt_plan::PlanRequestParser for PlanRequestParser<'_> {
command.to_synthetic_plan_request(UserCacheConfig::disabled()),
)))
}
ResolvedCommand::Run(run_command) if run_command.dry_run => {
// A nested `--dry-run` runs no tasks, so it isn't expanded into
// the parent's graph.
Ok(Some(PlanRequest::Synthetic(
command.to_synthetic_plan_request(UserCacheConfig::disabled()),
)))
}
ResolvedCommand::Run(run_command) => {
match run_command.into_query_plan_request(&command.cwd) {
Ok((query_plan_request, _)) => {
Expand Down Expand Up @@ -336,6 +343,10 @@ impl<'a> Session<'a> {
self.plan_from_query(qpr).await?
};

if run_command.dry_run {
return self.dry_run(&graph).await.map_err(SessionError::from);
}

let workspace_path = self.workspace_path();
let writer: Box<dyn std::io::Write> = Box::new(std::io::stdout());

Expand Down
67 changes: 67 additions & 0 deletions crates/vt/src/session/reporter/dry_run.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
//! Rendering for `vp run --dry-run`: one line per task with its predicted
//! cache result, in the style of the full run summary.

use owo_colors::Style;
use vt_path::AbsolutePath;
use vt_plan::ExecutionItemDisplay;
use vt_str::Str;

use super::{
CACHE_MISS_STYLE, COMMAND_STYLE, ColorizeExt, format_command_display,
summary::{SavedCacheMissReason, format_hit},
};
use crate::session::cache::CacheHitSource;

/// What a run would do with the cache for one task.
pub enum DryRunPrediction {
/// The local cache has a valid entry.
Hit,
/// The local cache has no valid entry.
Miss(SavedCacheMissReason),
/// Caching is off for the task.
Disabled,
/// A built-in command, which is never cached.
BuiltIn,
/// A task that runs before this one isn't a cache hit, and running it
/// could change this task's inputs. Holds that task's name or command.
Unknown { runs_after: Str },
}

/// Format one task's line, e.g. `build: $ tsc → Cache hit`.
pub fn format_dry_run_line(
display: &ExecutionItemDisplay,
workspace_path: &AbsolutePath,
prediction: &DryRunPrediction,
) -> Str {
let (detail, style) = match prediction {
DryRunPrediction::Hit => {
(vt_str::format!("→ {}", format_hit(CacheHitSource::Local)), Style::new().green())
}
DryRunPrediction::Miss(reason) => {
(vt_str::format!("→ Cache miss: {reason}"), CACHE_MISS_STYLE)
}
DryRunPrediction::Disabled => (Str::from("→ Cache disabled"), Style::new().bright_black()),
DryRunPrediction::BuiltIn => {
(Str::from("→ Cache disabled for built-in command"), Style::new().bright_black())
}
DryRunPrediction::Unknown { runs_after } => (
vt_str::format!("→ Unknown: runs after '{runs_after}', which isn't a cache hit"),
Style::new().yellow(),
),
};
vt_str::format!(
"{}: {} {}\n",
vt_str::format!("{}", display.task_display).style(Style::new().bright_white().bold()),
format_command_display(display, workspace_path).style(COMMAND_STYLE),
detail.style(style),
)
}

/// The note printed once when a task has a remote cache configured.
pub fn format_remote_not_checked() -> Str {
vt_str::format!(
"{}\n",
"Remote cache not checked: --dry-run only reads the local cache"
.style(Style::new().bright_black())
)
}
1 change: 1 addition & 0 deletions crates/vt/src/session/reporter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
//! Additionally, [`plain::PlainReporter`] is a standalone [`LeafExecutionReporter`] for
//! single-leaf synthetic executions (e.g., `execute_synthetic`).

pub mod dry_run;
mod grouped;
mod interleaved;
mod labeled;
Expand Down
Loading