-
-
Notifications
You must be signed in to change notification settings - Fork 775
feat(prepare): add mise prepare command for dependency preparation
#7281
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
mise prepare command for dependency preparationmise prepare command for dependency preparation
mise prepare command for dependency preparationmise prepare command for dependency preparation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR introduces a new prepare system to mise that automatically ensures project dependencies are ready before running commands. When executing commands like mise x npm run dev, mise now checks if dependencies are stale by comparing modification times of lockfiles against output directories, running install commands only when needed.
Key changes:
- Adds
mise preparecommand with options for dry-run, force execution, and provider listing - Integrates auto-prepare into
mise xandmise runworkflows with opt-out via--no-prepareflag - Implements NPM provider with detection for npm/yarn/pnpm/bun through lockfile patterns
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
| src/prepare/mod.rs | Defines core PrepareProvider trait and PrepareCommand structure |
| src/prepare/engine.rs | Implements PrepareEngine for discovering, checking, and executing prepare steps |
| src/prepare/rule.rs | Defines configuration structures for prepare rules and provider overrides |
| src/prepare/providers/npm.rs | Implements NPM prepare provider with package manager detection |
| src/prepare/providers/custom.rs | Implements custom prepare provider for user-defined rules with glob support |
| src/prepare/providers/mod.rs | Exports prepare provider implementations |
| src/cli/prepare.rs | Implements prepare CLI command with list, dry-run, and force options |
| src/cli/exec.rs | Integrates auto-prepare into exec command flow |
| src/cli/run.rs | Integrates auto-prepare into run command flow |
| src/cli/en.rs | Adds no_prepare field to en command |
| src/cli/mod.rs | Registers prepare command in CLI |
| src/config/config_file/mod.rs | Adds prepare_config method to ConfigFile trait |
| src/config/config_file/mise_toml.rs | Adds prepare configuration support to MiseToml |
| src/shims.rs | Disables prepare for shims to avoid performance impact |
| src/main.rs | Registers prepare module |
| settings.toml | Adds prepare.auto and prepare.auto_timeout settings |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
src/prepare/providers/npm.rs
Outdated
| } | ||
|
|
||
| /// Detect the package manager from lockfile presence | ||
| fn detect_package_manager(project_root: &PathBuf) -> (Option<PackageManager>, Option<PathBuf>) { |
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The function returns a PathBuf for the lockfile but then later code can fall back to package.json which is misleading. When PackageManager::Npm is returned with package.json (lines 78-80), the returned PathBuf represents package.json not a lockfile, making the return type semantically inconsistent with the function's purpose of detecting a package manager from lockfiles.
src/prepare/providers/npm.rs
Outdated
| let parts: Vec<&str> = custom_run.split_whitespace().collect(); | ||
| let (program, args) = parts.split_first().unwrap_or((&"npm", &[])); |
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shell command parsing with split_whitespace() doesn't handle quoted arguments correctly. For example, a command like npm run \"my script\" would be split into separate arguments incorrectly. Consider using a proper shell parser or documenting that complex commands aren't supported.
src/prepare/providers/custom.rs
Outdated
| let parts: Vec<&str> = self.rule.run.split_whitespace().collect(); | ||
| let (program, args) = parts | ||
| .split_first() | ||
| .ok_or_else(|| eyre::eyre!("prepare rule {} has empty run command", self.id))?; |
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Shell command parsing with split_whitespace() doesn't handle quoted arguments correctly. Commands with quoted strings like sh -c \"echo hello\" will be incorrectly split. Consider using a proper shell parser or documenting this limitation.
src/prepare/engine.rs
Outdated
| let outputs_mtime = Self::last_modified(&outputs)?; | ||
|
|
||
| match (sources_mtime, outputs_mtime) { | ||
| (Some(src), Some(out)) => Ok(src < out), // Fresh if outputs newer than sources |
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The comment states 'Fresh if outputs newer than sources' but the comparison src < out returns true when source mtime is less than (older than) output mtime. While this is correct, the phrasing could be clearer: 'Fresh if sources older than outputs' would match the actual comparison direction.
| (Some(src), Some(out)) => Ok(src < out), // Fresh if outputs newer than sources | |
| (Some(src), Some(out)) => Ok(src < out), // Fresh if sources older than outputs |
settings.toml
Outdated
| [prepare.auto_timeout] | ||
| default = "5m" | ||
| description = "Timeout for auto-prepare operations." | ||
| env = "MISE_PREPARE_AUTO_TIMEOUT" | ||
| type = "Duration" | ||
|
|
Copilot
AI
Dec 13, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The prepare.auto_timeout setting is defined but never used in the PrepareEngine implementation. The timeout is not enforced when running prepare commands in engine.rs, making this configuration non-functional.
| [prepare.auto_timeout] | |
| default = "5m" | |
| description = "Timeout for auto-prepare operations." | |
| env = "MISE_PREPARE_AUTO_TIMEOUT" | |
| type = "Duration" |
| (Some(src), Some(out)) => Ok(src < out), // Fresh if outputs newer than sources | ||
| (None, _) => Ok(true), // No sources exist, consider fresh | ||
| (_, None) => Ok(false), // No outputs exist, not fresh | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| ..Default::default() | ||
| }) | ||
| .await?; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Project prepare.auto config is ignored
Auto-prepare gating checks Settings::get().prepare.auto, but the per-project [prepare] auto = false value parsed into PrepareConfig.auto is never consulted. This makes the documented project-level auto toggle ineffective for mise x/mise run.
Additional Locations (1)
| go: other.go.clone().or_else(|| self.go.clone()), | ||
| python: other.python.clone().or_else(|| self.python.clone()), | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Merging prepare configs unintentionally flips auto
PrepareConfig::merge always assigns auto: other.auto. Because auto defaults to true, any config file that defines prepare rules/providers but omits auto will override an earlier auto = false from another config, re-enabling auto-prepare unintentionally.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Run help triggers installs and prepare
mise run -h/--help now builds a Toolset, installs missing versions, and may run PrepareEngine before checking the help flags. This introduces unexpected side effects and latency for help output, and can install dependencies/tools when the user only requested help.
src/cli/run.rs#L200-L239
Lines 200 to 239 in 135af4b
| impl Run { | |
| pub async fn run(mut self) -> Result<()> { | |
| let mut config = Config::get().await?; | |
| // Build and install toolset so tools like npm are available for prepare | |
| let mut ts = ToolsetBuilder::new() | |
| .with_args(&self.tool) | |
| .with_default_to_latest(true) | |
| .build(&config) | |
| .await?; | |
| let opts = InstallOptions { | |
| jobs: self.jobs, | |
| raw: self.raw, | |
| ..Default::default() | |
| }; | |
| ts.install_missing_versions(&mut config, &opts).await?; | |
| // Run prepare with toolset environment (includes tools PATH) | |
| if !self.no_prepare && Settings::get().prepare.auto { | |
| let env = ts.env_with_path(&config).await?; | |
| let engine = PrepareEngine::new(config.clone())?; | |
| engine | |
| .run(PrepareOptions { | |
| env, | |
| ..Default::default() | |
| }) | |
| .await?; | |
| } | |
| if self.task == "-h" { | |
| self.get_clap_command().print_help()?; | |
| return Ok(()); | |
| } | |
| if self.task == "--help" { | |
| self.get_clap_command().print_long_help()?; | |
| return Ok(()); | |
| } | |
Hyperfine Performance
|
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2025.12.7 x -- echo |
20.2 ± 0.2 | 19.7 | 22.3 | 1.03 ± 0.02 |
mise x -- echo |
19.7 ± 0.3 | 19.0 | 20.8 | 1.00 |
mise env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2025.12.7 env |
19.7 ± 0.3 | 19.3 | 21.2 | 1.03 ± 0.03 |
mise env |
19.2 ± 0.5 | 18.5 | 23.4 | 1.00 |
mise hook-env
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2025.12.7 hook-env |
19.7 ± 0.6 | 19.2 | 27.9 | 1.03 ± 0.03 |
mise hook-env |
19.2 ± 0.3 | 18.7 | 21.3 | 1.00 |
mise ls
| Command | Mean [ms] | Min [ms] | Max [ms] | Relative |
|---|---|---|---|---|
mise-2025.12.7 ls |
16.5 ± 0.2 | 16.1 | 17.9 | 1.00 |
mise ls |
16.7 ± 0.2 | 16.2 | 18.2 | 1.02 ± 0.02 |
xtasks/test/perf
| Command | mise-2025.12.7 | mise | Variance |
|---|---|---|---|
| install (cached) | 108ms | 107ms | +0% |
| ls (cached) | 65ms | 65ms | +0% |
| bin-paths (cached) | 70ms | 71ms | -1% |
| task-ls (cached) | 433ms | ✅ 275ms | +57% |
✅ Performance improvement: task-ls cached is 57%
|
bugbot run |
src/prepare/mod.rs
Outdated
| config: &rule::PrepareProviderConfig, | ||
| ) -> Self { | ||
| let parts: Vec<&str> = run.split_whitespace().collect(); | ||
| let (program, args) = parts.split_first().unwrap_or((&"sh", &[])); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Command parsing uses naive split instead of shell parsing
The PrepareCommand::from_string function uses split_whitespace() to parse commands, which doesn't handle quoted arguments or shell escaping. Commands like npm run "my script" or sh -c 'complex command' would be incorrectly split into ["npm", "run", "\"my", "script\""] instead of ["npm", "run", "my script"]. The rest of the codebase uses shell_words::split() for proper shell-aware parsing (e.g., in en.rs, settings.rs). Custom prepare rules and provider overrides with quoted arguments will fail silently with incorrect argument splitting.
Adds a new "prepare" system to mise that ensures project dependencies are ready before running commands. When running `mise x npm run dev`, mise checks if `node_modules/` is stale relative to `package-lock.json` and runs `npm install` if needed. Features: - New `mise prepare` command (alias: `mise prep`) with --dry-run, --force, --list options - Auto-prepare before `mise x` and `mise run` (can disable with --no-prepare) - Built-in NPM provider (detects npm/yarn/pnpm/bun from lockfiles) - Custom prepare rules via [prepare.rules.*] in mise.toml - Stateless mtime-based freshness checking - Extensible framework for future providers (cargo, go, pip, etc.) Configuration: - `settings.prepare.auto` - enable/disable auto-prepare (default: true) - `settings.prepare.auto_timeout` - timeout for prepare operations - `[prepare]` section in mise.toml for custom rules 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Fix prepare to run after tools are installed so npm/yarn are available - Pass toolset environment to PrepareEngine for tool PATH - Add e2e test for mise prep command - Add e2e-win test for mise prep command - Use miseprintln! for user-facing output 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Remove global `prepare.auto` and `prepare.auto_timeout` settings - Add per-provider `auto` field (defaults to false) - Remove `.rules.` prefix - custom providers are now `[prepare.codegen]` - Providers must be explicitly configured (no auto-detection) - Update schema, docs, and tests for new config structure Config example: ```toml [prepare.npm] auto = true # Auto-run before mise x/run [prepare.codegen] sources = ["schema.graphql"] outputs = ["generated/"] run = "npm run codegen" ``` 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
Adds a warning when prepare providers have stale dependencies during hook-env execution. Users will see "mise WARN prepare: npm may need update, run 'mise prep'" when lockfiles are newer than installed outputs. - Add check_staleness() method to PrepareEngine - Add notify_if_stale() function called from hook_env display_status - Add status.show_prepare_stale setting (default: true) to control warning 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Each Node.js package manager now has its own dedicated provider: - npm: detects package-lock.json - yarn: detects yarn.lock - pnpm: detects pnpm-lock.yaml - bun: detects bun.lockb or bun.lock This allows users to configure each provider independently: [prepare.npm] auto = true [prepare.yarn] auto = true 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
…iders Adds built-in prepare providers for major package managers: - cargo: Cargo.lock → target/ (cargo fetch) - go: go.sum → vendor/ or go.sum (go mod download) - pip: requirements.txt → .venv/ (pip install -r requirements.txt) - poetry: poetry.lock → .venv/ (poetry install) - uv: uv.lock → .venv/ (uv sync) - bundler: Gemfile.lock → vendor/bundle/ or .bundle/ (bundle install) - composer: composer.lock → vendor/ (composer install) Also simplifies the config by removing: - enabled field (just don't configure if not wanted) - extra_sources/extra_outputs (use custom providers) - priority (providers run sequentially) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Use parallel::parallel to execute multiple prepare providers concurrently, respecting the jobs setting for concurrency limits. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
- Add [experimental] tag to prepare command docstring - Add ensure_experimental check in PrepareEngine::new() - Create prepare feature documentation at docs/dev-tools/prepare.md - Add prepare to dev-tools sidebar - Regenerate CLI docs with experimental badge 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
- Fix Go provider self-referential freshness check by using go.mod as source and go.sum as output (instead of go.sum in both) - Move help flag check before toolset build in `mise run` to avoid unnecessary installs and prepare when only requesting help - Fix freshness check to use `<=` instead of `<` to handle equal mtimes on filesystems with coarse timestamp resolution 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
- Use shell_words::split() instead of split_whitespace() to correctly handle quoted arguments like `npm run "my script"` or `sh -c 'cmd'` - Return error for empty run commands instead of silently executing bare shell 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Use $TestDrive instead of (Get-Location).Path for MISE_TRUSTED_CONFIG_PATHS since native executables can't understand PowerShell PSDrive paths. Also add MISE_EXPERIMENTAL=1 for prepare tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
The cargo provider's outputs() returned [target/] but cargo fetch downloads crates to ~/.cargo/registry/, not target/. This caused the freshness check to always consider cargo stale. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Use Join-Path $TestDrive for file creation to ensure files are created at the physical path, not potentially a PSDrive-relative path. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
The go provider's outputs() returns vendor/ when it exists, but prepare_command() was always running `go mod download` which doesn't update vendor/. This caused infinite re-runs since the freshness check always failed. Now the command dynamically chooses: - `go mod vendor` when vendor/ directory exists - `go mod download` when it doesn't 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Custom provider glob patterns like `sources = ["*.graphql"]` that match no files returned an empty vec, triggering `sources.is_empty()` which always returned stale. Non-glob patterns for non-existent files returned a vec with the path, which went through mtime comparison and returned fresh (since no sources exist). Both represent "no source files exist" but had opposite behaviors. Fix: - Remove `sources.is_empty()` check (keep only `outputs.is_empty()`) - Reorder match arms so `(_, None)` takes precedence for `(None, None)` Now both glob and non-glob patterns behave consistently: - No sources exist + outputs exist → fresh (nothing to build from) - No sources exist + outputs don't exist → stale (need to create outputs) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1. Windows e2e tests: Use physical path ($TestDrive) instead of PSDrive path (TestDrive:) since mise (native executable) can't understand PowerShell PSDrive paths. 2. Go provider: Check go.mod for applicability instead of go.sum. All other providers check their source/lockfile, but Go was uniquely checking go.sum (an output). New Go projects with go.mod but no go.sum yet would not have the provider activate. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1. Windows e2e tests: Use relative paths for file creation since we're already in $TestDrive from BeforeAll. The absolute path approach wasn't working reliably with mise's project_root detection. 2. Directory freshness: For outputs that are directories (like .venv), recursively find the newest file within (up to 3 levels deep) for mtime comparison. This fixes Python providers (pip, poetry, uv) where .venv directory mtime doesn't update when packages are installed to subdirectories like .venv/lib/pythonX.Y/site-packages/. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Each test now creates a unique GUID-named subdirectory under $TestDrive to avoid config inheritance from the repo root's mise.toml. This ensures that: 1. Each test has its own isolated environment 2. MISE_TRUSTED_CONFIG_PATHS points to the exact test directory 3. No parent directory configs interfere with the test Also switched to Set-Content for simpler file creation. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Other tests like vfox need MISE_EXPERIMENTAL for custom backends. Removing it in prepare's AfterAll was breaking subsequent tests. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Each test should be self-contained. The vfox test needs MISE_EXPERIMENTAL for custom backends like vfox-npm, so set it in its own BeforeAll. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
…nfig Mise was finding the repo's mise.toml by walking up directories, which was setting project_root to the repo root instead of the test directory. Setting MISE_CONFIG_FILE to the test's mise.toml prevents this. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
Windows config discovery walks up directories and finds the repo's mise.toml, setting project_root incorrectly for isolated tests. There's no env var to override project_root. Keep the basic "no providers" test which works. The full prepare functionality is comprehensively tested in Linux e2e tests at e2e/cli/test_prepare. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
|
|
||
| fn outputs(&self) -> Vec<PathBuf> { | ||
| vec![self.project_root.join(".venv")] | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bug: Poetry provider assumes non-default venv location
The poetry provider checks .venv as the output directory, but Poetry's default behavior creates virtual environments in {cache-dir}/virtualenvs/, not in the project's .venv. Only when virtualenvs.in-project = true is explicitly configured does Poetry use .venv. With the default configuration, .venv won't exist, causing freshness checks to always return stale and poetry install to run on every mise run or mise x invocation when auto = true.
Summary
Adds a new "prepare" system to mise that ensures project dependencies are ready before running commands. When running
mise x npm run dev, mise checks ifnode_modules/is stale relative topackage-lock.jsonand runsnpm installif needed.mise preparecommand with--dry-run,--force,--listoptionsmise xandmise run(can disable with--no-prepare)[prepare.rules.*]in mise.tomlExample usage
Configuration
Test plan
🤖 Generated with Claude Code
Note
Adds an experimental
mise preparecommand and auto-prepare beforemise x/mise run, with built-in/custom providers and docs/completions/manpage updates.mise prepare(prep) command with--force,--list,--dry-run,--only,--skip.mise execandmise run; opt out via--no-prepare(shims skip by default).hook-env; new settingsettings.status.show_prepare_stale.npm,yarn,pnpm,bun,go,pip,poetry,uv,bundler,composer.[prepare.<id>]inmise.toml(sources/outputs/run/env/dir/auto/description;disablelist).docs/dev-tools/prepare.md; update CLI docs, manpage, usage spec (mise.usage.kdl), VitePress sidebar, and Fig completions.Written by Cursor Bugbot for commit b3e0ed3. This will update automatically on new commits. Configure here.