diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bf8c88472..ebc33c844 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ env: NODE_VERSION: "20" BUN_VERSION: "1.3" GO_VERSION: "1.24" + # The mise tests install 2026.7.18 separately to cover managed downloads. + MISE_VERSION: "2026.8.3" PYTHON_VERSION: "3.12" PHP_VERSION: "8.4" RUBY_VERSION: "3.4" @@ -557,6 +559,17 @@ jobs: # Dummy dependency path to satisfy required input while enabling caching cache-dependency-path: LICENSE + - name: "Install mise" + if: ${{ contains(format(' {0} ', matrix.languages), ' mise ') }} + uses: jdx/mise-action@7e36c90d9ab29c415a2384db3006f3ec8a8cc654 # v4.2.4 + with: + version: ${{ env.MISE_VERSION }} + install: false + cache: false + env: false + add_shims_to_path: false + working_directory: ${{ runner.temp }} + - name: "Install Lua" if: ${{ contains(format(' {0} ', matrix.languages), ' lua ') }} uses: leafo/gh-actions-lua@6919171ccf181b826f44b9bca76307b577217377 # v13.0.0 diff --git a/AGENTS.md b/AGENTS.md index 56173bb85..83a46407e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,7 +5,8 @@ - Prefer the smallest coherent change. Reuse existing mechanisms and avoid wrappers or abstractions for speculative or unmeasured gains. - Prefer direct `if` or `match` control flow and explicit state types over clever - combinators, wrappers, or invalid boolean/`Option` combinations. + combinators, wrappers, or invalid boolean/`Option` combinations. Prefer plain + `if`/`else` to `.then()` or `.then_some()`. - Try hard to avoid `panic!`, `unreachable!`, `.unwrap()`, and `.expect()`. Encode those constraints in the type system instead. More explicit code or a larger refactor is acceptable when it avoids these calls. diff --git a/README.md b/README.md index e945c9395..84c935f8e 100644 --- a/README.md +++ b/README.md @@ -359,7 +359,7 @@ prek self update ### prek is easier to work with - No need to install Python or any other runtime just to use `prek`; it is a single binary. -- Its [language support](https://prek.j178.dev/languages/) covers every language available in `pre-commit`, plus Bun, Deno, and PHP, and it automatically installs managed toolchains when needed for Python, Node.js, Bun, Deno, Go, Rust, and Ruby. +- Its [language support](https://prek.j178.dev/languages/) covers every language available in `pre-commit`, plus Bun, Deno, mise, and PHP, and it automatically installs managed toolchains when needed for Python, Node.js, Bun, Deno, Go, mise, Rust, and Ruby. - It supports native [`prek.toml`](https://prek.j178.dev/configuration/) in addition to pre-commit YAML, and [`prek util yaml-to-toml`](https://prek.j178.dev/reference/cli/#prek-util-yaml-to-toml) helps migrate existing configs. - Built-in support for [workspaces](https://prek.j178.dev/workspace/) means monorepos can keep separate configs per project and still run everything from one command, while independent same-depth projects run concurrently without mixing file scopes. - [`prek install`](https://prek.j178.dev/reference/cli/#prek-install) and [`prek uninstall`](https://prek.j178.dev/reference/cli/#prek-uninstall) honor repo-local and worktree-local `core.hooksPath`. diff --git a/crates/prek-consts/src/env_vars.rs b/crates/prek-consts/src/env_vars.rs index a60475970..b3518f8ad 100644 --- a/crates/prek-consts/src/env_vars.rs +++ b/crates/prek-consts/src/env_vars.rs @@ -111,6 +111,7 @@ impl EnvVars { pub const PREK_INTERNAL__DENO_BINARY_NAME: &'static str = "PREK_INTERNAL__DENO_BINARY_NAME"; pub const PREK_INTERNAL__DOTNET_BINARY_NAME: &'static str = "PREK_INTERNAL_DOTNET_BINARY_NAME"; pub const PREK_INTERNAL__GO_BINARY_NAME: &'static str = "PREK_INTERNAL__GO_BINARY_NAME"; + pub const PREK_INTERNAL__MISE_BINARY_NAME: &'static str = "PREK_INTERNAL__MISE_BINARY_NAME"; pub const PREK_INTERNAL__NODE_BINARY_NAME: &'static str = "PREK_INTERNAL__NODE_BINARY_NAME"; pub const PREK_INTERNAL__RUSTUP_BINARY_NAME: &'static str = "PREK_INTERNAL__RUSTUP_BINARY_NAME"; pub const PREK_INTERNAL__SKIP_CABAL_UPDATE: &'static str = "PREK_INTERNAL__SKIP_CABAL_UPDATE"; @@ -138,6 +139,18 @@ impl EnvVars { pub const DENO_DIR: &'static str = "DENO_DIR"; pub const DENO_NO_UPDATE_CHECK: &'static str = "DENO_NO_UPDATE_CHECK"; + // mise related + pub const MISE_DATA_DIR: &'static str = "MISE_DATA_DIR"; + pub const MISE_CACHE_DIR: &'static str = "MISE_CACHE_DIR"; + pub const MISE_CONFIG_DIR: &'static str = "MISE_CONFIG_DIR"; + pub const MISE_STATE_DIR: &'static str = "MISE_STATE_DIR"; + pub const MISE_SYSTEM_CONFIG_DIR: &'static str = "MISE_SYSTEM_CONFIG_DIR"; + pub const MISE_SYSTEM_DATA_DIR: &'static str = "MISE_SYSTEM_DATA_DIR"; + pub const MISE_TMP_DIR: &'static str = "MISE_TMP_DIR"; + pub const MISE_CEILING_PATHS: &'static str = "MISE_CEILING_PATHS"; + pub const MISE_NO_CONFIG: &'static str = "MISE_NO_CONFIG"; + pub const MISE_SYSTEM_DEPS: &'static str = "MISE_SYSTEM_DEPS"; + // GitHub API authentication (to avoid rate limits) pub const GITHUB_TOKEN: &'static str = "GITHUB_TOKEN"; diff --git a/crates/prek/src/checksum.rs b/crates/prek/src/checksum.rs index 7cb224552..819e90f2e 100644 --- a/crates/prek/src/checksum.rs +++ b/crates/prek/src/checksum.rs @@ -101,8 +101,9 @@ pub(crate) fn digest_from_sha256sums( continue; }; let name = name.trim(); - // GNU-style checksum files may prefix binary-mode filenames with `*`. + // GNU entries use `*` for binary mode and may retain a leading `./` from the input path. let name = name.strip_prefix('*').unwrap_or(name); + let name = name.strip_prefix("./").unwrap_or(name); if name == filename { return digest.parse().map(Some); } @@ -169,6 +170,16 @@ mod tests { Ok(()) } + #[test] + fn parses_sha256sums_relative_filename() -> Result<()> { + let digest = + digest_from_sha256sums(&format!("{EMPTY_SHA256} ./target.tar.gz"), "target.tar.gz")? + .context("expected target digest")?; + + assert_eq!(digest.to_string(), EMPTY_SHA256); + Ok(()) + } + #[test] fn returns_none_for_missing_sha256sums_entry() -> Result<()> { let digest = digest_from_sha256sums( diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index c1df7a3fa..b1add2b6c 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -398,6 +398,7 @@ pub enum Language { Haskell, Julia, Lua, + Mise, Node, Perl, Php, diff --git a/crates/prek/src/languages/mise/installer.rs b/crates/prek/src/languages/mise/installer.rs new file mode 100644 index 000000000..e079d5671 --- /dev/null +++ b/crates/prek/src/languages/mise/installer.rs @@ -0,0 +1,325 @@ +use std::env::consts::EXE_EXTENSION; +use std::fmt::Display; +use std::path::{Path, PathBuf}; +use std::sync::LazyLock; + +use anyhow::{Context, Result}; +use itertools::Itertools; +use prek_consts::env_vars::{EnvVars, EnvVarsRead}; +use semver::Version; +use target_lexicon::{Architecture, ArmArchitecture, Environment, HOST, OperatingSystem, Triple}; +use tracing::{debug, trace, warn}; + +use super::{inherited_mise_vars, mise_ceiling}; +use crate::archive; +use crate::checksum::{Sha256Digest, digest_from_sha256sums}; +use crate::fs::{LockedFile, is_executable}; +use crate::git; +use crate::http::{REQWEST_CLIENT, download_artifact}; +use crate::languages::version::SemverRequest; +use crate::process::Cmd; +use crate::store::Store; + +// This is the first release where MISE_CEILING_PATHS also isolates early .miserc discovery. +const MIN_MISE_VERSION: Version = Version::new(2026, 5, 18); + +static MISE_BINARY_NAME: LazyLock = LazyLock::new(|| { + EnvVars + .var(EnvVars::PREK_INTERNAL__MISE_BINARY_NAME) + .unwrap_or_else(|_| "mise".to_string()) +}); + +#[derive(Debug)] +pub(crate) struct MiseResult { + mise: PathBuf, + version: Version, +} + +impl Display for MiseResult { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}@{}", self.mise.display(), self.version) + } +} + +impl MiseResult { + fn from_dir(dir: &Path, version: Version) -> Self { + Self { + mise: bin_dir(dir).join("mise").with_extension(EXE_EXTENSION), + version, + } + } + + pub(crate) async fn from_executable(mise: PathBuf) -> Result { + let isolated = tempfile::tempdir()?; + let mut command = Cmd::new(&mise); + for key in inherited_mise_vars() { + command.env_remove(key); + } + // Even `mise --version` discovers miserc files, initializes backend state, + // runs migrations and cache pruning, then checks for updates. CI disables + // the update check; disposable roots keep the probe away from user state. + let output = command + .current_dir(isolated.path()) + .env(EnvVars::CI, "1") + .env(EnvVars::MISE_DATA_DIR, isolated.path().join("data")) + .env(EnvVars::MISE_CACHE_DIR, isolated.path().join("cache")) + .env(EnvVars::MISE_CONFIG_DIR, isolated.path().join("config")) + .env(EnvVars::MISE_STATE_DIR, isolated.path().join("state")) + .env( + EnvVars::MISE_SYSTEM_CONFIG_DIR, + isolated.path().join("system-config"), + ) + .env( + EnvVars::MISE_SYSTEM_DATA_DIR, + isolated.path().join("system-data"), + ) + .env(EnvVars::MISE_CEILING_PATHS, mise_ceiling(isolated.path())?) + .env(EnvVars::MISE_NO_CONFIG, "1") + .arg("--version") + .check(true) + .output() + .await?; + let output = String::from_utf8_lossy(&output.stdout); + let version = output + .split_whitespace() + .next() + .context("Failed to parse mise version output")? + .parse() + .context("Failed to parse mise version")?; + + Ok(Self { mise, version }) + } + + pub(crate) fn mise(&self) -> &Path { + &self.mise + } + + pub(crate) fn version(&self) -> &Version { + &self.version + } +} + +pub(crate) struct MiseInstaller { + root: PathBuf, +} + +impl MiseInstaller { + pub(crate) fn new(root: PathBuf) -> Self { + Self { root } + } + + pub(crate) async fn install( + &self, + store: &Store, + request: &SemverRequest, + allows_download: bool, + ) -> Result { + fs_err::tokio::create_dir_all(&self.root).await?; + let _lock = LockedFile::acquire(self.root.join(".lock"), "mise").await?; + + if let Some(result) = self.find_installed(request).await { + trace!(%result, "Found managed mise"); + return Ok(result); + } + + if let Some(result) = self.find_system_mise(request).await { + trace!(%result, "Using system mise"); + return Ok(result); + } + + if !allows_download { + anyhow::bail!("No compatible mise executable found and downloads are disabled"); + } + + let version = self.resolve_version(request).await?; + trace!(%version, "Downloading mise"); + self.download(store, &version).await + } + + async fn find_installed(&self, request: &SemverRequest) -> Option { + let installed = fs_err::read_dir(&self.root) + .ok() + .into_iter() + .flatten() + .filter_map(|entry| match entry { + Ok(entry) => Some(entry), + Err(err) => { + warn!(?err, "Failed to read managed mise entry"); + None + } + }) + .filter(|entry| entry.file_type().is_ok_and(|kind| kind.is_dir())) + .filter_map(|entry| { + let version = entry + .file_name() + .to_string_lossy() + .parse::() + .ok()?; + Some((version, entry.path())) + }) + .sorted_unstable_by(|(a, _), (b, _)| b.cmp(a)); + + for (version, path) in installed { + let candidate = MiseResult::from_dir(&path, version.clone()); + if !is_compatible(request, &version) || !is_executable(candidate.mise()) { + continue; + } + match MiseResult::from_executable(candidate.mise().to_path_buf()).await { + Ok(result) if result.version() == &version => return Some(result), + Ok(result) => { + warn!(expected = %version, found = %result.version(), path = %path.display(), "Managed mise version mismatch"); + } + Err(err) => { + warn!(?err, path = %path.display(), "Failed to query managed mise version"); + } + } + } + + None + } + + async fn find_system_mise(&self, request: &SemverRequest) -> Option { + let paths = match which::which_all(&*MISE_BINARY_NAME) { + Ok(paths) => paths, + Err(err) => { + debug!(%err, "No mise executable found in PATH"); + return None; + } + }; + + for path in paths { + match MiseResult::from_executable(path).await { + Ok(result) if is_compatible(request, result.version()) => return Some(result), + Ok(result) => trace!(%result, "System mise does not match request"), + Err(err) => warn!(?err, "Failed to query system mise version"), + } + } + + None + } + + async fn resolve_version(&self, request: &SemverRequest) -> Result { + let output = git::git_cmd()? + .arg("ls-remote") + .arg("--tags") + .arg("https://github.com/jdx/mise") + .output() + .await?; + let output = str::from_utf8(&output.stdout)?; + + output + .lines() + .filter_map(|line| { + let reference = line.split_once('\t')?.1; + reference + .strip_prefix("refs/tags/v")? + .parse::() + .ok() + }) + .filter(|version| is_compatible(request, version)) + .max() + .context("No released mise version matches the request") + } + + async fn download(&self, store: &Store, version: &Version) -> Result { + let (platform, extension) = release_platform(&HOST)?; + let filename = format!("mise-v{version}-{platform}.{extension}"); + let base_url = format!("https://github.com/jdx/mise/releases/download/v{version}"); + let url = format!("{base_url}/{filename}"); + let checksum_url = format!("{base_url}/SHASUMS256.txt"); + + let download = download_artifact(&url, &filename, store, async || { + Self::fetch_checksum(&checksum_url, &filename).await + }) + .await + .context("Failed to download mise")?; + let install_dir = tempfile::Builder::new() + .prefix(".install-") + .tempdir_in(&self.root)?; + let target_bin_dir = bin_dir(install_dir.path()); + fs_err::tokio::create_dir_all(&target_bin_dir).await?; + let target_binary = target_bin_dir.join("mise").with_extension(EXE_EXTENSION); + if HOST.operating_system == OperatingSystem::Windows { + fs_err::tokio::copy(download.path(), &target_binary).await?; + } else { + let extracted = archive::extract_archive(download.path()) + .await + .context("Failed to extract mise")?; + let source = bin_dir(&extracted) + .join("mise") + .with_extension(EXE_EXTENSION); + fs_err::tokio::rename(&source, &target_binary).await?; + } + crate::fs::make_executable(&target_binary)?; + + let target = self.root.join(version.to_string()); + if target.exists() { + fs_err::tokio::remove_dir_all(&target).await?; + } + fs_err::tokio::rename(install_dir.keep(), &target).await?; + + Ok(MiseResult::from_dir(&target, version.clone())) + } + + async fn fetch_checksum(url: &str, filename: &str) -> Result> { + let checksums = REQWEST_CLIENT + .get(url) + .send() + .await + .with_context(|| format!("Failed to fetch mise checksums from {url}"))? + .error_for_status() + .with_context(|| format!("Failed to fetch mise checksums from {url}"))? + .text() + .await?; + digest_from_sha256sums(&checksums, filename) + } +} + +fn is_compatible(request: &SemverRequest, version: &Version) -> bool { + is_supported_version(version) && request.matches(version) +} + +pub(crate) fn is_supported_version(version: &Version) -> bool { + version >= &MIN_MISE_VERSION +} + +fn release_platform(host: &Triple) -> Result<(String, &'static str)> { + let platform = match (host.operating_system, host.architecture) { + (OperatingSystem::Darwin(_), Architecture::X86_64) => "macos-x64", + (OperatingSystem::Darwin(_), Architecture::Aarch64(_)) => "macos-arm64", + (OperatingSystem::Linux, Architecture::X86_64) => "linux-x64", + (OperatingSystem::Linux, Architecture::Aarch64(_)) => "linux-arm64", + (OperatingSystem::Linux, Architecture::Arm(ArmArchitecture::Armv7)) => "linux-armv7", + (OperatingSystem::Windows, Architecture::X86_64) => "windows-x64", + (OperatingSystem::Windows, Architecture::Aarch64(_)) => "windows-arm64", + (operating_system, architecture) => anyhow::bail!( + "Unsupported platform for mise: operating_system={operating_system:?}, architecture={architecture:?}" + ), + }; + let extension = if host.operating_system == OperatingSystem::Windows { + // mise's signed ZIP starts with a zipsign preamble, which async_zip's streaming reader + // cannot skip. + "exe" + } else { + "tar.gz" + }; + + let platform = if host.operating_system == OperatingSystem::Linux + && matches!( + host.environment, + Environment::Musl + | Environment::Musleabi + | Environment::Musleabihf + | Environment::Muslabi64 + ) { + format!("{platform}-musl") + } else { + platform.to_string() + }; + + Ok((platform, extension)) +} + +fn bin_dir(prefix: &Path) -> PathBuf { + prefix.join("bin") +} diff --git a/crates/prek/src/languages/mise/mise.rs b/crates/prek/src/languages/mise/mise.rs new file mode 100644 index 000000000..d5276321b --- /dev/null +++ b/crates/prek/src/languages/mise/mise.rs @@ -0,0 +1,298 @@ +use std::collections::BTreeMap; +use std::ffi::{OsStr, OsString}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use anyhow::{Context, Result}; +use prek_consts::env_vars::{EnvVars, EnvVarsRead}; +use tracing::debug; + +use super::installer::{MiseInstaller, MiseResult, is_supported_version}; +use super::{inherited_mise_vars, is_mise_var, mise_ceiling}; +use crate::cli::reporter::HookInstallReporter; +use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::languages::version::SemverRequest; +use crate::languages::{ExecutionEnvironment, LanguageBackend, is_path_env}; +use crate::process::Cmd; +use crate::store::{Store, ToolBucket}; + +/// Mutable mise state stays inside the hook environment because mise's install +/// identity does not include every backend option. Equivalent hooks reuse the +/// entire environment through prek's normal environment cache. +#[derive(Debug)] +struct MiseEnvironment { + root: PathBuf, +} + +impl MiseEnvironment { + fn new(env_path: &Path) -> Self { + Self { + root: env_path.join("mise"), + } + } + + fn vars(&self) -> [(&'static str, OsString); 9] { + let path = |name| self.root.join(name).into_os_string(); + [ + (EnvVars::MISE_DATA_DIR, path("data")), + (EnvVars::MISE_CACHE_DIR, path("cache")), + (EnvVars::MISE_CONFIG_DIR, path("config")), + (EnvVars::MISE_STATE_DIR, path("state")), + (EnvVars::MISE_SYSTEM_CONFIG_DIR, path("system-config")), + (EnvVars::MISE_SYSTEM_DATA_DIR, path("system-data")), + (EnvVars::MISE_TMP_DIR, path("tmp")), + (EnvVars::MISE_NO_CONFIG, OsString::from("1")), + (EnvVars::MISE_SYSTEM_DEPS, OsString::from("warn")), + ] + } + + fn command(&self, mise: &Path, cwd: &Path) -> Result { + let mut command = Cmd::new(mise); + for key in inherited_mise_vars() { + command.env_remove(key); + } + command + .current_dir(cwd) + .envs(self.vars()) + .env(EnvVars::MISE_CEILING_PATHS, mise_ceiling(cwd)?) + .arg("--yes") + .check(true); + Ok(command) + } + + fn apply_to_environment(&self, environment: &mut ExecutionEnvironment) { + for key in inherited_mise_vars() { + environment.env_remove(key); + } + environment.envs(self.vars()); + } +} + +#[derive(Debug, Copy, Clone)] +pub(crate) struct Mise; + +#[async_trait::async_trait(?Send)] +impl LanguageBackend for Mise { + async fn install( + &self, + store: &Store, + hook: Arc, + reporter: &HookInstallReporter, + ) -> Result { + let progress = reporter.on_install_start(&hook); + let installer = MiseInstaller::new(store.tools_path(ToolBucket::Mise)); + let request: &SemverRequest = hook.language_request.version(); + let mise = installer + .install(store, request, hook.language_request.allows_download()) + .await + .context("Failed to install mise")?; + + let mut info = InstallInfo::new(&hook, &store.hooks_dir())?; + info.with_toolchain(mise.mise().to_path_buf()) + .with_language_version(mise.version().clone()); + + let environment = MiseEnvironment::new(&info.env_path); + + // TODO(#2022): Support provisioning remote hooks from the repository's `mise.toml`. + if !hook.additional_dependencies.is_empty() { + debug!(deps = ?hook.additional_dependencies, "Installing mise tools"); + let mut command = environment.command(mise.mise(), tool_cwd(&hook))?; + command + .arg("install") + .arg("--") + .args(tools_with_versions(&hook.additional_dependencies)); + command + .output() + .await + .context("Failed to install mise tools")?; + } + + info.persist_env_path(); + reporter.on_install_complete(progress); + + Ok(InstalledHook::Installed { + hook, + info: Arc::new(info), + }) + } + + async fn check_health(&self, info: &InstallInfo) -> Result<()> { + let mise = MiseResult::from_executable(info.toolchain.clone()) + .await + .context("Failed to query mise version")?; + if mise.version() != &info.language_version { + anyhow::bail!( + "mise version mismatch: expected {}, found {}", + info.language_version, + mise.version() + ); + } + if !is_supported_version(mise.version()) { + anyhow::bail!("mise {} is no longer supported", mise.version()); + } + Ok(()) + } + + fn execution_environment( + &self, + store: &Store, + hook: &InstalledHook, + ) -> Result { + let info = hook.install_info().context("mise must be installed")?; + + let mut environment = ExecutionEnvironment::new(); + // Only prepend prek's managed bin directory. System mise is already on PATH, and moving + // its parent would also reorder unrelated executables in that directory. + if info + .toolchain + .starts_with(store.tools_path(ToolBucket::Mise)) + { + environment.set_path(managed_mise_path(&info.toolchain)?); + } + MiseEnvironment::new(&info.env_path).apply_to_environment(&mut environment); + Ok(environment) + } + + async fn prepare_execution_environment( + &self, + hook: &InstalledHook, + cwd: &Path, + environment: &mut ExecutionEnvironment, + ) -> Result<()> { + environment.env(EnvVars::MISE_CEILING_PATHS, mise_ceiling(cwd)?); + + if hook.additional_dependencies.is_empty() { + return Ok(()); + } + + let info = hook.install_info().context("mise must be installed")?; + let mise_environment = MiseEnvironment::new(&info.env_path); + let tool_cwd = tool_cwd(hook); + // Backends can contribute dynamic environment variables and PATH entries, so activation + // must be delegated to the selected mise CLI. Hook argv never crosses its UTF-8 boundary. + let mut command = mise_environment.command(&info.toolchain, tool_cwd)?; + if let Some(path) = environment.language_path() { + command.env(EnvVars::PATH, path); + } + command + .arg("env") + .arg("--json") + .arg("--") + .args(tools_with_versions(&hook.additional_dependencies)); + let output = command + .output() + .await + .context("Failed to activate mise tools")?; + + let mut activated: BTreeMap = + serde_json::from_slice(&output.stdout).context("Failed to parse mise environment")?; + let activated_path = activated + .iter() + .find_map(|(key, value)| { + if is_path_env(key) { + Some(value.clone()) + } else { + None + } + }) + .context("mise environment did not include PATH")?; + // TODO(#2022): Preserve non-UTF-8 PATH entries omitted by `mise env --json`. + activated.retain(|key, _| !is_path_env(key) && !is_mise_var(OsStr::new(key))); + environment.envs(&activated).set_path(activated_path); + + Ok(()) + } +} + +fn tool_cwd(hook: &Hook) -> &Path { + if let Some(repo_path) = hook.repo_path() { + repo_path + } else { + // TODO(#1603): Install local hook dependencies from an isolated synthetic repository. + hook.work_dir() + } +} + +/// Prepends a managed mise CLI to the inherited PATH. +fn managed_mise_path(mise: &Path) -> Result { + let bin_dir = mise + .parent() + .context("mise executable must have a parent directory")? + .to_path_buf(); + let base_path = EnvVars.var_os(EnvVars::PATH); + std::env::join_paths( + std::iter::once(bin_dir).chain( + base_path + .as_ref() + .into_iter() + .flat_map(std::env::split_paths), + ), + ) + .context("Failed to join mise PATH") +} + +fn tools_with_versions(tools: &[String]) -> impl Iterator + '_ { + tools.iter().map(|tool| tool_with_version(tool)) +} + +fn tool_with_version(tool: &str) -> String { + let (backend, version) = split_tool_version(tool); + let version = version.unwrap_or("latest"); + format!("{backend}@{version}") +} + +fn split_tool_version(tool: &str) -> (&str, Option<&str>) { + let Some((left, right)) = tool.split_once('@') else { + return (tool, None); + }; + let (backend, version) = if left.is_empty() { + let Some((name, version)) = right.split_once('@') else { + return (tool, None); + }; + (&tool[..=name.len()], version) + } else if left.ends_with(':') { + let Some((name, version)) = right.split_once('@') else { + return (tool, None); + }; + (&tool[..=(left.len() + name.len())], version) + } else { + (left, right) + }; + let version = if version.is_empty() { + None + } else { + Some(version) + }; + (backend, version) +} + +#[cfg(test)] +mod tests { + use super::tool_with_version; + + #[test] + fn gives_unversioned_tools_an_explicit_latest_version() { + let cases = [ + ("node", "node@latest"), + ("node@", "node@latest"), + ("node@22", "node@22"), + ( + "github:ajeetdsouza/zoxide", + "github:ajeetdsouza/zoxide@latest", + ), + ( + "ubi:BurntSushi/ripgrep[exe=rg]", + "ubi:BurntSushi/ripgrep[exe=rg]@latest", + ), + ("npm:@antfu/ni", "npm:@antfu/ni@latest"), + ("npm:@antfu/ni@1", "npm:@antfu/ni@1"), + ("@biomejs/biome", "@biomejs/biome@latest"), + ("@biomejs/biome@2", "@biomejs/biome@2"), + ("node@path:../node", "node@path:../node"), + ]; + + for (tool, expected) in cases { + assert_eq!(tool_with_version(tool), expected); + } + } +} diff --git a/crates/prek/src/languages/mise/mod.rs b/crates/prek/src/languages/mise/mod.rs new file mode 100644 index 000000000..7d14f6930 --- /dev/null +++ b/crates/prek/src/languages/mise/mod.rs @@ -0,0 +1,28 @@ +use std::ffi::{OsStr, OsString}; +use std::path::Path; + +use anyhow::{Context, Result}; + +mod installer; +#[allow(clippy::module_inception)] +mod mise; + +pub(crate) use mise::Mise; + +fn mise_ceiling(cwd: &Path) -> Result { + std::env::join_paths([cwd]) + .context("Failed to isolate mise from working directory configuration") +} + +fn is_mise_var(key: impl AsRef) -> bool { + let key = key.as_ref().to_string_lossy(); + #[cfg(windows)] + let key = key.to_ascii_uppercase(); + key.starts_with("MISE_") || key.starts_with("__MISE_") +} + +fn inherited_mise_vars() -> impl Iterator { + std::env::vars_os() + .map(|(key, _)| key) + .filter(|key| is_mise_var(key)) +} diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index 041f4c4aa..9b58bc6f9 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -35,6 +35,7 @@ mod golang; mod haskell; mod julia; mod lua; +mod mise; mod node; mod perl; mod php; @@ -52,6 +53,7 @@ pub(crate) mod version; // stronger contract than callers need and rejects the borrowed async closures used by backends. #[async_trait::async_trait(?Send)] trait LanguageBackend: Sync { + /// Provisions the environment required by `hook` and returns the prepared hook. async fn install( &self, store: &Store, @@ -59,8 +61,12 @@ trait LanguageBackend: Sync { reporter: &HookInstallReporter, ) -> Result; + /// Checks whether the installed environment described by `info` can be reused. async fn check_health(&self, info: &InstallInfo) -> Result<()>; + /// Builds the language-specific base environment for hook commands. + /// + /// The default leaves the caller's environment unchanged. fn execution_environment( &self, _store: &Store, @@ -69,6 +75,9 @@ trait LanguageBackend: Sync { Ok(ExecutionEnvironment::default()) } + /// Resolves the configured entry after the execution environment is prepared. + /// + /// This is used for normal hook runs; `prek exec` executes its supplied command directly. fn prepare_hook_entry( &self, store: &Store, @@ -80,6 +89,24 @@ trait LanguageBackend: Sync { .resolve(environment.path(hook), hook.work_dir(), store)?) } + /// Applies asynchronous or working-directory-dependent changes to `environment`. + /// + /// This is called after [`Self::execution_environment`] and before command resolution. `cwd` + /// is the hook work directory for a normal run and the caller's working directory for + /// `prek exec`. + async fn prepare_execution_environment( + &self, + _hook: &InstalledHook, + _cwd: &Path, + _environment: &mut ExecutionEnvironment, + ) -> Result<()> { + Ok(()) + } + + /// Runs the hook for `filenames`, reports progress, and returns its exit code and output. + /// + /// The default prepares the environment, resolves the entry, and executes filename batches + /// from the hook work directory. async fn run( &self, store: &Store, @@ -89,7 +116,9 @@ trait LanguageBackend: Sync { ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); - let environment = self.execution_environment(store, hook)?; + let mut environment = self.execution_environment(store, hook)?; + self.prepare_execution_environment(hook, hook.work_dir(), &mut environment) + .await?; let entry = self.prepare_hook_entry(store, hook, &environment)?; let run = async |batch: &[&Path]| { let output = environment @@ -199,8 +228,19 @@ impl ExecutionEnvironment { pub(crate) fn path<'a>(&'a self, hook: &'a InstalledHook) -> Option<&'a OsStr> { hook.env .iter() - .find_map(|(key, value)| is_path_env(key).then_some(OsStr::new(value))) - .or(self.path.as_deref()) + .find_map(|(key, value)| { + if is_path_env(key) { + Some(OsStr::new(value)) + } else { + None + } + }) + .or(self.language_path()) + } + + /// PATH supplied by the language backend before hook-specific overrides. + fn language_path(&self) -> Option<&OsStr> { + self.path.as_deref() } } @@ -236,6 +276,7 @@ pub(crate) enum ShellSupport { // golang: install requested version, support env, support additional deps // haskell: only system version, support env, support additional deps // lua: only system version, support env, support additional deps +// mise: install requested version, support env, support additional deps // node: install requested version, support env, support additional deps (delegated to nodeenv) // perl: only system version, support env, support additional deps // php: only system version, support env, support additional deps @@ -264,6 +305,7 @@ impl Language { Self::Haskell => &haskell::Haskell, Self::Julia => &julia::Julia, Self::Lua => &lua::Lua, + Self::Mise => &mise::Mise, Self::Node => &node::Node, Self::Perl => &perl::Perl, Self::Php => &php::Php, @@ -295,6 +337,7 @@ impl Language { | Self::Haskell | Self::Julia | Self::Lua + | Self::Mise | Self::Node | Self::Perl | Self::Php @@ -318,6 +361,7 @@ impl Language { | Self::Golang | Self::Haskell | Self::Lua + | Self::Mise | Self::Node | Self::Perl | Self::Php @@ -349,6 +393,7 @@ impl Language { Self::Deno => &[ToolBucket::Deno], Self::Dotnet => &[ToolBucket::Dotnet], Self::Golang => &[ToolBucket::Go], + Self::Mise => &[ToolBucket::Mise], Self::Node => &[ToolBucket::Node], Self::Python | Self::Pygrep => &[ToolBucket::Uv, ToolBucket::Python], Self::Ruby => &[ToolBucket::Ruby], @@ -389,6 +434,7 @@ impl Language { | Self::Haskell | Self::Julia | Self::Lua + | Self::Mise | Self::Perl | Self::Php | Self::R @@ -408,6 +454,7 @@ impl Language { | Self::Deno | Self::Dotnet | Self::Golang + | Self::Mise | Self::Node | Self::Python | Self::Ruby @@ -447,6 +494,7 @@ impl Language { | Self::Haskell | Self::Julia | Self::Lua + | Self::Mise | Self::Node | Self::Perl | Self::Php @@ -481,6 +529,7 @@ impl Language { | Self::Golang | Self::Haskell | Self::Lua + | Self::Mise | Self::Node | Self::Perl | Self::Php @@ -560,7 +609,10 @@ impl Language { ) -> Result { self.ensure_exec_supported(hook)?; - let environment = self.backend().execution_environment(store, hook)?; + let mut environment = self.backend().execution_environment(store, hook)?; + self.backend() + .prepare_execution_environment(hook, cwd, &mut environment) + .await?; environment .command(hook, cwd, command)? .status() @@ -586,6 +638,7 @@ pub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> { | Language::Haskell | Language::Julia | Language::Lua + | Language::Mise | Language::Node | Language::Perl | Language::Php diff --git a/crates/prek/src/languages/version.rs b/crates/prek/src/languages/version.rs index a1231aa61..ca8c44704 100644 --- a/crates/prek/src/languages/version.rs +++ b/crates/prek/src/languages/version.rs @@ -63,6 +63,7 @@ impl_language_version_request!(RubyRequest, Ruby); impl_language_version_request!(NodeRequest, Node); impl_language_version_request!(PythonRequest, Python); impl_language_version_request!(RustRequest, Rust); +impl_language_version_request!(SemverRequest, Semver); impl LanguageRequest { pub(crate) fn is_any(&self) -> bool { @@ -134,6 +135,7 @@ impl VersionRequest { | Language::Haskell | Language::Julia | Language::Lua + | Language::Mise | Language::Perl | Language::Php | Language::Pygrep @@ -199,9 +201,13 @@ impl SemverRequest { } fn satisfied_by(&self, install_info: &InstallInfo) -> bool { + self.matches(&install_info.language_version) + } + + pub(crate) fn matches(&self, version: &semver::Version) -> bool { match self { Self::Any => true, - Self::Range(request) => request.matches(&install_info.language_version), + Self::Range(request) => request.matches(version), } } } @@ -238,4 +244,14 @@ mod tests { &VersionRequest::Semver(SemverRequest::Any) ); } + + #[test] + fn semver_exact_versions_require_equals() { + let exact: SemverRequest = "=2026.7.18".parse().unwrap(); + let compatible: SemverRequest = "2026.7.18".parse().unwrap(); + let newer = "2026.8.2".parse().unwrap(); + + assert!(!exact.matches(&newer)); + assert!(compatible.matches(&newer)); + } } diff --git a/crates/prek/src/store.rs b/crates/prek/src/store.rs index dfb11c56d..a6c0f3324 100644 --- a/crates/prek/src/store.rs +++ b/crates/prek/src/store.rs @@ -422,6 +422,7 @@ pub(crate) enum ToolBucket { Bun, Dotnet, Deno, + Mise, } #[derive(Copy, Clone, Eq, Hash, PartialEq, strum::AsRefStr, strum::Display)] diff --git a/crates/prek/tests/languages/main.rs b/crates/prek/tests/languages/main.rs index d26365b14..659384478 100644 --- a/crates/prek/tests/languages/main.rs +++ b/crates/prek/tests/languages/main.rs @@ -16,6 +16,7 @@ mod golang; mod haskell; mod julia; mod lua; +mod mise; mod node; mod perl; mod php; diff --git a/crates/prek/tests/languages/mise.rs b/crates/prek/tests/languages/mise.rs new file mode 100644 index 000000000..216e94baf --- /dev/null +++ b/crates/prek/tests/languages/mise.rs @@ -0,0 +1,181 @@ +use std::env::consts::EXE_EXTENSION; + +use anyhow::Result; +use assert_cmd::assert::OutputAssertExt; +use assert_fs::fixture::PathChild; +use prek_consts::env_vars::{EnvVars, EnvVarsRead}; + +use crate::common::{TestContext, cmd_snapshot, git_cmd, make_executable}; + +#[test] +fn reuses_managed_mise() { + if !EnvVars.is_set(EnvVars::CI) { + return; + } + + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: mise-managed + name: mise managed + language: mise + language_version: "=2026.7.18" + entry: mise --version + additional_dependencies: ["github:ajeetdsouza/zoxide@0.10.0"] + always_run: true + verbose: true + pass_filenames: false + "#}); + context.git_add("."); + + let mut filters = context.filters(); + filters.push(( + r"2026\.7\.18 [^\r\n]+ \(\d{4}-\d{2}-\d{2}\)", + "2026.7.18 [PLATFORM] ([DATE])", + )); + + cmd_snapshot!(filters.clone(), context.run() + .env(EnvVars::PREK_INTERNAL__MISE_BINARY_NAME, "mise-never-exists"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + mise managed.............................................................Passed + - hook id: mise-managed + - duration: [TIME] + + 2026.7.18 [PLATFORM] ([DATE]) + + ----- stderr ----- + "#); + + // A different environment requirement forces another installer call. With downloads disabled + // and no system binary, this run can only reuse the managed mise installed above. + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: mise-managed + name: mise managed + language: mise + language_version: system + entry: mise --version + always_run: true + verbose: true + pass_filenames: false + "}); + context.git_add("."); + + cmd_snapshot!(filters, context.run() + .env(EnvVars::PREK_INTERNAL__MISE_BINARY_NAME, "mise-never-exists"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + mise managed.............................................................Passed + - hook id: mise-managed + - duration: [TIME] + + 2026.7.18 [PLATFORM] ([DATE]) + + ----- stderr ----- + "#); +} + +#[test] +fn system_mise_installs_and_activates_dependencies() -> Result<()> { + if !EnvVars.is_set(EnvVars::CI) { + return Ok(()); + } + + let context = TestContext::new(); + context.init_project(); + + let hook_repo = context.home_dir().child("mise-system-hook-repo"); + fs_err::create_dir_all(&hook_repo)?; + fs_err::write( + hook_repo.join(".pre-commit-hooks.yaml"), + indoc::indoc! {r#" + - id: mise-system + name: mise system + language: mise + language_version: system + entry: zoxide --version + additional_dependencies: ["github:ajeetdsouza/zoxide@0.10.0"] + always_run: true + verbose: true + pass_filenames: false + "#}, + )?; + // Provisioning must not read configuration from the hook repository. + fs_err::write(hook_repo.join("mise.toml"), "not valid = [")?; + git_cmd(&hook_repo).arg("init").assert().success(); + git_cmd(&hook_repo).args(["add", "."]).assert().success(); + git_cmd(&hook_repo) + .args(["commit", "-m", "Add mise hook"]) + .assert() + .success(); + let rev_output = git_cmd(&hook_repo).args(["rev-parse", "HEAD"]).output()?; + let rev = String::from_utf8(rev_output.stdout)?; + + // Keep a conflicting executable beside the real system mise. Activating the private tool must + // not move this whole directory ahead of the PATH returned by `mise env`. + let system_bin = context.home_dir().child("system-bin"); + fs_err::create_dir_all(&system_bin)?; + let system_mise = system_bin.join("mise").with_extension(EXE_EXTENSION); + fs_err::copy(which::which("mise")?, &system_mise)?; + make_executable(&system_mise)?; + #[cfg(unix)] + { + let system_zoxide = system_bin.join("zoxide"); + fs_err::write(&system_zoxide, "#!/bin/sh\nexit 1\n")?; + make_executable(&system_zoxide)?; + } + #[cfg(windows)] + fs_err::write(system_bin.join("zoxide.cmd"), "@exit /b 1\r\n")?; + + context.write_pre_commit_config(&indoc::formatdoc! {r" + repos: + - repo: '{}' + rev: {} + hooks: + - id: mise-system + ", hook_repo.display(), rev.trim()}); + // Early miserc discovery must not read configuration from the calling project. + fs_err::write(context.work_dir().join(".miserc.toml"), "not valid = [")?; + context.git_add("."); + + let ambient_data = context.work_dir().join("ambient-mise-data"); + let path = std::env::join_paths( + std::iter::once(system_bin.to_path_buf()).chain( + EnvVars + .var_os(EnvVars::PATH) + .as_ref() + .into_iter() + .flat_map(std::env::split_paths), + ), + )?; + cmd_snapshot!(context.filters(), context.run() + .env(EnvVars::PATH, path) + .env(EnvVars::MISE_DATA_DIR, &ambient_data) + .env("MISE_GLOBAL_CONFIG_FILE", "invalid ambient config") + .env("__MISE_DIFF", "invalid inherited state"), @r" + success: true + exit_code: 0 + ----- stdout ----- + mise system..............................................................Passed + - hook id: mise-system + - duration: [TIME] + + zoxide 0.10.0 + + ----- stderr ----- + "); + assert!( + !ambient_data.exists(), + "Inherited MISE_DATA_DIR must not receive hook tools" + ); + + Ok(()) +} diff --git a/docs/languages.md b/docs/languages.md index 362a3e7f2..534a01da5 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -33,6 +33,7 @@ Languages with managed toolchain downloads in prek today: - [Bun](#bun) - [Deno](#deno) - [Golang](#golang) +- [mise](#mise) - [Rust](#rust) - [Ruby](#ruby) @@ -315,6 +316,41 @@ Lua does not support `language_version` today. It uses the system `lua` / `luaro The hook entry should point at an executable installed by LuaRocks. +### mise + +!!! note "prek-only" + + Mise language support is a prek extension. pre-commit does not have native + `mise` support. + +List the tools a hook needs in `additional_dependencies`, using mise tool +specifications such as `aqua:golangci/golangci-lint@2`. Before running `entry`, +prek installs those tools in an isolated environment and adds their executables +to `PATH`. + +When downloads are allowed and no compatible mise installation is available, +prek downloads mise automatically. The mise executable can be shared across +hooks. Installed tools and other mise data stay in prek's hook cache and do not +modify the user's mise setup. + +```yaml +repos: + - repo: local + hooks: + - id: golangci-lint + name: golangci-lint + language: mise + additional_dependencies: ["aqua:golangci/golangci-lint@2"] + entry: golangci-lint run --fast-only ./... + pass_filenames: false +``` + +#### `language_version` + +`language_version` selects the mise CLI, not the installed tools. Prek requires +mise 2026.5.18 or newer. Supported values are `default`, `system`, exact releases +such as `=2026.7.18`, and semver ranges such as `>=2026.7, <2027`. + ### node prek expects a `package.json` and installs via `npm install .`, exposing executables from the package `bin`. `entry` should match a provided bin name. `additional_dependencies` are supported. diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 21f4218c7..cce1adbca 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -391,6 +391,7 @@ prek list [OPTIONS] [HOOK|PROJECT]...
  • haskell
  • julia
  • lua
  • +
  • mise
  • node
  • perl
  • php
  • diff --git a/prek.schema.json b/prek.schema.json index 3a8f8adcc..4d2513e03 100644 --- a/prek.schema.json +++ b/prek.schema.json @@ -76,6 +76,9 @@ "lua": { "type": "string" }, + "mise": { + "type": "string" + }, "node": { "type": "string" }, @@ -420,6 +423,7 @@ "haskell", "julia", "lua", + "mise", "node", "perl", "php", diff --git a/scripts/generate-ci-matrix.py b/scripts/generate-ci-matrix.py index 5576d7931..803a80730 100644 --- a/scripts/generate-ci-matrix.py +++ b/scripts/generate-ci-matrix.py @@ -30,6 +30,7 @@ class LanguageTest: "haskell": LanguageTest("test(haskell::)", 240), "julia": LanguageTest("test(julia::)", 110), "lua": LanguageTest("test(lua::)", 35), + "mise": LanguageTest("test(mise::)", 90), "node": LanguageTest("test(node::)", 35), "perl": LanguageTest("test(perl::)", 30), "php": LanguageTest("test(php::)", 30),