Skip to content
Merged
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
/target
node_modules
dist
.claude/settings.local.json
.claude/settings.local.json
*.tsbuildinfo
9 changes: 7 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,13 +7,18 @@ A monorepo task runner (like nx/turbo) with intelligent caching and dependency r
**Task Execution**: Run tasks across monorepo packages with automatic dependency ordering.

```bash
# Run task in current package (implicit mode)
vite-plus build
# Built-in commands
vite-plus build # Run Vite build (dedicated command)
vite-plus test # Run Vite test (dedicated command)
vite-plus lint # Run oxlint (dedicated command)

# Run tasks across packages (explicit mode)
vite-plus run build -r # recursive with topological ordering
vite-plus run app#build web#build # specific packages
vite-plus run build -r --no-topological # recursive without implicit deps

# Run task in current package (implicit mode - for non-built-in tasks)
vite-plus dev # runs dev script from package.json
```

## Key Architecture
Expand Down
2 changes: 2 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions crates/vite_error/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,15 @@ pub enum Error {
#[error(transparent)]
SerdeYmlError(#[from] serde_yml::Error),

#[error("Lint failed")]
LintFailed { status: String, reason: String },

#[error("Vite failed")]
ViteError { status: String, reason: String },

#[error("Test failed")]
TestFailed { status: String, reason: String },

#[error(transparent)]
AnyhowError(#[from] anyhow::Error),
}
69 changes: 68 additions & 1 deletion crates/vite_task/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,20 @@ mod task_command;
mod task_graph_builder;
mod workspace;

use std::{ffi::OsStr, sync::Arc};
use std::{ffi::OsStr, future::Future, sync::Arc};

use bincode::{Decode, Encode};
use compact_str::ToCompactString;
use diff::Diff;
use serde::{Deserialize, Serialize};
use vite_error::Error;

use crate::{
ResolveCommandResult,
cmd::TaskParsedCommand,
collections::{HashMap, HashSet},
config::name::TaskName,
execute::TaskEnvs,
str::Str,
};

Expand Down Expand Up @@ -90,6 +94,49 @@ impl ResolvedTask {
pub fn display_name(&self) -> Str {
self.name.to_compact_string().into()
}

#[tracing::instrument(skip(workspace, resolve_command, args))]
/// Resolve a built-in task, like `vite lint`, `vite build`
pub(crate) async fn resolve_from_built_in<
Resolved: Future<Output = Result<ResolveCommandResult, Error>>,
ResolveFn: Fn() -> Resolved,
>(
workspace: &Workspace,
resolve_command: ResolveFn,
task_name: &str,
args: impl Iterator<Item = impl AsRef<str>> + Clone,
) -> Result<Self, Error> {
let ResolveCommandResult { bin_path, envs } = resolve_command().await?;
let link_task = TaskCommand::Parsed(TaskParsedCommand {
args: args.clone().map(|arg| arg.as_ref().into()).collect(),
envs: envs.into_iter().map(|(k, v)| (k.into(), v.into())).collect(),
program: bin_path.into(),
});
let task_config: TaskConfig = link_task.clone().into();
let resolved_task_config = ResolvedTaskConfig {
config_dir: workspace.dir.as_path().to_string_lossy().as_ref().into(),
config: task_config,
};
let resolved_envs = TaskEnvs::resolve(workspace.dir.as_path(), &resolved_task_config)?;
let resolved_command = ResolvedTaskCommand {
fingerprint: CommandFingerprint {
cwd: workspace.dir.as_path().to_string_lossy().as_ref().into(),
command: link_task,
envs_without_pass_through: resolved_envs.envs_without_pass_through,
},
all_envs: resolved_envs.all_envs,
};
Ok(Self {
name: TaskName {
package_name: workspace.package_json.name.as_str().into(),
task_group_name: task_name.into(),
subcommand_index: None,
},
args: args.map(|arg| arg.as_ref().into()).collect(),
resolved_config: resolved_task_config,
resolved_command,
})
}
}

#[derive(Clone)]
Expand All @@ -112,11 +159,31 @@ impl std::fmt::Debug for ResolvedTaskCommand {
}
}

/// Fingerprint for command execution that affects caching.
///
/// # Environment Variable Impact on Cache
///
/// The `envs_without_pass_through` field is crucial for cache correctness:
/// - Only includes envs explicitly declared in the task's `envs` array
/// - Does NOT include pass-through envs (PATH, CI, etc.)
/// - These envs become part of the cache key
///
/// When a task runs:
/// 1. All envs (including pass-through) are available to the process
/// 2. Only declared envs affect the cache key
/// 3. If a declared env changes value, cache will miss
/// 4. If a pass-through env changes, cache will still hit
///
/// For built-in tasks (lint, build, etc):
/// - The resolver provides envs which become part of the fingerprint
/// - If resolver provides different envs between runs, cache breaks
/// - Each built-in task type must have unique task name to avoid cache collision
#[derive(Encode, Decode, Debug, Serialize, PartialEq, Eq, Diff, Clone)]
#[diff(attr(#[derive(Debug)]))]
pub struct CommandFingerprint {
pub cwd: Str,
pub command: TaskCommand,
/// Environment variables that affect caching (excludes pass-through envs)
pub envs_without_pass_through: HashMap<Str, Str>,
}

Expand Down
44 changes: 44 additions & 0 deletions crates/vite_task/src/config/workspace.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,50 @@ impl Workspace {
Self::load_with_cache_path(dir, None, topological_run)
}

pub fn partial_load(dir: PathBuf) -> Result<Self, Error> {
Self::partial_load_with_cache_path(dir, None)
}

pub fn partial_load_with_cache_path(
dir: PathBuf,
cache_path: Option<PathBuf>,
) -> Result<Self, Error> {
let cache_path = cache_path.unwrap_or_else(|| {
if let Ok(env_cache_path) = std::env::var("VITE_CACHE_PATH") {
PathBuf::from(env_cache_path)
} else {
dir.join("node_modules/.vite/task-cache.db")
}
});

if !cache_path.exists()
&& let Some(cache_dir) = cache_path.parent()
{
tracing::info!("Creating task cache directory at {}", cache_dir.display());
std::fs::create_dir_all(cache_dir)?;
}
let task_cache = TaskCache::load_from_file(&cache_path)?;

let package_json_path = dir.join("package.json");
let package_json = if package_json_path.exists() {
let file = File::open(&package_json_path)?;
let reader = BufReader::new(file);
serde_json::from_reader(reader)?
} else {
PackageJson::default()
};

Ok(Self {
package_graph: Graph::new(),
dir,
task_cache,
fs: CachedFileSystem::default(),
package_json,
task_graph: StableDiGraph::new(),
topological_run: false,
})
}

pub fn load_with_cache_path(
dir: PathBuf,
cache_path: Option<PathBuf>,
Expand Down
37 changes: 34 additions & 3 deletions crates/vite_task/src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use wax::Glob;
use crate::{
Error,
collections::{HashMap, HashSet},
config::{ResolvedTask, ResolvedTaskConfig, TaskCommand},
config::{ResolvedTask, ResolvedTaskCommand, ResolvedTaskConfig, TaskCommand},
maybe_str::MaybeString,
str::Str,
};
Expand Down Expand Up @@ -85,9 +85,38 @@ async fn collect_std_outputs(
}
}

/// Environment variables for task execution.
///
/// # How Environment Variables Affect Caching
///
/// Vite-plus distinguishes between two types of environment variables:
///
/// 1. **Declared envs** (in task config's `envs` array):
/// - Explicitly declared as dependencies of the task
/// - Included in `envs_without_pass_through`
/// - Changes to these invalidate the cache
/// - Example: `NODE_ENV`, `API_URL`, `BUILD_MODE`
///
/// 2. **Pass-through envs** (in task config's `pass_through_envs` or defaults like PATH):
/// - Available to the task but don't affect caching
/// - Only in `all_envs`, NOT in `envs_without_pass_through`
/// - Changes to these don't invalidate cache
/// - Example: PATH, HOME, USER, CI
///
/// ## Cache Key Generation
/// - Only `envs_without_pass_through` is included in the cache key
/// - This ensures tasks are re-run when important envs change
/// - But allows cache reuse when only incidental envs change
///
/// ## Common Issues
/// - If a built-in resolver provides different envs, cache will be polluted
/// - Missing important envs from `envs` array = stale cache on env changes
/// - Including volatile envs in `envs` array = unnecessary cache misses
#[derive(Debug)]
pub struct TaskEnvs {
/// All environment variables available to the task (declared + pass-through)
pub all_envs: HashMap<Str, Arc<OsStr>>,
/// Only declared envs that affect the cache key (excludes pass-through)
pub envs_without_pass_through: HashMap<Str, Str>,
}

Expand Down Expand Up @@ -141,8 +170,10 @@ impl TaskEnvs {
}
}

pub async fn execute_task(task: &ResolvedTask, base_dir: &Path) -> Result<ExecutedTask, Error> {
let resolved_command = &task.resolved_command;
pub async fn execute_task(
resolved_command: &ResolvedTaskCommand,
base_dir: &Path,
) -> Result<ExecutedTask, Error> {
let spy = Spy::global()?;

let mut cmd = match &resolved_command.fingerprint.command {
Expand Down
Loading
Loading