From 3746242cb5337f04c8097797efc7c6b6091efb27 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Thu, 7 May 2026 20:17:12 +0800 Subject: [PATCH 1/5] Add python_uv hook environments --- crates/prek/src/cli/cache_gc.rs | 18 +- crates/prek/src/config.rs | 110 +++++- crates/prek/src/hook.rs | 337 +++++++++++++----- crates/prek/src/hook/env_spec.rs | 271 ++++++++++++++ crates/prek/src/languages/golang/gomod.rs | 16 +- crates/prek/src/languages/mod.rs | 193 +++++----- crates/prek/src/languages/python/mod.rs | 10 +- crates/prek/src/languages/python/pep723.rs | 22 +- crates/prek/src/languages/python/pyproject.rs | 16 +- crates/prek/src/languages/python/python.rs | 174 ++++++++- crates/prek/src/languages/version.rs | 2 +- docs/authoring-hooks.md | 1 + docs/languages.md | 40 +++ docs/reference/configuration.md | 64 ++++ prek.schema.json | 62 ++++ 15 files changed, 1117 insertions(+), 219 deletions(-) create mode 100644 crates/prek/src/hook/env_spec.rs diff --git a/crates/prek/src/cli/cache_gc.rs b/crates/prek/src/cli/cache_gc.rs index 8c9085096..657fc195f 100644 --- a/crates/prek/src/cli/cache_gc.rs +++ b/crates/prek/src/cli/cache_gc.rs @@ -186,7 +186,7 @@ pub(crate) async fn cache_gc( }; kept_configs.insert(config_path); - used_env_keys.extend(hook_env_keys_from_config(store, &config)); + used_env_keys.extend(hook_env_keys_from_config(store, &config, config_path)); // Mark repos referenced by this config (if present in store). // We do this via config parsing (no clone), so GC won't keep repos for missing configs. @@ -350,8 +350,13 @@ fn print_removed_details(printer: Printer, verb: &str, removal: &Removal) -> Res Ok(()) } -fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec { +fn hook_env_keys_from_config( + store: &Store, + config: &config::Config, + config_path: &Path, +) -> Vec { let mut keys = Vec::new(); + let project_root = config_path.parent().unwrap_or_else(|| Path::new(".")); for repo_config in &config.repos { match repo_config { @@ -383,7 +388,12 @@ fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec keys.push(key), Ok(None) => {} Err(err) => { @@ -395,7 +405,7 @@ fn hook_env_keys_from_config(store: &Store, config: &config::Config) -> Vec { for hook in &repo_config.hooks { let hook_spec = HookSpec::from(hook.clone()); - match HookEnvKey::from_hook_spec(config, hook_spec, None) { + match HookEnvKey::from_hook_spec(config, hook_spec, None, project_root) { Ok(Some(key)) => keys.push(key), Ok(None) => {} Err(err) => { diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index 8ef8e7a86..7c69eb946 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use std::error::Error as _; use std::fmt::Display; use std::ops::RangeInclusive; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::Result; use fancy_regex::Regex; @@ -262,6 +262,7 @@ pub enum Language { Perl, Pygrep, Python, + PythonUv, R, Ruby, Rust, @@ -548,6 +549,56 @@ pub(crate) enum Shell { Cmd, } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub(crate) enum PythonUvLockMode { + #[default] + Locked, + Frozen, +} + +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +pub(crate) struct PythonUvOptions { + /// The uv project directory. Defaults to the project configuration directory. + pub project: Option, + /// The uv lockfile path. Defaults to `/uv.lock`. + pub lockfile: Option, + /// Dependency groups to install into the managed hook environment. + pub dependency_groups: Option>, + /// Project extras to install into the managed hook environment. + pub extras: Option>, + /// Whether to install the current project into the managed hook environment. + pub install_project: Option, + /// Whether uv should check lockfile freshness or use it as-is. + pub lock_mode: Option, +} + +impl PythonUvOptions { + pub(crate) fn update(&mut self, other: &Self) { + macro_rules! update_if_some { + ($($field:ident),* $(,)?) => { + $( + if other.$field.is_some() { + self.$field.clone_from(&other.$field); + } + )* + }; + } + + update_if_some!( + project, + lockfile, + dependency_groups, + extras, + install_project, + lock_mode, + ); + } +} + /// Common hook options. #[derive(Debug, Clone, Default, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -606,6 +657,8 @@ pub(crate) struct HookOptions { /// The minimum version of prek required to run this hook. #[serde(deserialize_with = "deserialize_and_validate_minimum_version", default)] pub minimum_prek_version: Option, + /// Language-specific options for `language: python_uv`. + pub uv: Option, #[serde(skip_serializing, flatten)] pub _unused_keys: BTreeMap, @@ -645,6 +698,14 @@ impl HookOptions { minimum_prek_version, ); + if let Some(other_uv) = &other.uv { + if let Some(self_uv) = &mut self.uv { + self_uv.update(other_uv); + } else { + self.uv.clone_from(&other.uv); + } + } + // Merge environment variables. if let Some(other_env) = &other.env { if let Some(self_env) = &mut self.env { @@ -1889,6 +1950,53 @@ mod tests { insta::assert_debug_snapshot!(result); } + #[test] + fn python_uv_options() -> Result<()> { + let dir = tempfile::tempdir()?; + let config_path = dir.path().join("prek.toml"); + fs_err::write( + &config_path, + indoc::indoc! {r#" + [[repos]] + repo = "local" + + [[repos.hooks]] + id = "ty" + name = "ty" + entry = "ty check ." + language = "python_uv" + + [repos.hooks.uv] + project = "." + lockfile = "uv.lock" + dependency_groups = ["typecheck"] + extras = ["typed"] + install_project = false + lock_mode = "frozen" + "#}, + )?; + + let config = read_config(&config_path)?; + let Repo::Local(repo) = &config.repos[0] else { + panic!("expected local repo"); + }; + let hook = &repo.hooks[0]; + assert_eq!(hook.language, Language::PythonUv); + + let uv = hook.options.uv.as_ref().expect("uv options"); + assert_eq!(uv.project.as_deref(), Some(Path::new("."))); + assert_eq!(uv.lockfile.as_deref(), Some(Path::new("uv.lock"))); + assert_eq!( + uv.dependency_groups.as_deref(), + Some(["typecheck".to_string()].as_slice()) + ); + assert_eq!(uv.extras.as_deref(), Some(["typed".to_string()].as_slice())); + assert_eq!(uv.install_project, Some(false)); + assert_eq!(uv.lock_mode, Some(PythonUvLockMode::Frozen)); + + Ok(()) + } + #[test] fn test_read_yaml_config() -> Result<()> { let config = read_config(Path::new("tests/fixtures/uv-pre-commit-config.yaml"))?; diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index 464a8316b..58446c8ef 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -2,12 +2,12 @@ use std::borrow::Cow; use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::sync::{Arc, OnceLock}; +use std::sync::Arc; use anyhow::{Context, Result}; use prek_consts::PRE_COMMIT_HOOKS_YAML; use prek_identify::{TagSet, tags}; -use rustc_hash::{FxBuildHasher, FxHashMap, FxHashSet}; +use rustc_hash::{FxHashMap, FxHashSet}; use serde::{Deserialize, Serialize}; use tempfile::TempDir; use thiserror::Error; @@ -19,10 +19,14 @@ use crate::config::{ }; use crate::hook_entry::HookEntry; use crate::languages::version::LanguageRequest; -use crate::languages::{ShellSupport, extract_metadata}; +use crate::languages::{HookMetadata, ShellSupport, extract_metadata}; use crate::store::Store; use crate::workspace::Project; +mod env_spec; + +pub(crate) use env_spec::{HookEnvSpec, PythonUvEnv}; + #[derive(Error, Debug)] pub(crate) enum Error { #[error(transparent)] @@ -265,39 +269,10 @@ impl HookBuilder { let language = self.hook_spec.language; let HookOptions { language_version, - additional_dependencies, shell, .. } = &self.hook_spec.options; - let additional_dependencies = additional_dependencies - .as_ref() - .map_or(&[][..], |deps| deps.as_slice()); - - if !additional_dependencies.is_empty() { - if !language.supports_install_env() { - return Err(Error::Hook { - hook: self.hook_spec.id.clone(), - error: anyhow::anyhow!( - "Hook specified `additional_dependencies: {}` but the language `{}` does not install an environment", - additional_dependencies.join(", "), - language, - ), - }); - } - - if !language.supports_dependency() { - return Err(Error::Hook { - hook: self.hook_spec.id.clone(), - error: anyhow::anyhow!( - "Hook specified `additional_dependencies: {}` but the language `{}` does not support installing dependencies for now", - additional_dependencies.join(", "), - language, - ), - }); - } - } - if !language.supports_language_version() { if let Some(language_version) = language_version && language_version != "default" @@ -349,10 +324,21 @@ impl HookBuilder { pub(crate) async fn build(mut self) -> Result { self.hook_spec.apply_project_defaults(self.project.config()); + let remote_repo_dependency = self + .hook_spec + .language + .supports_install_env() + .then(|| match self.repo.as_ref() { + Repo::Remote { .. } => Some(self.repo.to_string()), + Repo::Local { .. } | Repo::Meta { .. } | Repo::Builtin { .. } => None, + }) + .flatten(); + self.check()?; let options = self.hook_spec.options; let language_version = options.language_version.unwrap_or_default(); + let uv = options.uv; let alias = options.alias.unwrap_or_default(); let args = options.args.unwrap_or_default(); let env = options.env.unwrap_or_default(); @@ -366,17 +352,19 @@ impl HookBuilder { let verbose = options.verbose.unwrap_or(false); let stages = options.stages.unwrap_or(Stages::ALL); let shell = options.shell; - let additional_dependencies = options + let mut additional_dependencies = options .additional_dependencies .unwrap_or_default() .into_iter() .collect::>(); - let language_request = LanguageRequest::parse(self.hook_spec.language, &language_version) - .map_err(|e| Error::Hook { - hook: self.hook_spec.id.clone(), - error: anyhow::anyhow!(e), - })?; + let mut language_request = + LanguageRequest::parse(self.hook_spec.language, &language_version).map_err(|e| { + Error::Hook { + hook: self.hook_spec.id.clone(), + error: anyhow::anyhow!(e), + } + })?; let entry = HookEntry::new(self.hook_spec.id.clone(), self.hook_spec.entry, shell); @@ -385,10 +373,44 @@ impl HookBuilder { .priority .unwrap_or(u32::try_from(self.idx).expect("idx too large")); - let mut hook = Hook { - dependencies: OnceLock::new(), + let mut metadata = HookMetadata { + id: &self.hook_spec.id, + language: self.hook_spec.language, + entry: &entry, + repo_path: self.repo.path(), + work_dir: self.project.path(), + additional_dependencies: &mut additional_dependencies, + language_request: &mut language_request, + }; + + if let Err(err) = extract_metadata(&mut metadata).await { + if err + .downcast_ref::() + .is_some_and(|e| e.kind() != std::io::ErrorKind::NotFound) + { + trace!( + "Failed to extract metadata from entry for hook `{}`: {err}", + self.hook_spec.id + ); + } + } + + let env_spec = HookEnvSpec::resolve( + self.hook_spec.language, + &additional_dependencies, + uv.as_ref(), + self.project.path(), + remote_repo_dependency.as_deref(), + ) + .map_err(|e| Error::Hook { + hook: self.hook_spec.id.clone(), + error: e, + })?; + + Ok(Hook { project: self.project, repo: self.repo, + env_spec, idx: self.idx, id: self.hook_spec.id, name: self.hook_spec.name, @@ -415,18 +437,7 @@ impl HookBuilder { description: options.description, log_file: options.log_file, minimum_prek_version: options.minimum_prek_version, - }; - - if let Err(err) = extract_metadata(&mut hook).await { - if err - .downcast_ref::() - .is_some_and(|e| e.kind() != std::io::ErrorKind::NotFound) - { - trace!("Failed to extract metadata from entry for hook `{hook}`: {err}"); - } - } - - Ok(hook) + }) } } @@ -435,8 +446,7 @@ impl HookBuilder { pub(crate) struct Hook { project: Arc, repo: Arc, - // Cached computed dependencies. - dependencies: OnceLock>, + env_spec: HookEnvSpec, /// The index of the hook defined in the configuration file. pub idx: usize, @@ -513,12 +523,11 @@ impl Hook { /// For remote hooks, the repo URL is included to avoid reusing an environment created /// from a different remote repository. pub(crate) fn env_key_dependencies(&self) -> &FxHashSet { - if !self.is_remote() { - return &self.additional_dependencies; - } - self.dependencies.get_or_init(|| { - env_key_dependencies(&self.additional_dependencies, Some(&self.repo.to_string())) - }) + self.env_spec.dependencies() + } + + pub(crate) fn python_uv_env(&self) -> Option<&PythonUvEnv> { + self.env_spec.python_uv() } /// Returns a lightweight view of the hook environment identity used for reusing installs. @@ -567,25 +576,6 @@ pub(crate) struct HookEnvKeyRef<'a> { pub(crate) language_request: &'a LanguageRequest, } -/// Builds the dependency set used to identify a hook environment. -/// -/// For remote hooks, `remote_repo_dependency` is included so environments from different -/// repositories are not reused accidentally. -fn env_key_dependencies( - additional_dependencies: &FxHashSet, - remote_repo_dependency: Option<&str>, -) -> FxHashSet { - let mut deps = FxHashSet::with_capacity_and_hasher( - additional_dependencies.len() + usize::from(remote_repo_dependency.is_some()), - FxBuildHasher, - ); - deps.extend(additional_dependencies.iter().cloned()); - if let Some(dep) = remote_repo_dependency { - deps.insert(dep.to_string()); - } - deps -} - /// Shared matching logic between a computed hook env key (owned or borrowed) and an installed /// environment described by [`InstallInfo`]. fn matches_install_info( @@ -607,6 +597,7 @@ impl HookEnvKey { config: &Config, mut hook_spec: HookSpec, remote_repo_dependency: Option<&str>, + project_root: &Path, ) -> Result> { let language = hook_spec.language; if !language.supports_install_env() { @@ -615,10 +606,13 @@ impl HookEnvKey { hook_spec.apply_project_defaults(config); hook_spec.options.language_version.get_or_insert_default(); - hook_spec + let additional_dependencies = hook_spec .options .additional_dependencies - .get_or_insert_default(); + .get_or_insert_default() + .iter() + .cloned() + .collect::>(); let request = hook_spec.options.language_version.as_deref().unwrap_or(""); let language_request = LanguageRequest::parse(language, request).with_context(|| { @@ -628,13 +622,14 @@ impl HookEnvKey { ) })?; - let additional_dependencies: FxHashSet = hook_spec - .options - .additional_dependencies - .as_ref() - .map_or_else(FxHashSet::default, |deps| deps.iter().cloned().collect()); - - let dependencies = env_key_dependencies(&additional_dependencies, remote_repo_dependency); + let env_spec = HookEnvSpec::resolve( + language, + &additional_dependencies, + hook_spec.options.uv.as_ref(), + project_root, + remote_repo_dependency, + )?; + let dependencies = env_spec.dependencies().clone(); Ok(Some(Self { language, @@ -838,7 +833,8 @@ mod tests { use rustc_hash::FxHashMap; use crate::config::{ - Config, HookOptions, Language, PassFilenames, RemoteHook, Shell, Stage, Stages, + Config, HookOptions, Language, PassFilenames, PythonUvLockMode, PythonUvOptions, + RemoteHook, Shell, Stage, Stages, }; use crate::hook::HookSpec; use crate::languages::version::LanguageRequest; @@ -945,8 +941,8 @@ mod tests { repo: Local { hooks: [], }, - dependencies: OnceLock( - , + env_spec: Dependencies( + {}, ), idx: 7, id: "test-hook", @@ -1140,6 +1136,145 @@ mod tests { Ok(()) } + #[tokio::test] + async fn hook_builder_rejects_uv_options_for_non_python_uv_language() -> Result<()> { + let temp = tempfile::tempdir()?; + let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); + fs_err::write(&config_path, "repos: []\n")?; + + let project = Arc::new(Project::from_config_file( + Cow::Borrowed(&config_path), + None, + )?); + let repo = Arc::new(Repo::Local { hooks: vec![] }); + + let hook_spec = HookSpec { + id: "test-hook".to_string(), + name: "test-hook".to_string(), + entry: "python -c 'print(1)'".to_string(), + language: Language::Python, + priority: None, + options: HookOptions { + uv: Some(PythonUvOptions::default()), + ..Default::default() + }, + }; + + let err = HookBuilder::new(project, repo, hook_spec, 0) + .build() + .await + .unwrap_err(); + + let super::Error::Hook { error, .. } = err else { + panic!("expected hook error"); + }; + assert_eq!( + error.to_string(), + "Hook specified `uv` options but the language `python` is not `python_uv`" + ); + + Ok(()) + } + + #[tokio::test] + async fn hook_builder_rejects_python_uv_additional_dependencies() -> Result<()> { + let temp = tempfile::tempdir()?; + let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); + fs_err::write(&config_path, "repos: []\n")?; + + let project = Arc::new(Project::from_config_file( + Cow::Borrowed(&config_path), + None, + )?); + let repo = Arc::new(Repo::Local { hooks: vec![] }); + + let hook_spec = HookSpec { + id: "test-hook".to_string(), + name: "test-hook".to_string(), + entry: "ty check .".to_string(), + language: Language::PythonUv, + priority: None, + options: HookOptions { + additional_dependencies: Some(vec!["ty".to_string()]), + ..Default::default() + }, + }; + + let err = HookBuilder::new(project, repo, hook_spec, 0) + .build() + .await + .unwrap_err(); + + let super::Error::Hook { error, .. } = err else { + panic!("expected hook error"); + }; + assert_eq!( + error.to_string(), + "`language: python_uv` does not install `additional_dependencies`; add Python packages to a uv dependency group and update `uv.lock` instead" + ); + + Ok(()) + } + + #[tokio::test] + async fn hook_builder_python_uv_env_key_tracks_lockfile_content() -> Result<()> { + let temp = tempfile::tempdir()?; + let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); + fs_err::write(&config_path, "repos: []\n")?; + fs_err::write( + temp.path().join("pyproject.toml"), + "[project]\nname = \"example\"\nversion = \"0.1.0\"\n", + )?; + fs_err::write(temp.path().join("uv.lock"), "version = 1\n")?; + + let project = Arc::new(Project::from_config_file( + Cow::Borrowed(&config_path), + None, + )?); + let repo = Arc::new(Repo::Local { hooks: vec![] }); + + let hook_spec = HookSpec { + id: "ty".to_string(), + name: "ty".to_string(), + entry: "ty check .".to_string(), + language: Language::PythonUv, + priority: None, + options: HookOptions { + uv: Some(PythonUvOptions { + dependency_groups: Some(vec![ + "typecheck".to_string(), + "lint".to_string(), + "typecheck".to_string(), + ]), + extras: Some(vec!["typed".to_string(), "cli".to_string()]), + install_project: Some(false), + lock_mode: Some(PythonUvLockMode::Frozen), + ..Default::default() + }), + ..Default::default() + }, + }; + + let hook = HookBuilder::new(project.clone(), repo.clone(), hook_spec.clone(), 0) + .build() + .await?; + let uv = hook.python_uv_env().expect("python_uv env"); + assert_eq!(uv.dependency_groups, ["lint", "typecheck"]); + assert_eq!(uv.extras, ["cli", "typed"]); + assert!(!uv.install_project); + assert_eq!(uv.lock_mode, PythonUvLockMode::Frozen); + let first_dependencies = hook.env_key_dependencies().clone(); + + fs_err::write(temp.path().join("uv.lock"), "version = 2\n")?; + + let hook = HookBuilder::new(project, repo, hook_spec, 0) + .build() + .await?; + assert_ne!(first_dependencies, *hook.env_key_dependencies()); + + Ok(()) + } + /// Set up a temporary directory with a minimal `.pre-commit-config.yaml` /// and a `remote-repo` subdirectory. fn setup_python_hook_test() -> Result<(tempfile::TempDir, Arc)> { @@ -1195,6 +1330,13 @@ mod tests { print("hello") "#}; + static PEP723_SCRIPT_WITH_DEPENDENCIES: &str = indoc::indoc! {r#" + # /// script + # dependencies = ["pyecho-cli"] + # /// + print("hello") + "#}; + #[tokio::test] async fn hook_builder_python_pep723_overrides_user_and_pyproject() -> Result<()> { let (temp, project) = setup_python_hook_test()?; @@ -1214,6 +1356,19 @@ mod tests { Ok(()) } + #[tokio::test] + async fn hook_builder_python_pep723_dependencies_are_env_key_dependencies() -> Result<()> { + let (temp, project) = setup_python_hook_test()?; + let repo_path = temp.path().join("remote-repo"); + fs_err::write(repo_path.join("hook.py"), PEP723_SCRIPT_WITH_DEPENDENCIES)?; + + let hook = build_python_hook(project, repo_path, None).await?; + + assert!(hook.additional_dependencies.contains("pyecho-cli")); + assert!(hook.env_key_dependencies().contains("pyecho-cli")); + Ok(()) + } + #[tokio::test] async fn hook_builder_python_user_language_version_overrides_pyproject() -> Result<()> { let (temp, project) = setup_python_hook_test()?; diff --git a/crates/prek/src/hook/env_spec.rs b/crates/prek/src/hook/env_spec.rs new file mode 100644 index 000000000..3a49de85d --- /dev/null +++ b/crates/prek/src/hook/env_spec.rs @@ -0,0 +1,271 @@ +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use rustc_hash::{FxBuildHasher, FxHashSet}; + +use crate::config::{Language, PythonUvLockMode, PythonUvOptions}; + +#[derive(Debug, Clone)] +pub(crate) struct PythonUvEnv { + pub(crate) project: PathBuf, + pub(crate) lockfile: PathBuf, + pub(crate) dependency_groups: Vec, + pub(crate) extras: Vec, + pub(crate) install_project: bool, + pub(crate) lock_mode: PythonUvLockMode, + dependencies: FxHashSet, +} + +impl PythonUvEnv { + const SCHEMA_VERSION: &'static str = "python_uv_v1"; + + pub(crate) fn resolve(options: &PythonUvOptions, project_root: &Path) -> Result { + let project = options.project.as_deref().map_or_else( + || project_root.to_path_buf(), + |path| resolve_path(project_root, path), + ); + let project = fs_err::canonicalize(&project).with_context(|| { + format!( + "Failed to resolve `uv.project` path `{}`", + project.display() + ) + })?; + + let pyproject = project.join("pyproject.toml"); + if !pyproject.is_file() { + anyhow::bail!( + "`language: python_uv` requires a uv project at `{}` with a `pyproject.toml`", + project.display() + ); + } + + let default_lockfile = project.join("uv.lock"); + let lockfile = options.lockfile.as_deref().map_or_else( + || default_lockfile.clone(), + |path| resolve_path(&project, path), + ); + let lockfile = fs_err::canonicalize(&lockfile).with_context(|| { + format!( + "Failed to resolve `uv.lockfile` path `{}`", + lockfile.display() + ) + })?; + + let default_lockfile = fs_err::canonicalize(&default_lockfile).with_context(|| { + format!( + "`language: python_uv` requires a lockfile at `{}`", + default_lockfile.display() + ) + })?; + if lockfile != default_lockfile { + anyhow::bail!( + "`language: python_uv` only supports the project's default uv lockfile (`{}`) for now", + default_lockfile.display() + ); + } + + let mut dependency_groups = options.dependency_groups.clone().unwrap_or_default(); + canonicalize_string_list(&mut dependency_groups); + + let mut extras = options.extras.clone().unwrap_or_default(); + canonicalize_string_list(&mut extras); + + let install_project = options.install_project.unwrap_or(true); + let lock_mode = options.lock_mode.unwrap_or_default(); + + let dependencies = python_uv_env_key_dependencies( + &project, + &lockfile, + &pyproject, + &dependency_groups, + &extras, + install_project, + lock_mode, + )?; + + Ok(Self { + project, + lockfile, + dependency_groups, + extras, + install_project, + lock_mode, + dependencies, + }) + } + + fn dependencies(&self) -> &FxHashSet { + &self.dependencies + } +} + +#[derive(Debug, Clone)] +pub(crate) enum HookEnvSpec { + Dependencies(FxHashSet), + PythonUv(PythonUvEnv), +} + +impl HookEnvSpec { + pub(super) fn resolve( + language: Language, + additional_dependencies: &FxHashSet, + uv: Option<&PythonUvOptions>, + project_root: &Path, + remote_repo_dependency: Option<&str>, + ) -> Result { + if language == Language::PythonUv { + if !additional_dependencies.is_empty() { + anyhow::bail!( + "`language: python_uv` does not install `additional_dependencies`; add Python packages to a uv dependency group and update `uv.lock` instead", + ); + } + + let default_uv = PythonUvOptions::default(); + let uv = uv.unwrap_or(&default_uv); + Ok(Self::PythonUv(PythonUvEnv::resolve(uv, project_root)?)) + } else { + if uv.is_some() { + anyhow::bail!( + "Hook specified `uv` options but the language `{language}` is not `python_uv`", + ); + } + + validate_additional_dependencies(language, additional_dependencies)?; + + Ok(Self::Dependencies(env_key_dependencies( + additional_dependencies, + remote_repo_dependency, + ))) + } + } + + pub(crate) fn dependencies(&self) -> &FxHashSet { + match self { + Self::Dependencies(dependencies) => dependencies, + Self::PythonUv(env) => env.dependencies(), + } + } + + pub(crate) fn python_uv(&self) -> Option<&PythonUvEnv> { + match self { + Self::Dependencies(_) => None, + Self::PythonUv(env) => Some(env), + } + } +} + +fn validate_additional_dependencies( + language: Language, + additional_dependencies: &FxHashSet, +) -> Result<()> { + if additional_dependencies.is_empty() { + return Ok(()); + } + + if !language.supports_install_env() { + anyhow::bail!( + "Hook specified `additional_dependencies: {}` but the language `{}` does not install an environment", + format_dependencies(additional_dependencies), + language, + ); + } + + if !language.supports_dependency() { + anyhow::bail!( + "Hook specified `additional_dependencies: {}` but the language `{}` does not support installing dependencies for now", + format_dependencies(additional_dependencies), + language, + ); + } + + Ok(()) +} + +fn format_dependencies(dependencies: &FxHashSet) -> String { + let mut dependencies = dependencies.iter().map(String::as_str).collect::>(); + dependencies.sort_unstable(); + dependencies.join(", ") +} + +/// Builds the dependency set used to identify a hook environment. +/// +/// For remote hooks, `remote_repo_dependency` is included so environments from different +/// repositories are not reused accidentally. +fn env_key_dependencies( + additional_dependencies: &FxHashSet, + remote_repo_dependency: Option<&str>, +) -> FxHashSet { + let mut deps = FxHashSet::with_capacity_and_hasher( + additional_dependencies.len() + usize::from(remote_repo_dependency.is_some()), + FxBuildHasher, + ); + deps.extend(additional_dependencies.iter().cloned()); + if let Some(dep) = remote_repo_dependency { + deps.insert(dep.to_string()); + } + deps +} + +fn resolve_path(base: &Path, path: &Path) -> PathBuf { + if path.is_absolute() { + path.to_path_buf() + } else { + base.join(path) + } +} + +fn canonicalize_string_list(values: &mut Vec) { + values.sort(); + values.dedup(); +} + +fn hash_file(path: &Path) -> Result { + let content = fs_err::read(path)?; + Ok(format!("{:016x}", seahash::hash(&content))) +} + +fn push_optional_file_hash( + dependencies: &mut FxHashSet, + key: &str, + path: &Path, +) -> Result<()> { + match fs_err::read(path) { + Ok(content) => { + dependencies.insert(format!("{key}={:016x}", seahash::hash(&content))); + } + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + Ok(()) +} + +fn python_uv_env_key_dependencies( + project: &Path, + lockfile: &Path, + pyproject: &Path, + dependency_groups: &[String], + extras: &[String], + install_project: bool, + lock_mode: PythonUvLockMode, +) -> Result> { + let mut dependencies = FxHashSet::default(); + dependencies.insert(format!("schema={}", PythonUvEnv::SCHEMA_VERSION)); + dependencies.insert(format!("project={}", project.display())); + dependencies.insert(format!("lockfile={}", lockfile.display())); + dependencies.insert(format!("lockfile_hash={}", hash_file(lockfile)?)); + dependencies.insert(format!("pyproject_hash={}", hash_file(pyproject)?)); + dependencies.insert(format!("dependency_groups={}", dependency_groups.join(","))); + dependencies.insert(format!("extras={}", extras.join(","))); + dependencies.insert(format!("install_project={install_project}")); + dependencies.insert(format!("lock_mode={lock_mode:?}")); + + push_optional_file_hash(&mut dependencies, "uv_toml_hash", &project.join("uv.toml"))?; + push_optional_file_hash( + &mut dependencies, + "python_version_file_hash", + &project.join(".python-version"), + )?; + + Ok(dependencies) +} diff --git a/crates/prek/src/languages/golang/gomod.rs b/crates/prek/src/languages/golang/gomod.rs index bd82fb8fc..af6d095c4 100644 --- a/crates/prek/src/languages/golang/gomod.rs +++ b/crates/prek/src/languages/golang/gomod.rs @@ -5,7 +5,7 @@ use anyhow::Result; use tracing::trace; use crate::config::Language; -use crate::hook::Hook; +use crate::languages::HookMetadata; use crate::languages::version::LanguageRequest; fn parse_go_mod_directives(contents: &str) -> (Option, Option) { @@ -104,19 +104,19 @@ async fn extract_go_mod_language_request(repo_path: &Path) -> Result Result<()> { +pub(crate) async fn extract_go_mod_metadata(metadata: &mut HookMetadata<'_>) -> Result<()> { // Respect an explicitly configured `language_version`. - if !hook.language_request.is_any() { - trace!(hook = %hook, "Skipping go.mod metadata extraction because language_version is already configured"); + if !metadata.language_request.is_any() { + trace!(hook = %metadata, "Skipping go.mod metadata extraction because language_version is already configured"); return Ok(()); } - let Some(repo_path) = hook.repo_path() else { + let Some(repo_path) = metadata.repo_path else { return Ok(()); }; let Some(req_str) = extract_go_mod_language_request(repo_path).await? else { - trace!(hook = %hook, "No go or toolchain directive found in go.mod"); + trace!(hook = %metadata, "No go or toolchain directive found in go.mod"); return Ok(()); }; @@ -128,8 +128,8 @@ pub(crate) async fn extract_go_mod_metadata(hook: &mut Hook) -> Result<()> { } }; - trace!(hook = %hook, version = %req_str, "Using go.mod-derived language_version"); - hook.language_request = req; + trace!(hook = %metadata, version = %req_str, "Using go.mod-derived language_version"); + *metadata.language_request = req; Ok(()) } diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index 4270dd11d..b08468384 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -1,4 +1,5 @@ use std::ffi::OsStr; +use std::fmt::{Display, Formatter}; use std::path::{Path, PathBuf}; use std::str::FromStr; use std::sync::Arc; @@ -6,13 +7,16 @@ use std::sync::Arc; use anyhow::Result; use prek_consts::env_vars::EnvVars; use prek_identify::parse_shebang; +use rustc_hash::FxHashSet; use tracing::{instrument, trace}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::config::Language; use crate::fs::CWD; use crate::hook::{Hook, InstallInfo, InstalledHook, Repo}; +use crate::hook_entry::HookEntry; use crate::hooks; +use crate::languages::version::LanguageRequest; use crate::store::{CacheBucket, Store, ToolBucket}; mod bun; @@ -36,27 +40,6 @@ mod swift; mod system; pub(crate) mod version; -static BUN: bun::Bun = bun::Bun; -static DART: dart::Dart = dart::Dart; -static DENO: deno::Deno = deno::Deno; -static DOCKER: docker::Docker = docker::Docker; -static DOCKER_IMAGE: docker_image::DockerImage = docker_image::DockerImage; -static DOTNET: dotnet::Dotnet = dotnet::Dotnet; -static FAIL: fail::Fail = fail::Fail; -static GOLANG: golang::Golang = golang::Golang; -static HASKELL: haskell::Haskell = haskell::Haskell; -static JULIA: julia::Julia = julia::Julia; -static LUA: lua::Lua = lua::Lua; -static NODE: node::Node = node::Node; -static PYGREP: pygrep::Pygrep = pygrep::Pygrep; -static PYTHON: python::Python = python::Python; -static RUBY: ruby::Ruby = ruby::Ruby; -static RUST: rust::Rust = rust::Rust; -static SCRIPT: script::Script = script::Script; -static SWIFT: swift::Swift = swift::Swift; -static SYSTEM: system::System = system::System; -static UNIMPLEMENTED: Unimplemented = Unimplemented; - trait LanguageImpl { async fn install( &self, @@ -76,6 +59,22 @@ trait LanguageImpl { ) -> Result<(i32, Vec)>; } +pub(crate) struct HookMetadata<'a> { + pub(crate) id: &'a str, + pub(crate) language: Language, + pub(crate) entry: &'a HookEntry, + pub(crate) repo_path: Option<&'a Path>, + pub(crate) work_dir: &'a Path, + pub(crate) additional_dependencies: &'a mut FxHashSet, + pub(crate) language_request: &'a mut LanguageRequest, +} + +impl Display for HookMetadata<'_> { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.id) + } +} + #[derive(thiserror::Error, Debug)] #[error("Language `{0}` is not implemented yet")] struct UnimplementedError(String); @@ -129,6 +128,7 @@ impl LanguageImpl for Unimplemented { // perl: only system version, support env, support additional deps // pygrep: only system version, no env, no additional deps // python: install requested version, support env, support additional deps (delegated to virtualenv) +// python_uv: install requested version, support env, no additional deps // r: only system version, support env, support additional deps // ruby: install requested version, support env, support additional deps (delegated to rbenv) // rust: install requested version, support env, support additional deps (delegated to rustup and cargo) @@ -153,6 +153,7 @@ impl Language { | Self::Node | Self::Pygrep | Self::Python + | Self::PythonUv | Self::Ruby | Self::Rust | Self::Script @@ -179,6 +180,7 @@ impl Language { | Self::Perl | Self::Pygrep | Self::Python + | Self::PythonUv | Self::R | Self::Ruby | Self::Rust @@ -197,6 +199,7 @@ impl Language { | Self::Lua | Self::Node | Self::Python + | Self::PythonUv | Self::Ruby | Self::Script | Self::Swift @@ -225,7 +228,7 @@ impl Language { Self::Dotnet => &[ToolBucket::Dotnet], Self::Golang => &[ToolBucket::Go], Self::Node => &[ToolBucket::Node], - Self::Python | Self::Pygrep => &[ToolBucket::Uv, ToolBucket::Python], + Self::Python | Self::PythonUv | Self::Pygrep => &[ToolBucket::Uv, ToolBucket::Python], Self::Ruby => &[ToolBucket::Ruby], Self::Rust => &[ToolBucket::Rustup], Self::Conda @@ -249,7 +252,7 @@ impl Language { match self { Self::Deno => &[CacheBucket::Deno], Self::Golang => &[CacheBucket::Go], - Self::Python | Self::Pygrep => &[CacheBucket::Uv, CacheBucket::Python], + Self::Python | Self::PythonUv | Self::Pygrep => &[CacheBucket::Uv, CacheBucket::Python], Self::Rust => &[CacheBucket::Cargo], Self::Bun | Self::Conda @@ -283,6 +286,7 @@ impl Language { | Self::Golang | Self::Node | Self::Python + | Self::PythonUv | Self::Ruby | Self::Rust => true, Self::Conda @@ -329,6 +333,7 @@ impl Language { | Self::DockerImage | Self::Fail | Self::Pygrep + | Self::PythonUv | Self::Script | Self::Swift | Self::System => false, @@ -342,54 +347,60 @@ impl Language { reporter: &HookInstallReporter, ) -> Result { match self { - Self::Dart => DART.install(hook, store, reporter).await, - Self::Bun => BUN.install(hook, store, reporter).await, - Self::Deno => DENO.install(hook, store, reporter).await, - Self::Docker => DOCKER.install(hook, store, reporter).await, - Self::DockerImage => DOCKER_IMAGE.install(hook, store, reporter).await, - Self::Dotnet => DOTNET.install(hook, store, reporter).await, - Self::Fail => FAIL.install(hook, store, reporter).await, - Self::Golang => GOLANG.install(hook, store, reporter).await, - Self::Haskell => HASKELL.install(hook, store, reporter).await, - Self::Julia => JULIA.install(hook, store, reporter).await, - Self::Lua => LUA.install(hook, store, reporter).await, - Self::Node => NODE.install(hook, store, reporter).await, - Self::Pygrep => PYGREP.install(hook, store, reporter).await, - Self::Python => PYTHON.install(hook, store, reporter).await, - Self::Ruby => RUBY.install(hook, store, reporter).await, - Self::Rust => RUST.install(hook, store, reporter).await, - Self::Script => SCRIPT.install(hook, store, reporter).await, - Self::Swift => SWIFT.install(hook, store, reporter).await, - Self::System => SYSTEM.install(hook, store, reporter).await, + Self::Dart => dart::Dart.install(hook, store, reporter).await, + Self::Bun => bun::Bun.install(hook, store, reporter).await, + Self::Deno => deno::Deno.install(hook, store, reporter).await, + Self::Docker => docker::Docker.install(hook, store, reporter).await, + Self::DockerImage => { + docker_image::DockerImage + .install(hook, store, reporter) + .await + } + Self::Dotnet => dotnet::Dotnet.install(hook, store, reporter).await, + Self::Fail => fail::Fail.install(hook, store, reporter).await, + Self::Golang => golang::Golang.install(hook, store, reporter).await, + Self::Haskell => haskell::Haskell.install(hook, store, reporter).await, + Self::Julia => julia::Julia.install(hook, store, reporter).await, + Self::Lua => lua::Lua.install(hook, store, reporter).await, + Self::Node => node::Node.install(hook, store, reporter).await, + Self::Pygrep => pygrep::Pygrep.install(hook, store, reporter).await, + Self::Python => python::Python.install(hook, store, reporter).await, + Self::PythonUv => python::PythonUv.install(hook, store, reporter).await, + Self::Ruby => ruby::Ruby.install(hook, store, reporter).await, + Self::Rust => rust::Rust.install(hook, store, reporter).await, + Self::Script => script::Script.install(hook, store, reporter).await, + Self::Swift => swift::Swift.install(hook, store, reporter).await, + Self::System => system::System.install(hook, store, reporter).await, Self::Conda | Self::Coursier | Self::Perl | Self::R => { - UNIMPLEMENTED.install(hook, store, reporter).await + Unimplemented.install(hook, store, reporter).await } } } pub(crate) async fn check_health(&self, info: &InstallInfo) -> Result<()> { match self { - Self::Dart => DART.check_health(info).await, - Self::Bun => BUN.check_health(info).await, - Self::Deno => DENO.check_health(info).await, - Self::Docker => DOCKER.check_health(info).await, - Self::DockerImage => DOCKER_IMAGE.check_health(info).await, - Self::Dotnet => DOTNET.check_health(info).await, - Self::Fail => FAIL.check_health(info).await, - Self::Golang => GOLANG.check_health(info).await, - Self::Haskell => HASKELL.check_health(info).await, - Self::Julia => JULIA.check_health(info).await, - Self::Lua => LUA.check_health(info).await, - Self::Node => NODE.check_health(info).await, - Self::Pygrep => PYGREP.check_health(info).await, - Self::Python => PYTHON.check_health(info).await, - Self::Ruby => RUBY.check_health(info).await, - Self::Rust => RUST.check_health(info).await, - Self::Script => SCRIPT.check_health(info).await, - Self::Swift => SWIFT.check_health(info).await, - Self::System => SYSTEM.check_health(info).await, + Self::Dart => dart::Dart.check_health(info).await, + Self::Bun => bun::Bun.check_health(info).await, + Self::Deno => deno::Deno.check_health(info).await, + Self::Docker => docker::Docker.check_health(info).await, + Self::DockerImage => docker_image::DockerImage.check_health(info).await, + Self::Dotnet => dotnet::Dotnet.check_health(info).await, + Self::Fail => fail::Fail.check_health(info).await, + Self::Golang => golang::Golang.check_health(info).await, + Self::Haskell => haskell::Haskell.check_health(info).await, + Self::Julia => julia::Julia.check_health(info).await, + Self::Lua => lua::Lua.check_health(info).await, + Self::Node => node::Node.check_health(info).await, + Self::Pygrep => pygrep::Pygrep.check_health(info).await, + Self::Python => python::Python.check_health(info).await, + Self::PythonUv => python::PythonUv.check_health(info).await, + Self::Ruby => ruby::Ruby.check_health(info).await, + Self::Rust => rust::Rust.check_health(info).await, + Self::Script => script::Script.check_health(info).await, + Self::Swift => swift::Swift.check_health(info).await, + Self::System => system::System.check_health(info).await, Self::Conda | Self::Coursier | Self::Perl | Self::R => { - UNIMPLEMENTED.check_health(info).await + Unimplemented.check_health(info).await } } } @@ -425,37 +436,42 @@ impl Language { } match self { - Self::Dart => DART.run(hook, filenames, store, reporter).await, - Self::Bun => BUN.run(hook, filenames, store, reporter).await, - Self::Deno => DENO.run(hook, filenames, store, reporter).await, - Self::Docker => DOCKER.run(hook, filenames, store, reporter).await, - Self::DockerImage => DOCKER_IMAGE.run(hook, filenames, store, reporter).await, - Self::Dotnet => DOTNET.run(hook, filenames, store, reporter).await, - Self::Fail => FAIL.run(hook, filenames, store, reporter).await, - Self::Golang => GOLANG.run(hook, filenames, store, reporter).await, - Self::Haskell => HASKELL.run(hook, filenames, store, reporter).await, - Self::Julia => JULIA.run(hook, filenames, store, reporter).await, - Self::Lua => LUA.run(hook, filenames, store, reporter).await, - Self::Node => NODE.run(hook, filenames, store, reporter).await, - Self::Pygrep => PYGREP.run(hook, filenames, store, reporter).await, - Self::Python => PYTHON.run(hook, filenames, store, reporter).await, - Self::Ruby => RUBY.run(hook, filenames, store, reporter).await, - Self::Rust => RUST.run(hook, filenames, store, reporter).await, - Self::Script => SCRIPT.run(hook, filenames, store, reporter).await, - Self::Swift => SWIFT.run(hook, filenames, store, reporter).await, - Self::System => SYSTEM.run(hook, filenames, store, reporter).await, + Self::Dart => dart::Dart.run(hook, filenames, store, reporter).await, + Self::Bun => bun::Bun.run(hook, filenames, store, reporter).await, + Self::Deno => deno::Deno.run(hook, filenames, store, reporter).await, + Self::Docker => docker::Docker.run(hook, filenames, store, reporter).await, + Self::DockerImage => { + docker_image::DockerImage + .run(hook, filenames, store, reporter) + .await + } + Self::Dotnet => dotnet::Dotnet.run(hook, filenames, store, reporter).await, + Self::Fail => fail::Fail.run(hook, filenames, store, reporter).await, + Self::Golang => golang::Golang.run(hook, filenames, store, reporter).await, + Self::Haskell => haskell::Haskell.run(hook, filenames, store, reporter).await, + Self::Julia => julia::Julia.run(hook, filenames, store, reporter).await, + Self::Lua => lua::Lua.run(hook, filenames, store, reporter).await, + Self::Node => node::Node.run(hook, filenames, store, reporter).await, + Self::Pygrep => pygrep::Pygrep.run(hook, filenames, store, reporter).await, + Self::Python => python::Python.run(hook, filenames, store, reporter).await, + Self::PythonUv => python::PythonUv.run(hook, filenames, store, reporter).await, + Self::Ruby => ruby::Ruby.run(hook, filenames, store, reporter).await, + Self::Rust => rust::Rust.run(hook, filenames, store, reporter).await, + Self::Script => script::Script.run(hook, filenames, store, reporter).await, + Self::Swift => swift::Swift.run(hook, filenames, store, reporter).await, + Self::System => system::System.run(hook, filenames, store, reporter).await, Self::Conda | Self::Coursier | Self::Perl | Self::R => { - UNIMPLEMENTED.run(hook, filenames, store, reporter).await + Unimplemented.run(hook, filenames, store, reporter).await } } } } -/// Try to extract metadata from the given hook. -pub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> { - match hook.language { - Language::Python => python::extract_metadata(hook).await, - Language::Golang => golang::extract_go_mod_metadata(hook).await, +/// Try to extract metadata before the hook installation identity is finalized. +pub(crate) async fn extract_metadata(metadata: &mut HookMetadata<'_>) -> Result<()> { + match metadata.language { + Language::Python => python::extract_metadata(metadata).await, + Language::Golang => golang::extract_go_mod_metadata(metadata).await, Language::Bun | Language::Conda | Language::Coursier @@ -471,6 +487,7 @@ pub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> { | Language::Node | Language::Perl | Language::Pygrep + | Language::PythonUv | Language::R | Language::Ruby | Language::Rust diff --git a/crates/prek/src/languages/python/mod.rs b/crates/prek/src/languages/python/mod.rs index b9a16fb5d..eb11a6769 100644 --- a/crates/prek/src/languages/python/mod.rs +++ b/crates/prek/src/languages/python/mod.rs @@ -1,6 +1,6 @@ use anyhow::Result; -use crate::hook::Hook; +use crate::languages::HookMetadata; mod pep723; mod pyproject; @@ -11,12 +11,12 @@ mod version; /// Extract Python hook metadata with explicit precedence: /// PEP 723 > user-configured `language_version` > pyproject.toml > default. -pub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> { - pyproject::extract_pyproject_metadata(hook).await?; - pep723::extract_pep723_metadata(hook).await +pub(crate) async fn extract_metadata(metadata: &mut HookMetadata<'_>) -> Result<()> { + pyproject::extract_pyproject_metadata(metadata).await?; + pep723::extract_pep723_metadata(metadata).await } -pub(crate) use python::Python; +pub(crate) use python::{Python, PythonUv}; pub(crate) use python::{python_exec, query_python_info_cached}; pub(crate) use uv::Uv; pub(crate) use version::PythonRequest; diff --git a/crates/prek/src/languages/python/pep723.rs b/crates/prek/src/languages/python/pep723.rs index 53019d45f..5250bcd5f 100644 --- a/crates/prek/src/languages/python/pep723.rs +++ b/crates/prek/src/languages/python/pep723.rs @@ -29,7 +29,7 @@ use memchr::memmem::Finder; use serde::Deserialize; use tracing::trace; -use crate::hook::Hook; +use crate::languages::HookMetadata; use crate::languages::version::LanguageRequest; static FINDER: LazyLock = LazyLock::new(|| Finder::new(b"# /// script")); @@ -255,24 +255,24 @@ impl ScriptTag { /// First part of `entry` must be a file path to the Python script. /// Effectively, we are implementing a new `python-script` language which works like `script`. /// But we don't want to introduce a new language just for this for now. -pub(crate) async fn extract_pep723_metadata(hook: &mut Hook) -> Result<()> { - if hook.entry.shell().is_some() { +pub(crate) async fn extract_pep723_metadata(metadata: &mut HookMetadata<'_>) -> Result<()> { + if metadata.entry.shell().is_some() { trace!( - "Skipping reading PEP 723 metadata for hook `{hook}` because `shell` treats `entry` as shell source", + "Skipping reading PEP 723 metadata for hook `{metadata}` because `shell` treats `entry` as shell source", ); return Ok(()); } - if !hook.additional_dependencies.is_empty() { + if !metadata.additional_dependencies.is_empty() { trace!( - "Skipping reading PEP 723 metadata for hook `{hook}` because it already has `additional_dependencies`", + "Skipping reading PEP 723 metadata for hook `{metadata}` because it already has `additional_dependencies`", ); return Ok(()); } - let repo_path = hook.repo_path().unwrap_or(hook.work_dir()); + let repo_path = metadata.repo_path.unwrap_or(metadata.work_dir); - let split = hook.entry.expect_direct().split()?; + let split = metadata.entry.expect_direct().split()?; let file = repo_path.join(&split[0]); let Some(script) = Pep723Script::read(&file).await? else { @@ -280,15 +280,15 @@ pub(crate) async fn extract_pep723_metadata(hook: &mut Hook) -> Result<()> { }; if let Some(dependencies) = script.metadata.dependencies { - hook.additional_dependencies = dependencies.into_iter().collect(); + *metadata.additional_dependencies = dependencies.into_iter().collect(); } if let Some(language_request) = script.metadata.requires_python { - if !hook.language_request.is_any() { + if !metadata.language_request.is_any() { trace!( "`language_version` is ignored because `requires_python` is specified in the PEP 723 metadata" ); } - hook.language_request = LanguageRequest::parse(hook.language, &language_request)?; + *metadata.language_request = LanguageRequest::parse(metadata.language, &language_request)?; } Ok(()) diff --git a/crates/prek/src/languages/python/pyproject.rs b/crates/prek/src/languages/python/pyproject.rs index ba23cab23..cc4151c1d 100644 --- a/crates/prek/src/languages/python/pyproject.rs +++ b/crates/prek/src/languages/python/pyproject.rs @@ -6,7 +6,7 @@ use serde::Deserialize; use tracing::trace; use crate::config::Language; -use crate::hook::Hook; +use crate::languages::HookMetadata; use crate::languages::version::LanguageRequest; #[derive(Debug, Deserialize)] @@ -43,21 +43,21 @@ async fn extract_pyproject_requires_python(repo_path: &Path) -> Result Result<()> { - if !hook.language_request.is_any() { +pub(crate) async fn extract_pyproject_metadata(metadata: &mut HookMetadata<'_>) -> Result<()> { + if !metadata.language_request.is_any() { trace!( - hook = %hook, + hook = %metadata, "Skipping pyproject.toml metadata extraction because language_version is already configured", ); return Ok(()); } - let Some(repo_path) = hook.repo_path() else { + let Some(repo_path) = metadata.repo_path else { return Ok(()); }; let Some(req_str) = extract_pyproject_requires_python(repo_path).await? else { - trace!(hook = %hook, "No requires-python found in pyproject.toml"); + trace!(hook = %metadata, "No requires-python found in pyproject.toml"); return Ok(()); }; @@ -69,8 +69,8 @@ pub(crate) async fn extract_pyproject_metadata(hook: &mut Hook) -> Result<()> { } }; - trace!(hook = %hook, version = %req_str, "Using pyproject.toml-derived language_version"); - hook.language_request = req; + trace!(hook = %metadata, version = %req_str, "Using pyproject.toml-derived language_version"); + *metadata.language_request = req; Ok(()) } diff --git a/crates/prek/src/languages/python/python.rs b/crates/prek/src/languages/python/python.rs index a2ac5414b..b11cc8c62 100644 --- a/crates/prek/src/languages/python/python.rs +++ b/crates/prek/src/languages/python/python.rs @@ -13,6 +13,7 @@ use serde::Deserialize; use tracing::{debug, trace}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; +use crate::config::PythonUvLockMode; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; use crate::languages::LanguageImpl; @@ -27,6 +28,9 @@ use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] pub(crate) struct Python; +#[derive(Debug, Copy, Clone)] +pub(crate) struct PythonUv; + pub(crate) struct PythonInfo { pub(crate) version: semver::Version, pub(crate) python_exec: PathBuf, @@ -235,6 +239,73 @@ impl LanguageImpl for Python { } } +impl LanguageImpl for PythonUv { + async fn install( + &self, + hook: Arc, + store: &Store, + reporter: &HookInstallReporter, + ) -> Result { + let progress = reporter.on_install_start(&hook); + + let uv_dir = store.tools_path(ToolBucket::Uv); + let uv = Uv::install(store, &uv_dir) + .await + .context("Failed to install uv")?; + + let mut info = InstallInfo::new( + hook.language, + hook.env_key_dependencies().clone(), + &store.hooks_dir(), + )?; + + debug!(%hook, target = %info.env_path.display(), "Installing python_uv environment"); + + Python::create_venv(&uv, store, &info, &hook.language_request) + .await + .context("Failed to create Python virtual environment")?; + + let uv_env = hook + .python_uv_env() + .expect("python_uv hook must have uv env options"); + Python::sync_project_environment_command(&uv, store, &info.env_path, uv_env) + .output() + .await + .context("Failed to sync uv project environment")?; + + let python = python_exec(&info.env_path); + let python_info = query_python_info(&python) + .await + .context("Failed to query Python info")?; + + info.with_language_version(python_info.version) + .with_toolchain(python_info.python_exec); + + 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<()> { + Python.check_health(info).await + } + + async fn run( + &self, + hook: &InstalledHook, + filenames: &[&Path], + store: &Store, + reporter: &HookRunReporter, + ) -> Result<(i32, Vec)> { + Python.run(hook, filenames, store, reporter).await + } +} + fn to_uv_python_request(request: &LanguageRequest) -> Option { match request { LanguageRequest::Any { .. } => None, @@ -278,6 +349,46 @@ impl Python { cmd } + fn sync_project_environment_command( + uv: &Uv, + store: &Store, + env_path: &Path, + uv_env: &crate::hook::PythonUvEnv, + ) -> Cmd { + let mut cmd = uv.cmd("uv sync", store); + cmd.arg("sync") + .arg("--project") + .arg(&uv_env.project) + .arg("--active") + .arg("--no-default-groups") + .env(EnvVars::VIRTUAL_ENV, env_path) + .current_dir(&uv_env.project); + + match uv_env.lock_mode { + PythonUvLockMode::Locked => { + cmd.arg("--locked"); + } + PythonUvLockMode::Frozen => { + cmd.arg("--frozen"); + } + } + + for group in &uv_env.dependency_groups { + cmd.arg("--group").arg(group); + } + for extra in &uv_env.extras { + cmd.arg("--extra").arg(extra); + } + if !uv_env.install_project { + cmd.arg("--no-install-project"); + } + + Self::remove_uv_python_override_envs(&mut cmd) + .remove_git_envs() + .check(true); + cmd + } + async fn create_venv( uv: &Uv, store: &Store, @@ -401,8 +512,8 @@ mod tests { use rustc_hash::FxHashSet; use super::Python; - use crate::config::Language; - use crate::hook::InstallInfo; + use crate::config::{Language, PythonUvLockMode, PythonUvOptions}; + use crate::hook::{InstallInfo, PythonUvEnv}; use crate::languages::python::uv::Uv; use crate::languages::version::LanguageRequest; use crate::store::Store; @@ -431,6 +542,12 @@ mod tests { .collect() } + fn args(cmd: &crate::process::Cmd) -> Vec { + cmd.get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect() + } + #[test] fn create_venv_command_removes_uv_system_python_override() { let (_temp, uv, store, info) = setup_test_install(); @@ -455,4 +572,57 @@ mod tests { assert_eq!(envs.get(EnvVars::UV_MANAGED_PYTHON), Some(&None)); assert_eq!(envs.get(EnvVars::UV_NO_MANAGED_PYTHON), Some(&None)); } + + #[test] + fn sync_project_environment_command_targets_managed_venv() { + let temp = tempfile::tempdir().expect("create tempdir"); + fs_err::write( + temp.path().join("pyproject.toml"), + "[project]\nname = \"example\"\nversion = \"0.1.0\"\n", + ) + .expect("write pyproject"); + fs_err::write(temp.path().join("uv.lock"), "version = 1\n").expect("write lockfile"); + + let uv_env = PythonUvEnv::resolve( + &PythonUvOptions { + dependency_groups: Some(vec!["typecheck".to_string()]), + extras: Some(vec!["typed".to_string()]), + install_project: Some(false), + lock_mode: Some(PythonUvLockMode::Frozen), + ..Default::default() + }, + temp.path(), + ) + .expect("resolve python_uv env"); + + let uv = Uv::new(PathBuf::from("uv")); + let store = Store::from_path(temp.path().join("store")); + let env_path = temp.path().join("managed-venv"); + let cmd = Python::sync_project_environment_command(&uv, &store, &env_path, &uv_env); + + assert_eq!( + args(&cmd), + [ + "sync", + "--project", + temp.path().canonicalize().unwrap().to_str().unwrap(), + "--active", + "--no-default-groups", + "--frozen", + "--group", + "typecheck", + "--extra", + "typed", + "--no-install-project", + ] + ); + + let envs = env_map(&cmd); + assert_eq!( + envs.get(EnvVars::VIRTUAL_ENV), + Some(&Some(env_path.to_string_lossy().into_owned())) + ); + assert_eq!(envs.get(EnvVars::UV_SYSTEM_PYTHON), Some(&None)); + assert_eq!(cmd.get_current_dir(), Some(uv_env.project.as_path())); + } } diff --git a/crates/prek/src/languages/version.rs b/crates/prek/src/languages/version.rs index ddc17f3e9..4c7bd45bd 100644 --- a/crates/prek/src/languages/version.rs +++ b/crates/prek/src/languages/version.rs @@ -92,7 +92,7 @@ impl LanguageRequest { Language::Deno => Self::Deno(request.parse()?), Language::Golang => Self::Golang(request.parse()?), Language::Node => Self::Node(request.parse()?), - Language::Python => Self::Python(request.parse()?), + Language::Python | Language::PythonUv => Self::Python(request.parse()?), Language::Ruby => Self::Ruby(request.parse()?), Language::Rust => Self::Rust(request.parse()?), Language::Conda diff --git a/docs/authoring-hooks.md b/docs/authoring-hooks.md index 57462f5eb..7a68782ee 100644 --- a/docs/authoring-hooks.md +++ b/docs/authoring-hooks.md @@ -31,6 +31,7 @@ each manifest hook: | `additional_dependencies` | No | No | list of strings | Extra dependencies installed into managed hook environments. | | `args` | No | No | list of strings | Extra arguments appended to `entry` before filenames. | | `env` | No | Yes | map of strings | Runtime environment variables for the hook process. | +| `uv` | No | Yes | object | Project sync options for `language: python_uv`. | | `always_run` | No | No | boolean | Run even when no files match. | | `fail_fast` | No | No | boolean | Stop the run immediately if this hook fails. | | `pass_filenames` | No | No | boolean or positive integer | Control whether, or how many, matching filenames are passed. | diff --git a/docs/languages.md b/docs/languages.md index 67660a5da..8f8539ffe 100644 --- a/docs/languages.md +++ b/docs/languages.md @@ -27,6 +27,7 @@ If `language_version` is `system`, prek skips downloads and requires a system-in Languages with managed toolchain downloads in prek today: - [Python](#python) +- [Python uv project sync](#python_uv) - [Node](#node) - [Bun](#bun) - [Deno](#deno) @@ -376,6 +377,45 @@ main() - When both `language_version` (in config) and `requires-python` (in script) are set, `language_version` takes precedence - Only `dependencies` and `requires-python` fields are supported; other metadata like `tool.uv` is ignored +### python_uv + +**Status in prek:** ✅ Supported. + +`python_uv` creates a prek-managed clean virtual environment, then syncs it from the project's `uv.lock` and selected uv dependency groups or extras. It is intended for project-aware tools such as type checkers that need the project's declared dependency closure without using the user's active `.venv`. + +Unlike `language: python`, `python_uv` does not install the remote hook repository or `additional_dependencies`. Python packages must be declared in the uv project and locked in `uv.lock`. + +Example: + +```yaml +repos: + - repo: https://github.com/astral-sh/ty-pre-commit + rev: v0.1.0 + hooks: + - id: ty + language: python_uv + entry: ty check . + pass_filenames: false + uv: + project: . + dependency_groups: [typecheck] + install_project: true + lock_mode: locked +``` + +#### `uv` + +Supported hook options: + +- `project`: uv project directory. Defaults to the directory containing the prek config. +- `lockfile`: uv lockfile path. Defaults to `/uv.lock`; currently only the project's default `uv.lock` is supported. +- `dependency_groups`: dependency groups to sync. Defaults to `[]`. +- `extras`: extras to sync. Defaults to `[]`. +- `install_project`: install the current project. Defaults to `true`. +- `lock_mode`: `locked` or `frozen`. Defaults to `locked`. + +prek syncs with `--no-default-groups`, so `dependency_groups` is the exact group set for the hook environment. + ### r **Status in prek:** Not supported yet. diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 71eed2bb9..17886a9cf 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -1206,6 +1206,70 @@ Extra dependencies for hooks that run inside a managed environment (for example If you set this for a language that doesn’t support dependency installation, `prek` fails with a configuration error. +`language: python_uv` does not support `additional_dependencies`; add Python packages to a uv dependency group and update `uv.lock` instead. + +### `uv` + + + +!!! note "prek-only" + + `uv` is a `prek` extension for `language: python_uv`. + +Configure how `python_uv` builds the prek-managed hook environment from a uv project. + +- Type: object +- Valid only when `language = "python_uv"` + +Fields: + +- `project`: uv project directory. Defaults to the directory containing the prek config. +- `lockfile`: uv lockfile path. Defaults to `/uv.lock`; currently only the project's default `uv.lock` is supported. +- `dependency_groups`: dependency groups to sync. Defaults to `[]`. +- `extras`: extras to sync. Defaults to `[]`. +- `install_project`: install the current project. Defaults to `true`. +- `lock_mode`: `locked` or `frozen`. Defaults to `locked`. + +Example: + +=== "prek.toml" + + ```toml + [[repos]] + repo = "local" + + [[repos.hooks]] + id = "ty" + name = "ty" + entry = "ty check ." + language = "python_uv" + pass_filenames = false + + [repos.hooks.uv] + project = "." + dependency_groups = ["typecheck"] + install_project = true + lock_mode = "locked" + ``` + +=== ".pre-commit-config.yaml" + + ```yaml + repos: + - repo: local + hooks: + - id: ty + name: ty + entry: ty check . + language: python_uv + pass_filenames: false + uv: + project: . + dependency_groups: [typecheck] + install_project: true + lock_mode: locked + ``` + ### `minimum_prek_version` diff --git a/prek.schema.json b/prek.schema.json index cd545a84a..6a7e0b58e 100644 --- a/prek.schema.json +++ b/prek.schema.json @@ -77,6 +77,9 @@ "python": { "type": "string" }, + "python_uv": { + "type": "string" + }, "r": { "type": "string" }, @@ -324,6 +327,10 @@ "minimum_prek_version": { "description": "The minimum version of prek required to run this hook.", "type": "string" + }, + "uv": { + "description": "Language-specific options for `language: python_uv`.", + "$ref": "#/definitions/PythonUvOptions" } }, "required": [ @@ -351,6 +358,7 @@ "perl", "pygrep", "python", + "python_uv", "r", "ruby", "rust", @@ -430,6 +438,48 @@ "prepare-commit-msg" ] }, + "PythonUvOptions": { + "type": "object", + "properties": { + "project": { + "description": "The uv project directory. Defaults to the project configuration directory.", + "type": "string" + }, + "lockfile": { + "description": "The uv lockfile path. Defaults to `/uv.lock`.", + "type": "string" + }, + "dependency_groups": { + "description": "Dependency groups to install into the managed hook environment.", + "type": "array", + "items": { + "type": "string" + } + }, + "extras": { + "description": "Project extras to install into the managed hook environment.", + "type": "array", + "items": { + "type": "string" + } + }, + "install_project": { + "description": "Whether to install the current project into the managed hook environment.", + "type": "boolean" + }, + "lock_mode": { + "description": "Whether uv should check lockfile freshness or use it as-is.", + "$ref": "#/definitions/PythonUvLockMode" + } + } + }, + "PythonUvLockMode": { + "type": "string", + "enum": [ + "locked", + "frozen" + ] + }, "LocalRepo": { "type": "object", "properties": { @@ -581,6 +631,10 @@ "minimum_prek_version": { "description": "The minimum version of prek required to run this hook.", "type": "string" + }, + "uv": { + "description": "Language-specific options for `language: python_uv`.", + "$ref": "#/definitions/PythonUvOptions" } }, "required": [ @@ -744,6 +798,10 @@ "minimum_prek_version": { "description": "The minimum version of prek required to run this hook.", "type": "string" + }, + "uv": { + "description": "Language-specific options for `language: python_uv`.", + "$ref": "#/definitions/PythonUvOptions" } }, "required": [ @@ -912,6 +970,10 @@ "minimum_prek_version": { "description": "The minimum version of prek required to run this hook.", "type": "string" + }, + "uv": { + "description": "Language-specific options for `language: python_uv`.", + "$ref": "#/definitions/PythonUvOptions" } }, "required": [ From c016aa10d7d9568ac4d448dc1e6e7fc35003e026 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Thu, 7 May 2026 22:07:18 +0800 Subject: [PATCH 2/5] Move hook env spec to top-level module --- crates/prek/src/hook.rs | 5 +---- crates/prek/src/{hook/env_spec.rs => hook_env.rs} | 0 crates/prek/src/languages/python/python.rs | 5 +++-- crates/prek/src/main.rs | 1 + 4 files changed, 5 insertions(+), 6 deletions(-) rename crates/prek/src/{hook/env_spec.rs => hook_env.rs} (100%) diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index 58446c8ef..943a82b47 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -18,15 +18,12 @@ use crate::config::{ MetaHook, PassFilenames, RemoteHook, Stages, read_manifest, }; use crate::hook_entry::HookEntry; +use crate::hook_env::{HookEnvSpec, PythonUvEnv}; use crate::languages::version::LanguageRequest; use crate::languages::{HookMetadata, ShellSupport, extract_metadata}; use crate::store::Store; use crate::workspace::Project; -mod env_spec; - -pub(crate) use env_spec::{HookEnvSpec, PythonUvEnv}; - #[derive(Error, Debug)] pub(crate) enum Error { #[error(transparent)] diff --git a/crates/prek/src/hook/env_spec.rs b/crates/prek/src/hook_env.rs similarity index 100% rename from crates/prek/src/hook/env_spec.rs rename to crates/prek/src/hook_env.rs diff --git a/crates/prek/src/languages/python/python.rs b/crates/prek/src/languages/python/python.rs index b11cc8c62..4b37541b6 100644 --- a/crates/prek/src/languages/python/python.rs +++ b/crates/prek/src/languages/python/python.rs @@ -353,7 +353,7 @@ impl Python { uv: &Uv, store: &Store, env_path: &Path, - uv_env: &crate::hook::PythonUvEnv, + uv_env: &crate::hook_env::PythonUvEnv, ) -> Cmd { let mut cmd = uv.cmd("uv sync", store); cmd.arg("sync") @@ -513,7 +513,8 @@ mod tests { use super::Python; use crate::config::{Language, PythonUvLockMode, PythonUvOptions}; - use crate::hook::{InstallInfo, PythonUvEnv}; + use crate::hook::InstallInfo; + use crate::hook_env::PythonUvEnv; use crate::languages::python::uv::Uv; use crate::languages::version::LanguageRequest; use crate::store::Store; diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index 0b1ff8b3e..08bba699b 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -37,6 +37,7 @@ mod fs; mod git; mod hook; mod hook_entry; +mod hook_env; mod hooks; mod http; mod install_source; From 4ae1b4c58a92cf9ebf5bdb76172527cda4fc9ab2 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Fri, 8 May 2026 11:57:31 +0800 Subject: [PATCH 3/5] Tweaks --- Cargo.toml | 2 +- crates/prek/src/cli/cache_gc.rs | 5 +- crates/prek/src/hook.rs | 138 ++------- crates/prek/src/hook_env.rs | 285 +++++++++++------- crates/prek/src/languages/mod.rs | 12 +- ...prek__config__tests__language_version.snap | 3 + .../prek__config__tests__meta_hooks-5.snap | 3 + ...ests__numeric_rev_is_parsed_as_string.snap | 1 + .../prek__config__tests__parse_hooks-3.snap | 1 + .../prek__config__tests__parse_repos-3.snap | 1 + .../prek__config__tests__parse_repos-4.snap | 1 + .../prek__config__tests__parse_repos-6.snap | 1 + .../prek__config__tests__parse_repos.snap | 1 + ...g__tests__read_config_with_merge_keys.snap | 2 + ...s__read_config_with_nested_merge_keys.snap | 1 + .../prek__config__tests__read_manifest.snap | 3 + ...prek__config__tests__read_toml_config.snap | 3 + ...prek__config__tests__read_yaml_config.snap | 7 + docs/reference/cli.md | 1 + 19 files changed, 236 insertions(+), 235 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index f3d59e23b..27fc7cfff 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,7 +75,7 @@ reqwest = { version = "0.13.2", default-features = false, features = [ rustc-hash = { version = "2.1.1" } rustix = { version = "1.0.8", features = ["pty", "process", "fs", "termios"] } same-file = { version = "1.0.6" } -seahash = { version = "4.1.0" } +seahash = { version = "4.1.0", features = ["use_std"] } semver = { version = "1.0.24", features = ["serde"] } serde = { version = "1.0.210", features = ["derive"] } serde_json = { version = "1.0.132", features = [ diff --git a/crates/prek/src/cli/cache_gc.rs b/crates/prek/src/cli/cache_gc.rs index 657fc195f..18e51862e 100644 --- a/crates/prek/src/cli/cache_gc.rs +++ b/crates/prek/src/cli/cache_gc.rs @@ -14,7 +14,8 @@ use tracing::{debug, trace, warn}; use crate::cli::ExitStatus; use crate::cli::cache_size::{dir_size_bytes, human_readable_bytes}; use crate::config::{self, Error as ConfigError, Repo as ConfigRepo, load_config}; -use crate::hook::{HOOK_MARKER, HookEnvKey, HookSpec, InstallInfo, Repo as HookRepo}; +use crate::hook::{HOOK_MARKER, HookSpec, InstallInfo, Repo as HookRepo}; +use crate::hook_env::HookEnvKey; use crate::printer::Printer; use crate::store::{CacheBucket, REPO_MARKER, Store, ToolBucket}; @@ -214,7 +215,7 @@ pub(crate) async fn cache_gc( // While doing this, try to derive the specific tool *version* directories in use from // `InstallInfo.toolchain` (which is persisted in `.prek-hook.json`). for info in &installed { - if used_env_keys.iter().any(|k| k.matches_install_info(info)) { + if used_env_keys.iter().any(|key| info.matches_env_key(key)) { if let Some(dir) = info .env_path .file_name() diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index 943a82b47..7b78e304e 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -18,7 +18,7 @@ use crate::config::{ MetaHook, PassFilenames, RemoteHook, Stages, read_manifest, }; use crate::hook_entry::HookEntry; -use crate::hook_env::{HookEnvSpec, PythonUvEnv}; +use crate::hook_env::{HookEnvKey, HookEnvSpec, PythonUvEnv}; use crate::languages::version::LanguageRequest; use crate::languages::{HookMetadata, ShellSupport, extract_metadata}; use crate::store::Store; @@ -527,21 +527,6 @@ impl Hook { self.env_spec.python_uv() } - /// Returns a lightweight view of the hook environment identity used for reusing installs. - /// - /// Returns `None` for languages that do not install an environment. - pub(crate) fn env_key(&self) -> Option> { - if !self.language.supports_install_env() { - return None; - } - - Some(HookEnvKeyRef { - language: self.language, - dependencies: self.env_key_dependencies(), - language_request: &self.language_request, - }) - } - /// Dependencies to pass to language dependency installers. /// /// For remote hooks, this includes the local path to the cloned repository so that @@ -557,106 +542,6 @@ impl Hook { } } -#[derive(Debug, Clone)] -pub(crate) struct HookEnvKey { - pub(crate) language: Language, - pub(crate) dependencies: FxHashSet, - pub(crate) language_request: LanguageRequest, -} - -/// Borrowed form of [`HookEnvKey`] for comparing a hook to an existing installation -/// without allocating/cloning dependency sets. -#[derive(Debug, Clone, Copy)] -pub(crate) struct HookEnvKeyRef<'a> { - pub(crate) language: Language, - pub(crate) dependencies: &'a FxHashSet, - pub(crate) language_request: &'a LanguageRequest, -} - -/// Shared matching logic between a computed hook env key (owned or borrowed) and an installed -/// environment described by [`InstallInfo`]. -fn matches_install_info( - language: Language, - dependencies: &FxHashSet, - language_request: &LanguageRequest, - info: &InstallInfo, -) -> bool { - info.language == language - && info.dependencies == *dependencies - && language_request.satisfied_by(info) -} - -impl HookEnvKey { - /// Compute the key used to match an installed hook environment. - /// - /// Returns `Ok(None)` if this hook does not install an environment. - pub(crate) fn from_hook_spec( - config: &Config, - mut hook_spec: HookSpec, - remote_repo_dependency: Option<&str>, - project_root: &Path, - ) -> Result> { - let language = hook_spec.language; - if !language.supports_install_env() { - return Ok(None); - } - - hook_spec.apply_project_defaults(config); - hook_spec.options.language_version.get_or_insert_default(); - let additional_dependencies = hook_spec - .options - .additional_dependencies - .get_or_insert_default() - .iter() - .cloned() - .collect::>(); - - let request = hook_spec.options.language_version.as_deref().unwrap_or(""); - let language_request = LanguageRequest::parse(language, request).with_context(|| { - format!( - "Invalid language_version `{request}` for hook `{}`", - hook_spec.id - ) - })?; - - let env_spec = HookEnvSpec::resolve( - language, - &additional_dependencies, - hook_spec.options.uv.as_ref(), - project_root, - remote_repo_dependency, - )?; - let dependencies = env_spec.dependencies().clone(); - - Ok(Some(Self { - language, - dependencies, - language_request, - })) - } - - pub(crate) fn matches_install_info(&self, info: &InstallInfo) -> bool { - matches_install_info( - self.language, - &self.dependencies, - &self.language_request, - info, - ) - } -} - -impl HookEnvKeyRef<'_> { - /// Returns true if this env key matches the given installed environment. - pub(crate) fn matches_install_info(&self, info: &InstallInfo) -> bool { - matches_install_info( - self.language, - self.dependencies, - self.language_request, - info, - ) - } -} - #[derive(Debug, Clone)] pub(crate) enum InstalledHook { Installed { @@ -812,9 +697,26 @@ impl InstallInfo { self.extra.get(key) } + /// Returns whether this installed environment can be reused for the hook. + /// + /// Used when a fully resolved hook is available, such as install reuse + /// checks before running a hook. pub(crate) fn matches(&self, hook: &Hook) -> bool { - hook.env_key() - .is_some_and(|key| key.matches_install_info(self)) + hook.language.supports_install_env() + && self.language == hook.language + && self.dependencies == *hook.env_key_dependencies() + && hook.language_request.satisfied_by(self) + } + + /// Returns whether this installed environment matches the normalized hook + /// environment key. + /// + /// Used when only the normalized key is available, such as cache GC scanning + /// configured hooks without building full hook instances. + pub(crate) fn matches_env_key(&self, key: &HookEnvKey) -> bool { + self.language == key.language + && self.dependencies == key.dependencies + && key.language_request.satisfied_by(self) } } diff --git a/crates/prek/src/hook_env.rs b/crates/prek/src/hook_env.rs index 3a49de85d..37d9fdf0b 100644 --- a/crates/prek/src/hook_env.rs +++ b/crates/prek/src/hook_env.rs @@ -1,9 +1,131 @@ +use std::hash::Hasher; +use std::io; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use rustc_hash::{FxBuildHasher, FxHashSet}; -use crate::config::{Language, PythonUvLockMode, PythonUvOptions}; +use crate::config::{Config, Language, PythonUvLockMode, PythonUvOptions}; +use crate::hook::HookSpec; +use crate::languages::version::LanguageRequest; + +/// Resolved description of the environment a hook installs into and reuses. +/// +/// A hook environment is identified by the language runtime request plus this +/// language-specific dependency set. The spec is the single source of truth for +/// environment reuse, cache GC, and installer inputs; it does not perform +/// installation itself. +#[derive(Debug, Clone)] +pub(crate) enum HookEnvSpec { + Dependencies(FxHashSet), + PythonUv(PythonUvEnv), +} + +impl HookEnvSpec { + pub(crate) fn resolve( + language: Language, + additional_dependencies: &FxHashSet, + uv: Option<&PythonUvOptions>, + project_root: &Path, + remote_repo_dependency: Option<&str>, + ) -> Result { + if language == Language::PythonUv { + if !additional_dependencies.is_empty() { + anyhow::bail!( + "`language: python_uv` does not install `additional_dependencies`; add Python packages to a uv dependency group and update `uv.lock` instead", + ); + } + + let default_uv = PythonUvOptions::default(); + let uv = uv.unwrap_or(&default_uv); + Ok(Self::PythonUv(PythonUvEnv::resolve(uv, project_root)?)) + } else { + if uv.is_some() { + anyhow::bail!( + "Hook specified `uv` options but the language `{language}` is not `python_uv`", + ); + } + + validate_additional_dependencies(language, additional_dependencies)?; + + Ok(Self::Dependencies(env_key_dependencies( + additional_dependencies, + remote_repo_dependency, + ))) + } + } + + pub(crate) fn dependencies(&self) -> &FxHashSet { + match self { + Self::Dependencies(dependencies) => dependencies, + Self::PythonUv(env) => env.dependencies(), + } + } + + pub(crate) fn python_uv(&self) -> Option<&PythonUvEnv> { + match self { + Self::Dependencies(_) => None, + Self::PythonUv(env) => Some(env), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct HookEnvKey { + pub(crate) language: Language, + pub(crate) dependencies: FxHashSet, + pub(crate) language_request: LanguageRequest, +} + +impl HookEnvKey { + /// Compute the key used to match an installed hook environment. + /// + /// Returns `Ok(None)` if this hook does not install an environment. + pub(crate) fn from_hook_spec( + config: &Config, + mut hook_spec: HookSpec, + remote_repo_dependency: Option<&str>, + project_root: &Path, + ) -> Result> { + let language = hook_spec.language; + if !language.supports_install_env() { + return Ok(None); + } + + hook_spec.apply_project_defaults(config); + hook_spec.options.language_version.get_or_insert_default(); + let additional_dependencies = hook_spec + .options + .additional_dependencies + .get_or_insert_default() + .iter() + .cloned() + .collect::>(); + + let request = hook_spec.options.language_version.as_deref().unwrap_or(""); + let language_request = LanguageRequest::parse(language, request).with_context(|| { + format!( + "Invalid language_version `{request}` for hook `{}`", + hook_spec.id + ) + })?; + + let env_spec = HookEnvSpec::resolve( + language, + &additional_dependencies, + hook_spec.options.uv.as_ref(), + project_root, + remote_repo_dependency, + )?; + let dependencies = env_spec.dependencies().clone(); + + Ok(Some(Self { + language, + dependencies, + language_request, + })) + } +} #[derive(Debug, Clone)] pub(crate) struct PythonUvEnv { @@ -99,59 +221,23 @@ impl PythonUvEnv { } } -#[derive(Debug, Clone)] -pub(crate) enum HookEnvSpec { - Dependencies(FxHashSet), - PythonUv(PythonUvEnv), -} - -impl HookEnvSpec { - pub(super) fn resolve( - language: Language, - additional_dependencies: &FxHashSet, - uv: Option<&PythonUvOptions>, - project_root: &Path, - remote_repo_dependency: Option<&str>, - ) -> Result { - if language == Language::PythonUv { - if !additional_dependencies.is_empty() { - anyhow::bail!( - "`language: python_uv` does not install `additional_dependencies`; add Python packages to a uv dependency group and update `uv.lock` instead", - ); - } - - let default_uv = PythonUvOptions::default(); - let uv = uv.unwrap_or(&default_uv); - Ok(Self::PythonUv(PythonUvEnv::resolve(uv, project_root)?)) - } else { - if uv.is_some() { - anyhow::bail!( - "Hook specified `uv` options but the language `{language}` is not `python_uv`", - ); - } - - validate_additional_dependencies(language, additional_dependencies)?; - - Ok(Self::Dependencies(env_key_dependencies( - additional_dependencies, - remote_repo_dependency, - ))) - } - } - - pub(crate) fn dependencies(&self) -> &FxHashSet { - match self { - Self::Dependencies(dependencies) => dependencies, - Self::PythonUv(env) => env.dependencies(), - } - } - - pub(crate) fn python_uv(&self) -> Option<&PythonUvEnv> { - match self { - Self::Dependencies(_) => None, - Self::PythonUv(env) => Some(env), - } +/// Builds the dependency set used to identify a hook environment. +/// +/// For remote hooks, `remote_repo_dependency` is included so environments from different +/// repositories are not reused accidentally. +fn env_key_dependencies( + additional_dependencies: &FxHashSet, + remote_repo_dependency: Option<&str>, +) -> FxHashSet { + let mut deps = FxHashSet::with_capacity_and_hasher( + additional_dependencies.len() + usize::from(remote_repo_dependency.is_some()), + FxBuildHasher, + ); + deps.extend(additional_dependencies.iter().cloned()); + if let Some(dep) = remote_repo_dependency { + deps.insert(dep.to_string()); } + deps } fn validate_additional_dependencies( @@ -181,31 +267,42 @@ fn validate_additional_dependencies( Ok(()) } +fn python_uv_env_key_dependencies( + project: &Path, + lockfile: &Path, + pyproject: &Path, + dependency_groups: &[String], + extras: &[String], + install_project: bool, + lock_mode: PythonUvLockMode, +) -> Result> { + let mut dependencies = FxHashSet::default(); + dependencies.insert(format!("schema={}", PythonUvEnv::SCHEMA_VERSION)); + dependencies.insert(format!("project={}", project.display())); + dependencies.insert(format!("lockfile={}", lockfile.display())); + dependencies.insert(format!("lockfile_hash={}", hash_file(lockfile)?)); + dependencies.insert(format!("pyproject_hash={}", hash_file(pyproject)?)); + dependencies.insert(format!("dependency_groups={}", dependency_groups.join(","))); + dependencies.insert(format!("extras={}", extras.join(","))); + dependencies.insert(format!("install_project={install_project}")); + dependencies.insert(format!("lock_mode={lock_mode:?}")); + + push_optional_file_hash(&mut dependencies, "uv_toml_hash", &project.join("uv.toml"))?; + push_optional_file_hash( + &mut dependencies, + "python_version_file_hash", + &project.join(".python-version"), + )?; + + Ok(dependencies) +} + fn format_dependencies(dependencies: &FxHashSet) -> String { let mut dependencies = dependencies.iter().map(String::as_str).collect::>(); dependencies.sort_unstable(); dependencies.join(", ") } -/// Builds the dependency set used to identify a hook environment. -/// -/// For remote hooks, `remote_repo_dependency` is included so environments from different -/// repositories are not reused accidentally. -fn env_key_dependencies( - additional_dependencies: &FxHashSet, - remote_repo_dependency: Option<&str>, -) -> FxHashSet { - let mut deps = FxHashSet::with_capacity_and_hasher( - additional_dependencies.len() + usize::from(remote_repo_dependency.is_some()), - FxBuildHasher, - ); - deps.extend(additional_dependencies.iter().cloned()); - if let Some(dep) = remote_repo_dependency { - deps.insert(dep.to_string()); - } - deps -} - fn resolve_path(base: &Path, path: &Path) -> PathBuf { if path.is_absolute() { path.to_path_buf() @@ -219,19 +316,14 @@ fn canonicalize_string_list(values: &mut Vec) { values.dedup(); } -fn hash_file(path: &Path) -> Result { - let content = fs_err::read(path)?; - Ok(format!("{:016x}", seahash::hash(&content))) -} - fn push_optional_file_hash( dependencies: &mut FxHashSet, key: &str, path: &Path, ) -> Result<()> { - match fs_err::read(path) { - Ok(content) => { - dependencies.insert(format!("{key}={:016x}", seahash::hash(&content))); + match hash_file(path) { + Ok(hash) => { + dependencies.insert(format!("{key}={hash}")); } Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) => return Err(err.into()), @@ -240,32 +332,9 @@ fn push_optional_file_hash( Ok(()) } -fn python_uv_env_key_dependencies( - project: &Path, - lockfile: &Path, - pyproject: &Path, - dependency_groups: &[String], - extras: &[String], - install_project: bool, - lock_mode: PythonUvLockMode, -) -> Result> { - let mut dependencies = FxHashSet::default(); - dependencies.insert(format!("schema={}", PythonUvEnv::SCHEMA_VERSION)); - dependencies.insert(format!("project={}", project.display())); - dependencies.insert(format!("lockfile={}", lockfile.display())); - dependencies.insert(format!("lockfile_hash={}", hash_file(lockfile)?)); - dependencies.insert(format!("pyproject_hash={}", hash_file(pyproject)?)); - dependencies.insert(format!("dependency_groups={}", dependency_groups.join(","))); - dependencies.insert(format!("extras={}", extras.join(","))); - dependencies.insert(format!("install_project={install_project}")); - dependencies.insert(format!("lock_mode={lock_mode:?}")); - - push_optional_file_hash(&mut dependencies, "uv_toml_hash", &project.join("uv.toml"))?; - push_optional_file_hash( - &mut dependencies, - "python_version_file_hash", - &project.join(".python-version"), - )?; - - Ok(dependencies) +fn hash_file(path: &Path) -> io::Result { + let mut file = fs_err::File::open(path)?; + let mut hasher = seahash::SeaHasher::new(); + io::copy(&mut file, &mut hasher)?; + Ok(format!("{:016x}", hasher.finish())) } diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index b08468384..f866fc303 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -75,18 +75,18 @@ impl Display for HookMetadata<'_> { } } -#[derive(thiserror::Error, Debug)] -#[error("Language `{0}` is not implemented yet")] -struct UnimplementedError(String); - -struct Unimplemented; - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ShellSupport { Supported, Unsupported(&'static str), } +#[derive(thiserror::Error, Debug)] +#[error("Language `{0}` is not implemented yet")] +struct UnimplementedError(String); + +struct Unimplemented; + impl LanguageImpl for Unimplemented { async fn install( &self, diff --git a/crates/prek/src/snapshots/prek__config__tests__language_version.snap b/crates/prek/src/snapshots/prek__config__tests__language_version.snap index a03475643..02758a5f8 100644 --- a/crates/prek/src/snapshots/prek__config__tests__language_version.snap +++ b/crates/prek/src/snapshots/prek__config__tests__language_version.snap @@ -39,6 +39,7 @@ Ok( stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -71,6 +72,7 @@ Ok( stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -103,6 +105,7 @@ Ok( stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap b/crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap index afc581817..afd0c36c4 100644 --- a/crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap +++ b/crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap @@ -45,6 +45,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -84,6 +85,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -114,6 +116,7 @@ Config { true, ), minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap b/crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap index 1ed0992c6..1c80dc313 100644 --- a/crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap +++ b/crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap @@ -37,6 +37,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap b/crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap index 47bbcb92a..aaed25610 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap @@ -36,6 +36,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap b/crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap index de253da67..17b1348e9 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap @@ -40,6 +40,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap b/crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap index 93c42ae52..fd43bb60b 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap @@ -37,6 +37,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap b/crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap index 93c42ae52..fd43bb60b 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap @@ -37,6 +37,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap b/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap index fa4e159cd..1962c5884 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap @@ -36,6 +36,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap b/crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap index 1164f40d3..249bc2634 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap @@ -41,6 +41,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -76,6 +77,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap b/crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap index 62730b61d..ab37810c6 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap @@ -42,6 +42,7 @@ Config { ), verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_manifest.snap b/crates/prek/src/snapshots/prek__config__tests__read_manifest.snap index 86e3b8cba..e5df6594c 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_manifest.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_manifest.snap @@ -42,6 +42,7 @@ Manifest { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: { "minimum_pre_commit_version": String("2.9.2"), }, @@ -85,6 +86,7 @@ Manifest { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: { "minimum_pre_commit_version": String("2.9.2"), }, @@ -131,6 +133,7 @@ Manifest { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: { "minimum_pre_commit_version": String("2.9.2"), }, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap b/crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap index 1c8ae25a3..8a1c7c6e3 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap @@ -36,6 +36,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -75,6 +76,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -110,6 +112,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap b/crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap index 736d7ca06..897a67f79 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap @@ -37,6 +37,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -78,6 +79,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -122,6 +124,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -170,6 +173,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -214,6 +218,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -253,6 +258,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, @@ -288,6 +294,7 @@ Config { stages: None, verbose: None, minimum_prek_version: None, + uv: None, _unused_keys: {}, }, }, diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71bf289d2..375a21a99 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -343,6 +343,7 @@ prek list [OPTIONS] [HOOK|PROJECT]...
  • perl
  • pygrep
  • python
  • +
  • python-uv
  • r
  • ruby
  • rust
  • From 7f66f5d950700fd3a90708cdfca8020d370eecbb Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Fri, 8 May 2026 17:59:37 +0800 Subject: [PATCH 4/5] Refactor --- crates/prek/src/cli/cache_gc.rs | 157 ++++------ crates/prek/src/cli/run/run.rs | 57 ++-- crates/prek/src/config.rs | 2 +- crates/prek/src/hook.rs | 241 +++++++++++---- crates/prek/src/hook_env.rs | 306 ++++++++++++++------ crates/prek/src/languages/bun/bun.rs | 28 +- crates/prek/src/languages/bun/version.rs | 8 +- crates/prek/src/languages/dart.rs | 24 +- crates/prek/src/languages/deno/deno.rs | 30 +- crates/prek/src/languages/deno/version.rs | 8 +- crates/prek/src/languages/docker.rs | 30 +- crates/prek/src/languages/docker_image.rs | 4 +- crates/prek/src/languages/dotnet/dotnet.rs | 30 +- crates/prek/src/languages/dotnet/version.rs | 44 +-- crates/prek/src/languages/fail.rs | 4 +- crates/prek/src/languages/golang/golang.rs | 20 +- crates/prek/src/languages/golang/version.rs | 8 +- crates/prek/src/languages/haskell.rs | 16 +- crates/prek/src/languages/julia.rs | 22 +- crates/prek/src/languages/lua.rs | 32 +- crates/prek/src/languages/mod.rs | 50 ++-- crates/prek/src/languages/node/node.rs | 30 +- crates/prek/src/languages/node/version.rs | 44 +-- crates/prek/src/languages/pygrep/pygrep.rs | 20 +- crates/prek/src/languages/python/python.rs | 91 +++--- crates/prek/src/languages/python/version.rs | 36 +-- crates/prek/src/languages/ruby/ruby.rs | 30 +- crates/prek/src/languages/ruby/version.rs | 47 +-- crates/prek/src/languages/rust/rust.rs | 42 +-- crates/prek/src/languages/rust/version.rs | 79 ++--- crates/prek/src/languages/script.rs | 4 +- crates/prek/src/languages/swift.rs | 28 +- crates/prek/src/languages/system.rs | 4 +- crates/prek/src/languages/version.rs | 26 +- crates/prek/src/store.rs | 10 +- crates/prek/tests/cache.rs | 53 +++- 36 files changed, 957 insertions(+), 708 deletions(-) diff --git a/crates/prek/src/cli/cache_gc.rs b/crates/prek/src/cli/cache_gc.rs index 18e51862e..a734bd7de 100644 --- a/crates/prek/src/cli/cache_gc.rs +++ b/crates/prek/src/cli/cache_gc.rs @@ -14,8 +14,8 @@ use tracing::{debug, trace, warn}; use crate::cli::ExitStatus; use crate::cli::cache_size::{dir_size_bytes, human_readable_bytes}; use crate::config::{self, Error as ConfigError, Repo as ConfigRepo, load_config}; -use crate::hook::{HOOK_MARKER, HookSpec, InstallInfo, Repo as HookRepo}; -use crate::hook_env::HookEnvKey; +use crate::hook::{HOOK_MARKER, HookSpec, InstalledHookEnv, Repo as HookRepo}; +use crate::hook_env::{HookEnvIdentity, HookEnvRequest}; use crate::printer::Printer; use crate::store::{CacheBucket, REPO_MARKER, Store, ToolBucket}; @@ -160,7 +160,7 @@ pub(crate) async fn cache_gc( let mut used_tools: FxHashSet = FxHashSet::default(); let mut used_tool_versions: FxHashMap> = FxHashMap::default(); let mut used_cache: FxHashSet = FxHashSet::default(); - let mut used_env_keys: Vec = Vec::new(); + let mut used_env_requests: Vec = Vec::new(); // Always keep Prek's own cache. used_cache.insert(CacheBucket::Prek); @@ -187,7 +187,7 @@ pub(crate) async fn cache_gc( }; kept_configs.insert(config_path); - used_env_keys.extend(hook_env_keys_from_config(store, &config, config_path)); + used_env_requests.extend(hook_env_requests_from_config(store, &config, config_path)); // Mark repos referenced by this config (if present in store). // We do this via config parsing (no clone), so GC won't keep repos for missing configs. @@ -206,17 +206,20 @@ pub(crate) async fn cache_gc( } // Mark tools/caches from hook languages. - for key in &used_env_keys { - used_tools.extend(key.language.tool_buckets()); - used_cache.extend(key.language.cache_buckets()); + for request in &used_env_requests { + used_tools.extend(request.language.tool_buckets()); + used_cache.extend(request.language.cache_buckets()); } // Mark hook environments by matching already-installed env metadata. // While doing this, try to derive the specific tool *version* directories in use from - // `InstallInfo.toolchain` (which is persisted in `.prek-hook.json`). - for info in &installed { - if used_env_keys.iter().any(|key| info.matches_env_key(key)) { - if let Some(dir) = info + // `InstalledHookEnv.toolchain` (which is persisted in `.prek-hook.json`). + for env in &installed { + if used_env_requests + .iter() + .any(|request| env.satisfies(request.into())) + { + if let Some(dir) = env .env_path .file_name() .and_then(|s| s.to_str()) @@ -225,7 +228,7 @@ pub(crate) async fn cache_gc( used_hook_env_dirs.insert(dir); } - mark_tool_versions_from_install_info(store, info, &mut used_tool_versions); + mark_tool_versions_from_installed_env(store, env, &mut used_tool_versions); } } @@ -351,12 +354,12 @@ fn print_removed_details(printer: Printer, verb: &str, removal: &Removal) -> Res Ok(()) } -fn hook_env_keys_from_config( +fn hook_env_requests_from_config( store: &Store, config: &config::Config, config_path: &Path, -) -> Vec { - let mut keys = Vec::new(); +) -> Vec { + let mut requests = Vec::new(); let project_root = config_path.parent().unwrap_or_else(|| Path::new(".")); for repo_config in &config.repos { @@ -389,16 +392,16 @@ fn hook_env_keys_from_config( let mut hook_spec = manifest_hook.clone(); hook_spec.apply_remote_hook_overrides(hook_config); - match HookEnvKey::from_hook_spec( + match HookEnvRequest::from_hook_spec( config, hook_spec, Some(&remote_dep), project_root, ) { - Ok(Some(key)) => keys.push(key), + Ok(Some(request)) => requests.push(request), Ok(None) => {} Err(err) => { - warn!(hook = %hook_config.id, repo = %remote_dep, %err, "Failed to compute hook env key, skipping"); + warn!(hook = %hook_config.id, repo = %remote_dep, %err, "Failed to compute hook env request, skipping"); } } } @@ -406,11 +409,11 @@ fn hook_env_keys_from_config( ConfigRepo::Local(repo_config) => { for hook in &repo_config.hooks { let hook_spec = HookSpec::from(hook.clone()); - match HookEnvKey::from_hook_spec(config, hook_spec, None, project_root) { - Ok(Some(key)) => keys.push(key), + match HookEnvRequest::from_hook_spec(config, hook_spec, None, project_root) { + Ok(Some(request)) => requests.push(request), Ok(None) => {} Err(err) => { - warn!(hook = %hook.id, %err, "Failed to compute hook env key, skipping"); + warn!(hook = %hook.id, %err, "Failed to compute hook env request, skipping"); } } } @@ -419,20 +422,20 @@ fn hook_env_keys_from_config( } } - keys + requests } -fn mark_tool_versions_from_install_info( +fn mark_tool_versions_from_installed_env( store: &Store, - info: &InstallInfo, + env: &InstalledHookEnv, used_tool_versions: &mut FxHashMap>, ) { - // NOTE: `InstallInfo.toolchain` is typically the executable path (e.g. + // NOTE: `InstalledHookEnv.toolchain` is typically the executable path (e.g. // tools/go/1.24.0/bin/go). We keep the first path component under the tool bucket. // If we can't recognize it, we do nothing (and GC will keep all versions). - for bucket in info.language.tool_buckets() { + for bucket in env.language.tool_buckets() { let bucket_root = store.tools_path(*bucket); - if let Some(version) = tool_version_dir_name(&bucket_root, &info.toolchain) { + if let Some(version) = tool_version_dir_name(&bucket_root, &env.toolchain) { used_tool_versions .entry(*bucket) .or_default() @@ -710,13 +713,13 @@ fn sweep_stale_patch_files(root: &Path, dry_run: bool, collect_names: bool) -> R fn label_for_entry( kind: RemovalKind, repo_marker: Option<&RepoMarker>, - hook_marker: Option<&InstallInfo>, + hook_marker: Option<&InstalledHookEnv>, ) -> Option { match kind { RemovalKind::Repos => repo_marker.map(|repo| format!("{}@{}", repo.repo, repo.rev)), - RemovalKind::HookEnvs => hook_marker.map(|info| { + RemovalKind::HookEnvs => hook_marker.map(|env| { // Keep this short; more info goes in detail lines. - format!("{} env", info.language.as_ref()) + format!("{} env", env.language.as_ref()) }), _ => None, } @@ -725,14 +728,14 @@ fn label_for_entry( fn detail_lines_for_entry( kind: RemovalKind, _repo_marker: Option<&RepoMarker>, - hook_marker: Option<&InstallInfo>, + hook_marker: Option<&InstalledHookEnv>, ) -> Vec { const MAX_VALUE_CHARS: usize = 140; match kind { RemovalKind::Repos => vec![], RemovalKind::HookEnvs => { - let Some(info) = hook_marker else { + let Some(env) = hook_marker else { return Vec::new(); }; @@ -740,21 +743,32 @@ fn detail_lines_for_entry( lines.push(format!( "{}: {} ({})", "language".dimmed().bold(), - info.language.as_ref(), - info.language_version + env.language.as_ref(), + env.language_version )); - let (repo_dep, deps) = split_repo_dependency(&info.dependencies); - if let Some(repo_dep) = repo_dep { - lines.push(format!( - "{}: {}", - "repo".dimmed().bold(), - truncate_end(&repo_dep, MAX_VALUE_CHARS) - )); - } - if !deps.is_empty() { - let deps_str = format_dependency_list(&deps, 6, MAX_VALUE_CHARS); - lines.push(format!("{}: {deps_str}", "deps".dimmed().bold())); + match &env.identity { + HookEnvIdentity::Dependencies(identity) => { + if let Some(repo) = &identity.remote_repo { + lines.push(format!( + "{}: {}", + "repo".dimmed().bold(), + truncate_end(repo, MAX_VALUE_CHARS) + )); + } + if !identity.dependencies.is_empty() { + let deps = identity.dependencies.iter().cloned().collect::>(); + let deps_str = format_dependency_list(&deps, 6, MAX_VALUE_CHARS); + lines.push(format!("{}: {deps_str}", "deps".dimmed().bold())); + } + } + HookEnvIdentity::PythonUv(identity) => { + lines.push(format!( + "{}: {}", + "python_uv".dimmed().bold(), + truncate_end(&identity.fingerprint, MAX_VALUE_CHARS) + )); + } } lines } @@ -776,7 +790,7 @@ fn read_repo_marker(root: &Path) -> Option { serde_json::from_str(&content).ok() } -fn read_hook_marker(root: &Path) -> Option { +fn read_hook_marker(root: &Path) -> Option { let content = fs_err::read_to_string(root.join(HOOK_MARKER)).ok()?; serde_json::from_str(&content).ok() } @@ -793,30 +807,6 @@ fn truncate_end(s: &str, max_chars: usize) -> String { out } -fn split_repo_dependency(deps: &FxHashSet) -> (Option, Vec) { - // Best-effort: the remote repo dependency is typically `repo@rev`. - // Prefer URL-like values to avoid accidentally treating PEP508 deps as repo identifiers. - let mut repo_dep: Option = None; - let mut rest = Vec::new(); - - for dep in deps { - if repo_dep.is_none() - && dep.contains('@') - && (dep.contains("://") - || dep.starts_with('/') - || dep.starts_with("..") - || dep.starts_with('.')) - { - repo_dep = Some(dep.clone()); - } else { - rest.push(dep.clone()); - } - } - - rest.sort_unstable(); - (repo_dep, rest) -} - fn format_dependency_list(deps: &[String], max_items: usize, max_chars: usize) -> String { if deps.is_empty() { return String::new(); @@ -854,33 +844,6 @@ mod tests { assert_eq!(truncate_end("ééé", 2), "é…"); } - #[test] - fn split_repo_dependency_prefers_url_like_repo_at_rev() { - let mut deps = FxHashSet::default(); - deps.insert("requests==2.32.0".to_string()); - deps.insert("black==24.1.0".to_string()); - deps.insert("https://github.com/pre-commit/pre-commit-hooks@v1.0.0".to_string()); - - let (repo_dep, rest) = split_repo_dependency(&deps); - - assert_eq!( - repo_dep.as_deref(), - Some("https://github.com/pre-commit/pre-commit-hooks@v1.0.0") - ); - assert_eq!(rest, vec!["black==24.1.0", "requests==2.32.0"]); - } - - #[test] - fn split_repo_dependency_returns_none_when_no_repo_like_dep() { - let mut deps = FxHashSet::default(); - deps.insert("requests==2.32.0".to_string()); - deps.insert("black==24.1.0".to_string()); - - let (repo_dep, rest) = split_repo_dependency(&deps); - assert!(repo_dep.is_none()); - assert_eq!(rest, vec!["black==24.1.0", "requests==2.32.0"]); - } - #[test] fn format_dependency_list_includes_more_suffix() { let deps = vec!["a".to_string(), "b".to_string(), "c".to_string()]; diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index c38c15f37..62e06a904 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -26,7 +26,7 @@ use crate::cli::{ExitStatus, RunExtraArgs}; use crate::config::{Language, PassFilenames, Stage}; use crate::fs::CWD; use crate::git::GIT_ROOT; -use crate::hook::{Hook, InstallInfo, InstalledHook, Repo}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv, Repo}; use crate::printer::Printer; use crate::run::{CONCURRENCY, USE_COLOR}; use crate::store::Store; @@ -266,37 +266,33 @@ fn set_env_vars(from_ref: Option<&String>, to_ref: Option<&String>, args: &RunEx } #[derive(Debug)] -struct LazyInstallInfo { - info: Arc, +struct LazyInstalledHookEnv { + env: Arc, health: mea::once::OnceCell, } -impl LazyInstallInfo { - fn new(info: Arc) -> Self { +impl LazyInstalledHookEnv { + fn new(env: Arc) -> Self { Self { - info, + env, health: mea::once::OnceCell::new(), } } - fn matches(&self, hook: &Hook) -> bool { - self.info.matches(hook) - } - - fn info(&self) -> Arc { - self.info.clone() + fn env(&self) -> Arc { + self.env.clone() } async fn ensure_healthy(&self) -> bool { - let info = self.info.clone(); + let env = self.env.clone(); *self .health - .get_or_init(async move || match info.check_health().await { + .get_or_init(async move || match env.check_health().await { Ok(()) => true, Err(err) => { warn!( %err, - path = %info.env_path.display(), + path = %env.env_path.display(), "Skipping unhealthy installed hook" ); false @@ -319,7 +315,7 @@ pub async fn install_hooks( .installed_hooks() .await .into_iter() - .map(LazyInstallInfo::new) + .map(LazyInstalledHookEnv::new) .collect::>(), ); @@ -362,34 +358,34 @@ pub async fn install_hooks( continue; } - let mut matched_info = None; + let mut matched_env = None; for env in &newly_installed { - if let InstalledHook::Installed { info, .. } = env { - if info.matches(&hook) { - matched_info = Some(info.clone()); + if let InstalledHook::Installed { env, .. } = env { + if env.satisfies(hook.env_request()) { + matched_env = Some(env.clone()); break; } } } - if matched_info.is_none() { + if matched_env.is_none() { for env in store_hooks.iter() { - if env.matches(&hook) { + if env.env.satisfies(hook.env_request()) { if env.ensure_healthy().await { - matched_info = Some(env.info()); + matched_env = Some(env.env()); break; } } } } - if let Some(info) = matched_info { + if let Some(env) = matched_env { debug!( "Found installed environment for hook `{hook}` at `{}`", - info.env_path.display() + env.env_path.display() ); - hook_envs.push(InstalledHook::Installed { hook, info }); + hook_envs.push(InstalledHook::Installed { hook, env }); continue; } @@ -407,8 +403,8 @@ pub async fn install_hooks( .with_context(|| format!("Failed to mark hook `{hook}` as installed"))?; match &installed_hook { - InstalledHook::Installed { info, .. } => { - debug!("Installed hook `{hook}` in `{}`", info.env_path.display()); + InstalledHook::Installed { env, .. } => { + debug!("Installed hook `{hook}` in `{}`", env.env_path.display()); } InstalledHook::NoNeedInstall { .. } => { debug!("Hook `{hook}` does not need installation"); @@ -439,7 +435,7 @@ pub async fn install_hooks( Ok(result) } -/// Partition hooks into groups where hooks in the same group have same dependencies. +/// Partition hooks into groups that can contend for the same managed environment. /// Hooks in different groups can be installed in parallel. fn partition_hooks(hooks: &[Arc]) -> Vec>> { if hooks.is_empty() { @@ -462,8 +458,7 @@ fn partition_hooks(hooks: &[Arc]) -> Vec>> { current_group.push(index); for i in 0..hooks.len() { - if !visited[i] && hooks[index].env_key_dependencies() == hooks[i].env_key_dependencies() - { + if !visited[i] && hooks[index].shares_env_identity_with(&hooks[i]) { dfs(i, hooks, visited, current_group); } } diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index 7c69eb946..fe6f83128 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -549,7 +549,7 @@ pub(crate) enum Shell { Cmd, } -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Deserialize)] #[serde(rename_all = "snake_case")] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] pub(crate) enum PythonUvLockMode { diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index 7b78e304e..e8eedd01d 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -1,3 +1,53 @@ +//! Hook model and conversion pipeline. +//! +//! This module contains the runtime hook types. Configuration parsing lives in +//! `config`, and language-specific install/run behavior lives in `languages`; +//! this module is the boundary that turns parsed configuration into executable +//! hooks and records the managed environments those hooks use. +//! +//! The main concepts are: +//! +//! - `HookSpec`: a normalized hook definition loaded from one repo source. It +//! is still configuration-shaped: remote manifest hooks, local hooks, meta +//! hooks, and builtin hooks are all converted into this common form before +//! project defaults and runtime metadata are applied. +//! - `HookBuilder`: combines a `HookSpec` with its `Project`, `Repo`, project +//! defaults, entry metadata, language-version request, and environment spec. +//! It is a short-lived builder used only while resolving configuration. +//! - `Hook`: the resolved runtime hook. It owns the data needed to match files, +//! install dependencies, and run the entry. A `Hook` is not proof that an +//! environment exists; it only carries the request and identity needed to find +//! or create one. +//! - `HookEnvSpec`: the resolved install input for a hook environment. It keeps +//! language-specific install settings, such as `python_uv` project options, +//! next to the exact environment identity derived from those settings. +//! - `HookEnvIdentity`: the exact, persisted identity of environment contents. +//! It is precise only once the language is fixed; language-version matching is +//! checked separately against the installed environment's actual version. +//! - `InstalledHookEnv`: metadata for an environment that was actually created +//! and persisted in `.prek-hook.json`, including the concrete toolchain, +//! language version, env path, and `HookEnvIdentity`. +//! - `InstalledHook`: a runtime `Hook` paired with its `InstalledHookEnv`, or a +//! hook that does not need managed installation. +//! +//! The normal lifecycle is: +//! +//! 1. Config/manifest hook -> `HookSpec`. +//! 2. `HookBuilder` applies defaults, extracts metadata, resolves +//! `HookEnvSpec`, and produces a `Hook`. +//! 3. Installation matches `Hook::env_request()` against stored +//! `InstalledHookEnv` records, checking both exact identity and language +//! version satisfaction. +//! 4. If no stored environment satisfies the request, the language installer +//! creates a new environment, fills an `InstalledHookEnv`, persists it, and +//! returns `InstalledHook::Installed`. +//! 5. Running hooks only sees `InstalledHook`, so language implementations can +//! rely on installation metadata when a managed environment is required. +//! +//! Keep in mind that `HookEnvIdentity` answers "same environment contents?", +//! while `HookEnvRequest` answers "can this installed environment satisfy this +//! hook?". The latter includes the language and language-version request. + use std::borrow::Cow; use std::fmt::{Display, Formatter}; use std::ops::Deref; @@ -18,7 +68,9 @@ use crate::config::{ MetaHook, PassFilenames, RemoteHook, Stages, read_manifest, }; use crate::hook_entry::HookEntry; -use crate::hook_env::{HookEnvKey, HookEnvSpec, PythonUvEnv}; +use crate::hook_env::{ + HookEnvIdentity, HookEnvIdentityRef, HookEnvRequestRef, HookEnvSpec, PythonUvEnv, +}; use crate::languages::version::LanguageRequest; use crate::languages::{HookMetadata, ShellSupport, extract_metadata}; use crate::store::Store; @@ -321,15 +373,14 @@ impl HookBuilder { pub(crate) async fn build(mut self) -> Result { self.hook_spec.apply_project_defaults(self.project.config()); - let remote_repo_dependency = self - .hook_spec - .language - .supports_install_env() - .then(|| match self.repo.as_ref() { + let remote_repo_dependency = if self.hook_spec.language.supports_install_env() { + match self.repo.as_ref() { Repo::Remote { .. } => Some(self.repo.to_string()), Repo::Local { .. } | Repo::Meta { .. } | Repo::Builtin { .. } => None, - }) - .flatten(); + } + } else { + None + }; self.check()?; @@ -515,12 +566,30 @@ impl Hook { matches!(&*self.repo, Repo::Remote { .. }) } - /// Dependencies used to identify whether an existing hook environment can be reused. + /// Exact identity used to decide whether an installed environment can be reused. + pub(crate) fn env_identity(&self) -> HookEnvIdentityRef<'_> { + self.env_spec.identity() + } + + /// Environment request used to find a reusable installed environment. /// - /// For remote hooks, the repo URL is included to avoid reusing an environment created - /// from a different remote repository. - pub(crate) fn env_key_dependencies(&self) -> &FxHashSet { - self.env_spec.dependencies() + /// This includes a version request, so it is intentionally broader than the + /// exact persisted environment identity. + pub(crate) fn env_request(&self) -> HookEnvRequestRef<'_> { + HookEnvRequestRef { + language: self.language, + identity: self.env_identity(), + language_request: &self.language_request, + } + } + + /// Returns whether two hooks may try to reuse the same managed environment. + /// + /// The language version request is deliberately not part of this grouping: + /// it is a selector checked against the installed environment's actual + /// version, so two different requests may still be satisfied by one install. + pub(crate) fn shares_env_identity_with(&self, other: &Self) -> bool { + self.language == other.language && self.env_identity() == other.env_identity() } pub(crate) fn python_uv_env(&self) -> Option<&PythonUvEnv> { @@ -546,7 +615,7 @@ impl Hook { pub(crate) enum InstalledHook { Installed { hook: Arc, - info: Arc, + env: Arc, }, NoNeedInstall(Arc), } @@ -575,7 +644,7 @@ impl InstalledHook { /// Get the path to the environment where the hook is installed. pub(crate) fn env_path(&self) -> Option<&Path> { match self { - InstalledHook::Installed { info, .. } => Some(&info.env_path), + InstalledHook::Installed { env, .. } => Some(&env.env_path), InstalledHook::NoNeedInstall(_) => None, } } @@ -583,41 +652,41 @@ impl InstalledHook { /// Get the directory the toolchain is installed in. pub(crate) fn toolchain_dir(&self) -> Option<&Path> { match self { - InstalledHook::Installed { info, .. } => info.toolchain.parent(), + InstalledHook::Installed { env, .. } => env.toolchain.parent(), InstalledHook::NoNeedInstall(_) => None, } } - /// Get the install info of the hook if it is installed. - pub(crate) fn install_info(&self) -> Option<&InstallInfo> { + /// Get the installed hook environment metadata if this hook is installed. + pub(crate) fn installed_env(&self) -> Option<&InstalledHookEnv> { match self { - InstalledHook::Installed { info, .. } => Some(info), + InstalledHook::Installed { env, .. } => Some(env), InstalledHook::NoNeedInstall(_) => None, } } /// Mark the hook as installed in the environment. pub(crate) async fn mark_as_installed(&self, _store: &Store) -> Result<()> { - let Some(info) = self.install_info() else { + let Some(env) = self.installed_env() else { return Ok(()); }; - let content = - serde_json::to_string_pretty(info).context("Failed to serialize install info")?; + let content = serde_json::to_string_pretty(env) + .context("Failed to serialize installed hook environment metadata")?; - fs_err::tokio::write(info.env_path.join(HOOK_MARKER), content) + fs_err::tokio::write(env.env_path.join(HOOK_MARKER), content) .await - .context("Failed to write install info")?; + .context("Failed to write installed hook environment metadata")?; Ok(()) } } #[derive(Debug, Deserialize, Serialize)] -pub(crate) struct InstallInfo { +pub(crate) struct InstalledHookEnv { pub(crate) language: Language, pub(crate) language_version: semver::Version, - pub(crate) dependencies: FxHashSet, + pub(crate) identity: HookEnvIdentity, pub(crate) env_path: PathBuf, pub(crate) toolchain: PathBuf, extra: FxHashMap, @@ -625,12 +694,12 @@ pub(crate) struct InstallInfo { temp_dir: Option, } -impl Clone for InstallInfo { +impl Clone for InstalledHookEnv { fn clone(&self) -> Self { Self { language: self.language, language_version: self.language_version.clone(), - dependencies: self.dependencies.clone(), + identity: self.identity.clone(), env_path: self.env_path.clone(), toolchain: self.toolchain.clone(), extra: self.extra.clone(), @@ -639,10 +708,10 @@ impl Clone for InstallInfo { } } -impl InstallInfo { +impl InstalledHookEnv { pub(crate) fn new( language: Language, - dependencies: FxHashSet, + identity: HookEnvIdentity, hooks_dir: &Path, ) -> Result { let env_path = tempfile::Builder::new() @@ -652,7 +721,7 @@ impl InstallInfo { Ok(Self { language, - dependencies, + identity, env_path: env_path.path().to_path_buf(), language_version: semver::Version::new(0, 0, 0), toolchain: PathBuf::new(), @@ -661,17 +730,17 @@ impl InstallInfo { }) } - pub(crate) fn persist_env_path(&mut self) { + pub(crate) fn persist(&mut self) { if let Some(temp_dir) = self.temp_dir.take() { self.env_path = temp_dir.keep(); } } - pub(crate) async fn from_env_path(path: &Path) -> Result { + pub(crate) async fn from_path(path: &Path) -> Result { let content = fs_err::tokio::read_to_string(path.join(HOOK_MARKER)).await?; - let info: InstallInfo = serde_json::from_str(&content)?; + let env: InstalledHookEnv = serde_json::from_str(&content)?; - Ok(info) + Ok(env) } pub(crate) async fn check_health(&self) -> Result<()> { @@ -697,26 +766,16 @@ impl InstallInfo { self.extra.get(key) } - /// Returns whether this installed environment can be reused for the hook. + /// Returns whether this installed environment satisfies the normalized hook + /// environment request. /// - /// Used when a fully resolved hook is available, such as install reuse - /// checks before running a hook. - pub(crate) fn matches(&self, hook: &Hook) -> bool { - hook.language.supports_install_env() - && self.language == hook.language - && self.dependencies == *hook.env_key_dependencies() - && hook.language_request.satisfied_by(self) - } - - /// Returns whether this installed environment matches the normalized hook - /// environment key. - /// - /// Used when only the normalized key is available, such as cache GC scanning + /// Used when only the normalized request is available, such as cache GC scanning /// configured hooks without building full hook instances. - pub(crate) fn matches_env_key(&self, key: &HookEnvKey) -> bool { - self.language == key.language - && self.dependencies == key.dependencies - && key.language_request.satisfied_by(self) + pub(crate) fn satisfies(&self, request: HookEnvRequestRef<'_>) -> bool { + request.language.supports_install_env() + && self.language == request.language + && HookEnvIdentityRef::from(&self.identity) == request.identity + && request.language_request.satisfied_by(self) } } @@ -736,6 +795,7 @@ mod tests { RemoteHook, Shell, Stage, Stages, }; use crate::hook::HookSpec; + use crate::hook_env::{HookEnvIdentity, HookEnvIdentityRef}; use crate::languages::version::LanguageRequest; use crate::workspace::Project; @@ -841,7 +901,10 @@ mod tests { hooks: [], }, env_spec: Dependencies( - {}, + DependencyEnvIdentity { + dependencies: {}, + remote_repo: None, + }, ), idx: 7, id: "test-hook", @@ -1116,7 +1179,7 @@ mod tests { } #[tokio::test] - async fn hook_builder_python_uv_env_key_tracks_lockfile_content() -> Result<()> { + async fn hook_builder_python_uv_identity_tracks_lockfile_content() -> Result<()> { let temp = tempfile::tempdir()?; let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); fs_err::write(&config_path, "repos: []\n")?; @@ -1162,14 +1225,71 @@ mod tests { assert_eq!(uv.extras, ["cli", "typed"]); assert!(!uv.install_project); assert_eq!(uv.lock_mode, PythonUvLockMode::Frozen); - let first_dependencies = hook.env_key_dependencies().clone(); + let first_identity = HookEnvIdentity::from(hook.env_identity()); fs_err::write(temp.path().join("uv.lock"), "version = 2\n")?; let hook = HookBuilder::new(project, repo, hook_spec, 0) .build() .await?; - assert_ne!(first_dependencies, *hook.env_key_dependencies()); + assert_ne!( + HookEnvIdentityRef::from(&first_identity), + hook.env_identity() + ); + + Ok(()) + } + + #[tokio::test] + async fn hook_builder_python_uv_identity_preserves_group_boundaries() -> Result<()> { + let temp = tempfile::tempdir()?; + let config_path = temp.path().join(PRE_COMMIT_CONFIG_YAML); + fs_err::write(&config_path, "repos: []\n")?; + fs_err::write( + temp.path().join("pyproject.toml"), + "[project]\nname = \"example\"\nversion = \"0.1.0\"\n", + )?; + fs_err::write(temp.path().join("uv.lock"), "version = 1\n")?; + + let project = Arc::new(Project::from_config_file( + Cow::Borrowed(&config_path), + None, + )?); + let repo = Arc::new(Repo::Local { hooks: vec![] }); + + let hook_spec = |dependency_groups| HookSpec { + id: "ty".to_string(), + name: "ty".to_string(), + entry: "ty check .".to_string(), + language: Language::PythonUv, + priority: None, + options: HookOptions { + uv: Some(PythonUvOptions { + dependency_groups: Some(dependency_groups), + ..Default::default() + }), + ..Default::default() + }, + }; + + let joined = HookBuilder::new( + project.clone(), + repo.clone(), + hook_spec(vec!["a,b".to_string()]), + 0, + ) + .build() + .await?; + let split = HookBuilder::new( + project, + repo, + hook_spec(vec!["a".to_string(), "b".to_string()]), + 0, + ) + .build() + .await?; + + assert_ne!(joined.env_identity(), split.env_identity()); Ok(()) } @@ -1256,7 +1376,7 @@ mod tests { } #[tokio::test] - async fn hook_builder_python_pep723_dependencies_are_env_key_dependencies() -> Result<()> { + async fn hook_builder_python_pep723_dependencies_are_env_identity_dependencies() -> Result<()> { let (temp, project) = setup_python_hook_test()?; let repo_path = temp.path().join("remote-repo"); fs_err::write(repo_path.join("hook.py"), PEP723_SCRIPT_WITH_DEPENDENCIES)?; @@ -1264,7 +1384,10 @@ mod tests { let hook = build_python_hook(project, repo_path, None).await?; assert!(hook.additional_dependencies.contains("pyecho-cli")); - assert!(hook.env_key_dependencies().contains("pyecho-cli")); + let HookEnvIdentityRef::Dependencies(identity) = hook.env_identity() else { + panic!("expected dependency identity"); + }; + assert!(identity.dependencies.contains("pyecho-cli")); Ok(()) } diff --git a/crates/prek/src/hook_env.rs b/crates/prek/src/hook_env.rs index 37d9fdf0b..1ea4391fd 100644 --- a/crates/prek/src/hook_env.rs +++ b/crates/prek/src/hook_env.rs @@ -1,24 +1,28 @@ -use std::hash::Hasher; +use std::collections::BTreeSet; +use std::hash::{Hash, Hasher}; use std::io; use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; -use rustc_hash::{FxBuildHasher, FxHashSet}; +use rustc_hash::FxHashSet; +use serde::{Deserialize, Serialize}; use crate::config::{Config, Language, PythonUvLockMode, PythonUvOptions}; use crate::hook::HookSpec; use crate::languages::version::LanguageRequest; -/// Resolved description of the environment a hook installs into and reuses. +/// Resolved installer inputs for a hook environment. /// -/// A hook environment is identified by the language runtime request plus this -/// language-specific dependency set. The spec is the single source of truth for -/// environment reuse, cache GC, and installer inputs; it does not perform -/// installation itself. +/// This keeps language-specific installation data, such as `python_uv` project +/// settings, next to the exact identity used for environment reuse. It does not +/// perform installation itself. #[derive(Debug, Clone)] pub(crate) enum HookEnvSpec { - Dependencies(FxHashSet), - PythonUv(PythonUvEnv), + Dependencies(DependencyEnvIdentity), + PythonUv { + env: PythonUvEnv, + identity: PythonUvEnvIdentity, + }, } impl HookEnvSpec { @@ -38,7 +42,8 @@ impl HookEnvSpec { let default_uv = PythonUvOptions::default(); let uv = uv.unwrap_or(&default_uv); - Ok(Self::PythonUv(PythonUvEnv::resolve(uv, project_root)?)) + let (env, identity) = PythonUvEnv::resolve(uv, project_root)?; + Ok(Self::PythonUv { env, identity }) } else { if uv.is_some() { anyhow::bail!( @@ -48,37 +53,130 @@ impl HookEnvSpec { validate_additional_dependencies(language, additional_dependencies)?; - Ok(Self::Dependencies(env_key_dependencies( + Ok(Self::Dependencies(DependencyEnvIdentity::new( additional_dependencies, remote_repo_dependency, ))) } } - pub(crate) fn dependencies(&self) -> &FxHashSet { + pub(crate) fn identity(&self) -> HookEnvIdentityRef<'_> { match self { - Self::Dependencies(dependencies) => dependencies, - Self::PythonUv(env) => env.dependencies(), + Self::Dependencies(identity) => HookEnvIdentityRef::Dependencies(identity), + Self::PythonUv { identity, .. } => HookEnvIdentityRef::PythonUv(identity), } } pub(crate) fn python_uv(&self) -> Option<&PythonUvEnv> { match self { Self::Dependencies(_) => None, - Self::PythonUv(env) => Some(env), + Self::PythonUv { env, .. } => Some(env), } } } -#[derive(Debug, Clone)] -pub(crate) struct HookEnvKey { +/// Exact, persisted identity of an installed hook environment. +/// +/// Unlike [`HookEnvRequest`], this is not a selector: two identities are either +/// equal or they describe different environment contents. It intentionally +/// separates install-time dependencies from other language-specific fingerprints +/// such as `python_uv` lockfile state. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(tag = "kind", content = "data", rename_all = "snake_case")] +pub(crate) enum HookEnvIdentity { + Dependencies(DependencyEnvIdentity), + PythonUv(PythonUvEnvIdentity), +} + +impl HookEnvIdentity { + #[cfg(test)] + pub(crate) fn empty_dependencies() -> Self { + Self::Dependencies(DependencyEnvIdentity::new(&FxHashSet::default(), None)) + } +} + +/// Borrowed view of an environment identity. +/// +/// Use this for matching and grouping. Convert to [`HookEnvIdentity`] only when +/// the identity must outlive the resolved hook, such as when writing install +/// metadata or collecting cache-GC requests. +#[derive(Debug, Clone, Copy, Hash)] +pub(crate) enum HookEnvIdentityRef<'a> { + Dependencies(&'a DependencyEnvIdentity), + PythonUv(&'a PythonUvEnvIdentity), +} + +impl From> for HookEnvIdentity { + fn from(identity: HookEnvIdentityRef<'_>) -> Self { + match identity { + HookEnvIdentityRef::Dependencies(identity) => Self::Dependencies(identity.clone()), + HookEnvIdentityRef::PythonUv(identity) => Self::PythonUv(identity.clone()), + } + } +} + +impl<'a> From<&'a HookEnvIdentity> for HookEnvIdentityRef<'a> { + fn from(identity: &'a HookEnvIdentity) -> Self { + match identity { + HookEnvIdentity::Dependencies(identity) => Self::Dependencies(identity), + HookEnvIdentity::PythonUv(identity) => Self::PythonUv(identity), + } + } +} + +impl Eq for HookEnvIdentityRef<'_> {} + +impl<'a, 'b> PartialEq> for HookEnvIdentityRef<'a> { + fn eq(&self, other: &HookEnvIdentityRef<'b>) -> bool { + match (self, other) { + (Self::Dependencies(left), HookEnvIdentityRef::Dependencies(right)) => left == right, + (Self::PythonUv(left), HookEnvIdentityRef::PythonUv(right)) => left == right, + _ => false, + } + } +} + +/// Identity for languages whose environment is determined by dependency specs. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct DependencyEnvIdentity { + /// User-provided dependency specs that affect the managed hook environment. + pub(crate) dependencies: BTreeSet, + /// Remote hook repository identity, included because the repository package + /// itself is installed into many language environments. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) remote_repo: Option, +} + +impl DependencyEnvIdentity { + fn new(additional_dependencies: &FxHashSet, remote_repo: Option<&str>) -> Self { + Self { + dependencies: additional_dependencies.iter().cloned().collect(), + remote_repo: remote_repo.map(str::to_string), + } + } +} + +/// Identity for `language: python_uv` environments. +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub(crate) struct PythonUvEnvIdentity { + /// Versioned digest of uv project inputs that affect `uv sync`. + pub(crate) fingerprint: String, +} + +/// Environment lookup request derived from hook configuration. +/// +/// This is not an exact key: `language_request` may be a range or `default`, +/// so an installed environment satisfies the request only after checking its +/// actual language version. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct HookEnvRequest { pub(crate) language: Language, - pub(crate) dependencies: FxHashSet, + pub(crate) identity: HookEnvIdentity, pub(crate) language_request: LanguageRequest, } -impl HookEnvKey { - /// Compute the key used to match an installed hook environment. +impl HookEnvRequest { + /// Compute the environment request used to find reusable installed envs. /// /// Returns `Ok(None)` if this hook does not install an environment. pub(crate) fn from_hook_spec( @@ -117,16 +215,37 @@ impl HookEnvKey { project_root, remote_repo_dependency, )?; - let dependencies = env_spec.dependencies().clone(); + let identity = env_spec.identity().into(); Ok(Some(Self { language, - dependencies, + identity, language_request, })) } } +/// Borrowed environment lookup request. +/// +/// This avoids cloning exact identity data when a resolved hook is only being +/// compared against installed environments. +#[derive(Debug, Clone, Copy)] +pub(crate) struct HookEnvRequestRef<'a> { + pub(crate) language: Language, + pub(crate) identity: HookEnvIdentityRef<'a>, + pub(crate) language_request: &'a LanguageRequest, +} + +impl<'a> From<&'a HookEnvRequest> for HookEnvRequestRef<'a> { + fn from(request: &'a HookEnvRequest) -> Self { + Self { + language: request.language, + identity: HookEnvIdentityRef::from(&request.identity), + language_request: &request.language_request, + } + } +} + #[derive(Debug, Clone)] pub(crate) struct PythonUvEnv { pub(crate) project: PathBuf, @@ -135,13 +254,15 @@ pub(crate) struct PythonUvEnv { pub(crate) extras: Vec, pub(crate) install_project: bool, pub(crate) lock_mode: PythonUvLockMode, - dependencies: FxHashSet, } impl PythonUvEnv { const SCHEMA_VERSION: &'static str = "python_uv_v1"; - pub(crate) fn resolve(options: &PythonUvOptions, project_root: &Path) -> Result { + pub(crate) fn resolve( + options: &PythonUvOptions, + project_root: &Path, + ) -> Result<(Self, PythonUvEnvIdentity)> { let project = options.project.as_deref().map_or_else( || project_root.to_path_buf(), |path| resolve_path(project_root, path), @@ -195,7 +316,7 @@ impl PythonUvEnv { let install_project = options.install_project.unwrap_or(true); let lock_mode = options.lock_mode.unwrap_or_default(); - let dependencies = python_uv_env_key_dependencies( + let identity = python_uv_env_identity( &project, &lockfile, &pyproject, @@ -205,39 +326,18 @@ impl PythonUvEnv { lock_mode, )?; - Ok(Self { - project, - lockfile, - dependency_groups, - extras, - install_project, - lock_mode, - dependencies, - }) - } - - fn dependencies(&self) -> &FxHashSet { - &self.dependencies - } -} - -/// Builds the dependency set used to identify a hook environment. -/// -/// For remote hooks, `remote_repo_dependency` is included so environments from different -/// repositories are not reused accidentally. -fn env_key_dependencies( - additional_dependencies: &FxHashSet, - remote_repo_dependency: Option<&str>, -) -> FxHashSet { - let mut deps = FxHashSet::with_capacity_and_hasher( - additional_dependencies.len() + usize::from(remote_repo_dependency.is_some()), - FxBuildHasher, - ); - deps.extend(additional_dependencies.iter().cloned()); - if let Some(dep) = remote_repo_dependency { - deps.insert(dep.to_string()); + Ok(( + Self { + project, + lockfile, + dependency_groups, + extras, + install_project, + lock_mode, + }, + identity, + )) } - deps } fn validate_additional_dependencies( @@ -267,7 +367,7 @@ fn validate_additional_dependencies( Ok(()) } -fn python_uv_env_key_dependencies( +fn python_uv_env_identity( project: &Path, lockfile: &Path, pyproject: &Path, @@ -275,26 +375,48 @@ fn python_uv_env_key_dependencies( extras: &[String], install_project: bool, lock_mode: PythonUvLockMode, -) -> Result> { - let mut dependencies = FxHashSet::default(); - dependencies.insert(format!("schema={}", PythonUvEnv::SCHEMA_VERSION)); - dependencies.insert(format!("project={}", project.display())); - dependencies.insert(format!("lockfile={}", lockfile.display())); - dependencies.insert(format!("lockfile_hash={}", hash_file(lockfile)?)); - dependencies.insert(format!("pyproject_hash={}", hash_file(pyproject)?)); - dependencies.insert(format!("dependency_groups={}", dependency_groups.join(","))); - dependencies.insert(format!("extras={}", extras.join(","))); - dependencies.insert(format!("install_project={install_project}")); - dependencies.insert(format!("lock_mode={lock_mode:?}")); - - push_optional_file_hash(&mut dependencies, "uv_toml_hash", &project.join("uv.toml"))?; - push_optional_file_hash( - &mut dependencies, - "python_version_file_hash", - &project.join(".python-version"), - )?; - - Ok(dependencies) +) -> Result { + let key = PythonUvEnvCacheKey { + schema: PythonUvEnv::SCHEMA_VERSION, + source: PythonUvEnvSourceCacheKey::UvLock { + project, + lockfile, + lockfile_hash: hash_file_contents(lockfile)?, + pyproject_hash: hash_file_contents(pyproject)?, + uv_toml_hash: hash_optional_file(&project.join("uv.toml"))?, + python_version_file_hash: hash_optional_file(&project.join(".python-version"))?, + }, + dependency_groups, + extras, + install_project, + lock_mode, + }; + + Ok(PythonUvEnvIdentity { + fingerprint: format!("{}:{}", PythonUvEnv::SCHEMA_VERSION, hash_digest(&key)), + }) +} + +#[derive(Hash)] +struct PythonUvEnvCacheKey<'a> { + schema: &'static str, + source: PythonUvEnvSourceCacheKey<'a>, + dependency_groups: &'a [String], + extras: &'a [String], + install_project: bool, + lock_mode: PythonUvLockMode, +} + +#[derive(Hash)] +enum PythonUvEnvSourceCacheKey<'a> { + UvLock { + project: &'a Path, + lockfile: &'a Path, + lockfile_hash: u64, + pyproject_hash: u64, + uv_toml_hash: Option, + python_version_file_hash: Option, + }, } fn format_dependencies(dependencies: &FxHashSet) -> String { @@ -316,25 +438,23 @@ fn canonicalize_string_list(values: &mut Vec) { values.dedup(); } -fn push_optional_file_hash( - dependencies: &mut FxHashSet, - key: &str, - path: &Path, -) -> Result<()> { - match hash_file(path) { - Ok(hash) => { - dependencies.insert(format!("{key}={hash}")); - } - Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} - Err(err) => return Err(err.into()), +fn hash_optional_file(path: &Path) -> Result> { + match hash_file_contents(path) { + Ok(hash) => Ok(Some(hash)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => Err(err.into()), } - - Ok(()) } -fn hash_file(path: &Path) -> io::Result { +fn hash_file_contents(path: &Path) -> io::Result { let mut file = fs_err::File::open(path)?; let mut hasher = seahash::SeaHasher::new(); io::copy(&mut file, &mut hasher)?; - Ok(format!("{:016x}", hasher.finish())) + Ok(hasher.finish()) +} + +fn hash_digest(value: &T) -> String { + let mut hasher = seahash::SeaHasher::new(); + value.hash(&mut hasher); + hex::encode(hasher.finish().to_le_bytes()) } diff --git a/crates/prek/src/languages/bun/bun.rs b/crates/prek/src/languages/bun/bun.rs index a850222b3..d16fa2331 100644 --- a/crates/prek/src/languages/bun/bun.rs +++ b/crates/prek/src/languages/bun/bun.rs @@ -9,7 +9,7 @@ use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; -use crate::hook::{Hook, InstallInfo}; +use crate::hook::{Hook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::bun::BunRequest; use crate::languages::bun::installer::{BunInstaller, BunResult, bin_dir, lib_dir}; @@ -51,19 +51,19 @@ impl LanguageImpl for Bun { .await .context("Failed to install bun")?; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - info.with_toolchain(bun.bun().to_path_buf()); + env.with_toolchain(bun.bun().to_path_buf()); // BunVersion implements Deref, so we clone the inner version - info.with_language_version((**bun.version()).clone()); + env.with_language_version((**bun.version()).clone()); // 2. Create env - let bin_dir = bin_dir(&info.env_path); - let lib_dir = lib_dir(&info.env_path); + let bin_dir = bin_dir(&env.env_path); + let lib_dir = lib_dir(&env.env_path); fs_err::tokio::create_dir_all(&bin_dir).await?; fs_err::tokio::create_dir_all(&lib_dir).await?; @@ -83,32 +83,32 @@ impl LanguageImpl for Bun { .arg("-g") .args(&*deps) .env(EnvVars::PATH, new_path) - .env(EnvVars::BUN_INSTALL, &info.env_path) + .env(EnvVars::BUN_INSTALL, &env.env_path) .check(true) .output() .await?; } - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { - let bun = BunResult::from_executable(info.toolchain.clone()) + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { + let bun = BunResult::from_executable(env.toolchain.clone()) .fill_version() .await .context("Failed to query bun version")?; - if **bun.version() != info.language_version { + if **bun.version() != env.language_version { anyhow::bail!( "Bun version mismatch: expected {}, found {}", - info.language_version, + env.language_version, bun.version() ); } diff --git a/crates/prek/src/languages/bun/version.rs b/crates/prek/src/languages/bun/version.rs index 9f616ad0d..a3d9faa03 100644 --- a/crates/prek/src/languages/bun/version.rs +++ b/crates/prek/src/languages/bun/version.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use serde::Deserialize; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone, Deserialize)] @@ -116,11 +116,11 @@ impl BunRequest { } } - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { - let version = &install_info.language_version; + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { + let version = &installed_env.language_version; self.matches( &BunVersion(version.clone()), - Some(install_info.toolchain.as_ref()), + Some(installed_env.toolchain.as_ref()), ) } diff --git a/crates/prek/src/languages/dart.rs b/crates/prek/src/languages/dart.rs index 7fd8e5676..89e8d7615 100644 --- a/crates/prek/src/languages/dart.rs +++ b/crates/prek/src/languages/dart.rs @@ -23,7 +23,7 @@ use serde::{Deserialize, Serialize}; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; @@ -96,13 +96,13 @@ impl LanguageImpl for Dart { ) -> Result { let progress = reporter.on_install_start(&hook); - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - debug!(%hook, target = %info.env_path.display(), "Installing Dart environment"); + debug!(%hook, target = %env.env_path.display(), "Installing Dart environment"); let dart = find_dart_binary()?; @@ -110,7 +110,7 @@ impl LanguageImpl for Dart { if source_path.join(PUBSPEC_YAML).exists() { install_from_pubspec( &dart, - &info.env_path, + &env.env_path, source_path, &hook.additional_dependencies, ) @@ -118,7 +118,7 @@ impl LanguageImpl for Dart { } else if !hook.additional_dependencies.is_empty() { install_package_config( &dart, - &info.env_path, + &env.env_path, None, None, &hook.additional_dependencies, @@ -127,24 +127,24 @@ impl LanguageImpl for Dart { .context("Failed to install Dart additional dependencies")?; } - info.with_toolchain(dart); - info.persist_env_path(); + env.with_toolchain(dart); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { let dart = find_dart_binary()?; - if dart != info.toolchain { + if dart != env.toolchain { anyhow::bail!( "Dart executable mismatch: expected `{}`, found `{}`", - info.toolchain.display(), + env.toolchain.display(), dart.display() ); } diff --git a/crates/prek/src/languages/deno/deno.rs b/crates/prek/src/languages/deno/deno.rs index 5e3c5a0a1..8df1eefac 100644 --- a/crates/prek/src/languages/deno/deno.rs +++ b/crates/prek/src/languages/deno/deno.rs @@ -8,7 +8,7 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::deno::DenoRequest; use crate::languages::deno::installer::{DenoInstaller, DenoResult, bin_dir}; @@ -78,17 +78,17 @@ impl LanguageImpl for Deno { .await .context("Failed to install deno")?; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - info.with_toolchain(deno.deno().to_path_buf()); - info.with_language_version((**deno.version()).clone()); + env.with_toolchain(deno.deno().to_path_buf()); + env.with_language_version((**deno.version()).clone()); // 2. Create env - let env_bin_dir = bin_dir(&info.env_path); + let env_bin_dir = bin_dir(&env.env_path); fs_err::tokio::create_dir_all(&env_bin_dir).await?; // Relative install targets in `additional_dependencies` are resolved by Deno @@ -136,7 +136,7 @@ impl LanguageImpl for Deno { .arg("--global") .arg("--force") .arg("--root") - .arg(&info.env_path); + .arg(&env.env_path); if let Some(name) = name { install_cmd.arg("--name").arg(name); @@ -150,26 +150,26 @@ impl LanguageImpl for Deno { .with_context(|| format!("Failed to install deno dependency `{spec}`"))?; } - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { - let deno = DenoResult::from_executable(info.toolchain.clone()) + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { + let deno = DenoResult::from_executable(env.toolchain.clone()) .fill_version() .await .context("Failed to query deno version")?; - if **deno.version() != info.language_version { + if **deno.version() != env.language_version { anyhow::bail!( "Deno version mismatch: expected {}, found {}", - info.language_version, + env.language_version, deno.version() ); } @@ -187,8 +187,8 @@ impl LanguageImpl for Deno { let progress = reporter.on_run_start(hook, filenames.len()); let deno_cache_dir = store.cache_path(CacheBucket::Deno); - let info = hook.install_info().expect("Deno must be installed"); - let env_dir = &info.env_path; + let env = hook.installed_env().expect("Deno must be installed"); + let env_dir = &env.env_path; let deno_bin_dir = hook.toolchain_dir().expect("Deno must have toolchain dir"); let new_path = prepend_paths(&[&bin_dir(env_dir), deno_bin_dir]).context("Failed to join PATH")?; diff --git a/crates/prek/src/languages/deno/version.rs b/crates/prek/src/languages/deno/version.rs index 0689ab220..8681bd88c 100644 --- a/crates/prek/src/languages/deno/version.rs +++ b/crates/prek/src/languages/deno/version.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use serde::Deserialize; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone, Deserialize, PartialEq, Eq, PartialOrd, Ord)] @@ -105,11 +105,11 @@ impl DenoRequest { } } - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { - let version = &install_info.language_version; + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { + let version = &installed_env.language_version; self.matches( &DenoVersion(version.clone()), - Some(install_info.toolchain.as_ref()), + Some(installed_env.toolchain.as_ref()), ) } diff --git a/crates/prek/src/languages/docker.rs b/crates/prek/src/languages/docker.rs index 74aec3a78..65cc61781 100644 --- a/crates/prek/src/languages/docker.rs +++ b/crates/prek/src/languages/docker.rs @@ -1,5 +1,4 @@ use std::borrow::Cow; -use std::collections::BTreeSet; use std::collections::hash_map::DefaultHasher; use std::fs; use std::hash::{Hash, Hasher}; @@ -14,7 +13,7 @@ use prek_consts::env_vars::EnvVars; use tracing::{trace, warn}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::{USE_COLOR, run_by_batch}; @@ -318,13 +317,12 @@ static CONTAINER_RUNTIME: LazyLock = LazyLock::new(ContainerRuntimeInfo::detect_runtime); impl Docker { - fn docker_tag(info: &InstallInfo) -> String { + fn docker_tag(env: &InstalledHookEnv) -> String { let mut hasher = DefaultHasher::new(); - info.language.hash(&mut hasher); - info.language_version.hash(&mut hasher); - let deps = info.dependencies.iter().collect::>(); - deps.hash(&mut hasher); + env.language.hash(&mut hasher); + env.language_version.hash(&mut hasher); + env.identity.hash(&mut hasher); let digest = hex::encode(hasher.finish().to_le_bytes()); format!("prek-{digest}") @@ -332,14 +330,14 @@ impl Docker { async fn build_docker_image( hook: &Hook, - install_info: &InstallInfo, + installed_env: &InstalledHookEnv, pull: bool, ) -> Result { let Some(src) = hook.repo_path() else { anyhow::bail!("Language `docker` cannot work with `local` repository"); }; - let tag = Self::docker_tag(install_info); + let tag = Self::docker_tag(installed_env); let mut cmd = Cmd::new(CONTAINER_RUNTIME.cmd(), "build docker image"); let cmd = cmd .arg("build") @@ -434,27 +432,27 @@ impl LanguageImpl for Docker { ) -> Result { let progress = reporter.on_install_start(&hook); - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - Docker::build_docker_image(&hook, &info, true) + Docker::build_docker_image(&hook, &env, true) .await .context("Failed to build docker image")?; - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, _info: &InstallInfo) -> Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> Result<()> { Ok(()) } @@ -476,7 +474,7 @@ impl LanguageImpl for Docker { let docker_tag = Docker::build_docker_image( hook, - hook.install_info().expect("Docker env must be installed"), + hook.installed_env().expect("Docker env must be installed"), false, ) .await diff --git a/crates/prek/src/languages/docker_image.rs b/crates/prek/src/languages/docker_image.rs index 2a1c5106f..d5fd1fe67 100644 --- a/crates/prek/src/languages/docker_image.rs +++ b/crates/prek/src/languages/docker_image.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::docker::Docker; use crate::run::run_by_batch; @@ -24,7 +24,7 @@ impl LanguageImpl for DockerImage { Ok(InstalledHook::NoNeedInstall(hook)) } - async fn check_health(&self, _info: &InstallInfo) -> Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> Result<()> { Ok(()) } diff --git a/crates/prek/src/languages/dotnet/dotnet.rs b/crates/prek/src/languages/dotnet/dotnet.rs index c3eaa66aa..ea9523dbd 100644 --- a/crates/prek/src/languages/dotnet/dotnet.rs +++ b/crates/prek/src/languages/dotnet/dotnet.rs @@ -8,7 +8,7 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::dotnet::DotnetRequest; use crate::languages::dotnet::installer::{DotnetInstaller, DotnetResult}; @@ -57,13 +57,13 @@ impl LanguageImpl for Dotnet { .await .context("Failed to install dotnet SDK")?; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - let tools_dir = tools_dir(&info.env_path); + let tools_dir = tools_dir(&env.env_path); debug!( path = %tools_dir.display(), @@ -76,32 +76,32 @@ impl LanguageImpl for Dotnet { } } - info.with_language_version((**dotnet.version()).clone()) + env.with_language_version((**dotnet.version()).clone()) .with_toolchain(dotnet.dotnet().to_path_buf()); - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { - let current_version = DotnetResult::from_executable(info.toolchain.clone()) + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { + let current_version = DotnetResult::from_executable(env.toolchain.clone()) .fill_version() .await .context("Failed to query current dotnet info")?; // Only check major.minor for compatibility - if current_version.version().major != info.language_version.major - || current_version.version().minor != info.language_version.minor + if current_version.version().major != env.language_version.major + || current_version.version().minor != env.language_version.minor { anyhow::bail!( "dotnet version mismatch: expected `{}.{}`, found `{}.{}`", - info.language_version.major, - info.language_version.minor, + env.language_version.major, + env.language_version.minor, current_version.version().major, current_version.version().minor ); @@ -122,8 +122,8 @@ impl LanguageImpl for Dotnet { let env_dir = hook.env_path().expect("dotnet hook must have env path"); let tools_dir = tools_dir(env_dir); let dotnet = &hook - .install_info() - .expect("dotnet hook must have install info") + .installed_env() + .expect("dotnet hook must have an installed environment") .toolchain; let dotnet_root = resolve_dotnet_root(dotnet).context("Failed to resolve DOTNET_ROOT")?; diff --git a/crates/prek/src/languages/dotnet/version.rs b/crates/prek/src/languages/dotnet/version.rs index 0b9932125..28a6dd1d6 100644 --- a/crates/prek/src/languages/dotnet/version.rs +++ b/crates/prek/src/languages/dotnet/version.rs @@ -10,7 +10,7 @@ use std::fmt::Display; use std::ops::Deref; use std::str::FromStr; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::version::{Error, try_into_u64_slice}; /// A parsed `.NET SDK` version. @@ -209,8 +209,8 @@ impl DotnetRequest { } /// Returns whether a persisted installation satisfies this request. - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { - let version = DotnetVersion(install_info.language_version.clone()); + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { + let version = DotnetVersion(installed_env.language_version.clone()); self.matches(&version) } @@ -230,8 +230,6 @@ impl DotnetRequest { mod tests { use std::path::PathBuf; - use rustc_hash::FxHashSet; - use super::*; use crate::config::Language; use crate::languages::version::LanguageRequest; @@ -388,36 +386,40 @@ mod tests { #[test] fn test_satisfied_by() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Dotnet, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Dotnet, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(8, 0, 100)) .with_toolchain(PathBuf::from("/usr/share/dotnet/dotnet")); - assert!(DotnetRequest::Any.satisfied_by(&install_info)); - assert!(DotnetRequest::Channel(DotnetChannel::Version(8, 0)).satisfied_by(&install_info)); + assert!(DotnetRequest::Any.satisfied_by(&installed_env)); + assert!(DotnetRequest::Channel(DotnetChannel::Version(8, 0)).satisfied_by(&installed_env)); assert!( - DotnetRequest::Channel(DotnetChannel::FeatureBand(8, 0, 1)).satisfied_by(&install_info) + DotnetRequest::Channel(DotnetChannel::FeatureBand(8, 0, 1)) + .satisfied_by(&installed_env) ); assert!( !DotnetRequest::Channel(DotnetChannel::FeatureBand(8, 0, 2)) - .satisfied_by(&install_info) + .satisfied_by(&installed_env) ); - assert!(DotnetRequest::Channel(DotnetChannel::Lts).satisfied_by(&install_info)); - assert!(!DotnetRequest::Channel(DotnetChannel::Sts).satisfied_by(&install_info)); - assert!(DotnetRequest::Exact(8, 0, 100).satisfied_by(&install_info)); - assert!(!DotnetRequest::Exact(8, 0, 101).satisfied_by(&install_info)); - assert!(!DotnetRequest::Channel(DotnetChannel::Version(9, 0)).satisfied_by(&install_info)); + assert!(DotnetRequest::Channel(DotnetChannel::Lts).satisfied_by(&installed_env)); + assert!(!DotnetRequest::Channel(DotnetChannel::Sts).satisfied_by(&installed_env)); + assert!(DotnetRequest::Exact(8, 0, 100).satisfied_by(&installed_env)); + assert!(!DotnetRequest::Exact(8, 0, 101).satisfied_by(&installed_env)); + assert!(!DotnetRequest::Channel(DotnetChannel::Version(9, 0)).satisfied_by(&installed_env)); // Test through LanguageRequest dispatch let req = LanguageRequest::parse(Language::Dotnet, "8").unwrap(); - assert!(req.satisfied_by(&install_info)); + assert!(req.satisfied_by(&installed_env)); let req = LanguageRequest::parse(Language::Dotnet, "9").unwrap(); - assert!(!req.satisfied_by(&install_info)); + assert!(!req.satisfied_by(&installed_env)); let req = LanguageRequest::parse(Language::Dotnet, "lts").unwrap(); - assert!(req.satisfied_by(&install_info)); + assert!(req.satisfied_by(&installed_env)); let req = LanguageRequest::parse(Language::Dotnet, "sts").unwrap(); - assert!(!req.satisfied_by(&install_info)); + assert!(!req.satisfied_by(&installed_env)); Ok(()) } diff --git a/crates/prek/src/languages/fail.rs b/crates/prek/src/languages/fail.rs index 35d5d71c6..48eb303c3 100644 --- a/crates/prek/src/languages/fail.rs +++ b/crates/prek/src/languages/fail.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::store::Store; @@ -22,7 +22,7 @@ impl LanguageImpl for Fail { Ok(InstalledHook::NoNeedInstall(hook)) } - async fn check_health(&self, _info: &InstallInfo) -> Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> Result<()> { Ok(()) } diff --git a/crates/prek/src/languages/golang/golang.rs b/crates/prek/src/languages/golang/golang.rs index e2f89de2e..d9c14f21c 100644 --- a/crates/prek/src/languages/golang/golang.rs +++ b/crates/prek/src/languages/golang/golang.rs @@ -8,7 +8,7 @@ use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::golang::GoRequest; use crate::languages::golang::installer::GoInstaller; @@ -43,16 +43,16 @@ impl LanguageImpl for Golang { .await .context("Failed to install go")?; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - info.with_toolchain(go.bin().to_path_buf()) + env.with_toolchain(go.bin().to_path_buf()) .with_language_version(go.version().deref().clone()); // 2. Create environment - fs_err::tokio::create_dir_all(bin_dir(&info.env_path)).await?; + fs_err::tokio::create_dir_all(bin_dir(&env.env_path)).await?; // 3. Install dependencies // go: ~/.cache/prek/tools/go/1.24.0/bin/go @@ -71,14 +71,14 @@ impl LanguageImpl for Golang { let mut cmd = go.cmd("go install"); cmd.arg("install") .env(EnvVars::GOTOOLCHAIN, "local") - .env(EnvVars::GOBIN, bin_dir(&info.env_path)); + .env(EnvVars::GOBIN, bin_dir(&env.env_path)); cmd } else { let mut cmd = go.cmd("go install"); cmd.arg("install") .env(EnvVars::GOTOOLCHAIN, "local") .env(EnvVars::GOROOT, go_root) - .env(EnvVars::GOBIN, bin_dir(&info.env_path)) + .env(EnvVars::GOBIN, bin_dir(&env.env_path)) .env(EnvVars::GOFLAGS, "-modcacherw") .env(EnvVars::GOPATH, &go_cache); cmd @@ -103,17 +103,17 @@ impl LanguageImpl for Golang { cmd.arg(dep).remove_git_envs().check(true).output().await?; } - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, _info: &InstallInfo) -> anyhow::Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> anyhow::Result<()> { Ok(()) } diff --git a/crates/prek/src/languages/golang/version.rs b/crates/prek/src/languages/golang/version.rs index edd3c2520..318609b43 100644 --- a/crates/prek/src/languages/golang/version.rs +++ b/crates/prek/src/languages/golang/version.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use serde::Deserialize; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone, Deserialize)] @@ -134,12 +134,12 @@ impl GoRequest { } } - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { - let version = &install_info.language_version; + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { + let version = &installed_env.language_version; self.matches( &GoVersion(version.clone()), - Some(install_info.toolchain.as_ref()), + Some(installed_env.toolchain.as_ref()), ) } diff --git a/crates/prek/src/languages/haskell.rs b/crates/prek/src/languages/haskell.rs index 44d48810a..456d9a771 100644 --- a/crates/prek/src/languages/haskell.rs +++ b/crates/prek/src/languages/haskell.rs @@ -9,7 +9,7 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; @@ -31,15 +31,15 @@ impl LanguageImpl for Haskell { ) -> Result { let progress = reporter.on_install_start(&hook); - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - debug!(%hook, target = %info.env_path.display(), "Installing Haskell environment"); + debug!(%hook, target = %env.env_path.display(), "Installing Haskell environment"); - let bin_dir = info.env_path.join("bin"); + let bin_dir = env.env_path.join("bin"); fs_err::tokio::create_dir_all(&bin_dir).await?; // Identify packages: *.cabal files in repo + additional_dependencies @@ -94,17 +94,17 @@ impl LanguageImpl for Haskell { .await .context("Failed to install haskell dependencies")?; - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, _info: &InstallInfo) -> Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> Result<()> { Ok(()) } diff --git a/crates/prek/src/languages/julia.rs b/crates/prek/src/languages/julia.rs index b7a558fff..a2b6be3a9 100644 --- a/crates/prek/src/languages/julia.rs +++ b/crates/prek/src/languages/julia.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result}; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; @@ -24,15 +24,15 @@ impl LanguageImpl for Julia { ) -> Result { let progress = reporter.on_install_start(&hook); - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - debug!(%hook, target = %info.env_path.display(), "Installing Julia environment"); + debug!(%hook, target = %env.env_path.display(), "Installing Julia environment"); - fs_err::tokio::create_dir_all(&info.env_path).await?; + fs_err::tokio::create_dir_all(&env.env_path).await?; let search_path = hook.repo_path().unwrap_or_else(|| hook.work_dir()); let find_src = |names: &[&str]| { @@ -43,7 +43,7 @@ impl LanguageImpl for Julia { }; // Copy Project.toml if exists - let project_dest = info.env_path.join("Project.toml"); + let project_dest = env.env_path.join("Project.toml"); if let Some(src) = find_src(&["JuliaProject.toml", "Project.toml"]) { fs_err::tokio::copy(src, project_dest).await?; } else { @@ -53,7 +53,7 @@ impl LanguageImpl for Julia { // Copy Manifest.toml (lock) if exists if let Some(src) = find_src(&["JuliaManifest.toml", "Manifest.toml"]) { - fs_err::tokio::copy(src, info.env_path.join("Manifest.toml")).await?; + fs_err::tokio::copy(src, env.env_path.join("Manifest.toml")).await?; } let julia_code = indoc::indoc! {r" @@ -67,7 +67,7 @@ impl LanguageImpl for Julia { Cmd::new("julia", "instantiate julia environment") .current_dir(search_path) .arg("--startup-file=no") - .arg(format!("--project={}", info.env_path.display())) + .arg(format!("--project={}", env.env_path.display())) .arg("-e") .arg(julia_code) .arg("--") @@ -77,17 +77,17 @@ impl LanguageImpl for Julia { .await .context("Failed to instantiate Julia environment")?; - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, _info: &InstallInfo) -> Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> Result<()> { Cmd::new("julia", "check julia version") .arg("--version") .check(true) diff --git a/crates/prek/src/languages/lua.rs b/crates/prek/src/languages/lua.rs index 5dac5a617..b86541601 100644 --- a/crates/prek/src/languages/lua.rs +++ b/crates/prek/src/languages/lua.rs @@ -9,7 +9,7 @@ use semver::Version; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; @@ -63,13 +63,13 @@ impl LanguageImpl for Lua { ) -> Result { let progress = reporter.on_install_start(&hook); - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - debug!(%hook, target = %info.env_path.display(), "Installing Lua environment"); + debug!(%hook, target = %env.env_path.display(), "Installing Lua environment"); // Check lua and luarocks are installed. let lua_info = query_lua_info().await.context("Failed to query Lua info")?; @@ -77,45 +77,45 @@ impl LanguageImpl for Lua { // Install dependencies for the remote repository. if let Some(repo_path) = hook.repo_path() { if let Some(rockspec) = Self::get_rockspec_file(repo_path) { - Self::install_rockspec(&info.env_path, repo_path, &rockspec).await?; + Self::install_rockspec(&env.env_path, repo_path, &rockspec).await?; } } // Install additional dependencies. for dep in &hook.additional_dependencies { - Self::install_dependency(&info.env_path, dep).await?; + Self::install_dependency(&env.env_path, dep).await?; } - info.with_toolchain(lua_info.executable) + env.with_toolchain(lua_info.executable) .with_language_version(lua_info.version); - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { let current_lua_info = query_lua_info() .await .context("Failed to query current Lua info")?; - if current_lua_info.version != info.language_version { + if current_lua_info.version != env.language_version { anyhow::bail!( "Lua version mismatch: expected `{}`, found `{}`", - info.language_version, + env.language_version, current_lua_info.version ); } - if current_lua_info.executable != info.toolchain { + if current_lua_info.executable != env.toolchain { anyhow::bail!( "Lua executable mismatch: expected `{}`, found `{}`", - info.toolchain.display(), + env.toolchain.display(), current_lua_info.executable.display() ); } @@ -137,8 +137,8 @@ impl LanguageImpl for Lua { let entry = hook.entry.resolve(Some(&new_path), store)?; let version = &hook - .install_info() - .expect("Lua must have install info") + .installed_env() + .expect("Lua hook must have an installed environment") .language_version; // version without patch, e.g. 5.4 let version = format!("{}.{}", version.major, version.minor); diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index f866fc303..079033965 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -13,7 +13,7 @@ use tracing::{instrument, trace}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::config::Language; use crate::fs::CWD; -use crate::hook::{Hook, InstallInfo, InstalledHook, Repo}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv, Repo}; use crate::hook_entry::HookEntry; use crate::hooks; use crate::languages::version::LanguageRequest; @@ -48,7 +48,7 @@ trait LanguageImpl { reporter: &HookInstallReporter, ) -> Result; - async fn check_health(&self, info: &InstallInfo) -> Result<()>; + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()>; async fn run( &self, @@ -97,7 +97,7 @@ impl LanguageImpl for Unimplemented { Ok(InstalledHook::NoNeedInstall(hook)) } - async fn check_health(&self, _info: &InstallInfo) -> Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> Result<()> { Ok(()) } @@ -377,30 +377,30 @@ impl Language { } } - pub(crate) async fn check_health(&self, info: &InstallInfo) -> Result<()> { + pub(crate) async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { match self { - Self::Dart => dart::Dart.check_health(info).await, - Self::Bun => bun::Bun.check_health(info).await, - Self::Deno => deno::Deno.check_health(info).await, - Self::Docker => docker::Docker.check_health(info).await, - Self::DockerImage => docker_image::DockerImage.check_health(info).await, - Self::Dotnet => dotnet::Dotnet.check_health(info).await, - Self::Fail => fail::Fail.check_health(info).await, - Self::Golang => golang::Golang.check_health(info).await, - Self::Haskell => haskell::Haskell.check_health(info).await, - Self::Julia => julia::Julia.check_health(info).await, - Self::Lua => lua::Lua.check_health(info).await, - Self::Node => node::Node.check_health(info).await, - Self::Pygrep => pygrep::Pygrep.check_health(info).await, - Self::Python => python::Python.check_health(info).await, - Self::PythonUv => python::PythonUv.check_health(info).await, - Self::Ruby => ruby::Ruby.check_health(info).await, - Self::Rust => rust::Rust.check_health(info).await, - Self::Script => script::Script.check_health(info).await, - Self::Swift => swift::Swift.check_health(info).await, - Self::System => system::System.check_health(info).await, + Self::Dart => dart::Dart.check_health(env).await, + Self::Bun => bun::Bun.check_health(env).await, + Self::Deno => deno::Deno.check_health(env).await, + Self::Docker => docker::Docker.check_health(env).await, + Self::DockerImage => docker_image::DockerImage.check_health(env).await, + Self::Dotnet => dotnet::Dotnet.check_health(env).await, + Self::Fail => fail::Fail.check_health(env).await, + Self::Golang => golang::Golang.check_health(env).await, + Self::Haskell => haskell::Haskell.check_health(env).await, + Self::Julia => julia::Julia.check_health(env).await, + Self::Lua => lua::Lua.check_health(env).await, + Self::Node => node::Node.check_health(env).await, + Self::Pygrep => pygrep::Pygrep.check_health(env).await, + Self::Python => python::Python.check_health(env).await, + Self::PythonUv => python::PythonUv.check_health(env).await, + Self::Ruby => ruby::Ruby.check_health(env).await, + Self::Rust => rust::Rust.check_health(env).await, + Self::Script => script::Script.check_health(env).await, + Self::Swift => swift::Swift.check_health(env).await, + Self::System => system::System.check_health(env).await, Self::Conda | Self::Coursier | Self::Perl | Self::R => { - Unimplemented.check_health(info).await + Unimplemented.check_health(env).await } } } diff --git a/crates/prek/src/languages/node/node.rs b/crates/prek/src/languages/node/node.rs index e7cba503b..11a9c1629 100644 --- a/crates/prek/src/languages/node/node.rs +++ b/crates/prek/src/languages/node/node.rs @@ -9,7 +9,7 @@ use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; -use crate::hook::{Hook, InstallInfo}; +use crate::hook::{Hook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::node::NodeRequest; use crate::languages::node::installer::{NodeInstaller, NodeResult, bin_dir, lib_dir}; @@ -52,20 +52,20 @@ impl LanguageImpl for Node { .await .context("Failed to install node")?; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; let lts = serde_json::to_string(&node.version().lts).context("Failed to serialize LTS")?; - info.with_toolchain(node.node().to_path_buf()); - info.with_language_version(node.version().version.clone()); - info.with_extra(EXTRA_KEY_LTS, <s); + env.with_toolchain(node.node().to_path_buf()); + env.with_language_version(node.version().version.clone()); + env.with_extra(EXTRA_KEY_LTS, <s); // 2. Create env - let bin_dir = bin_dir(&info.env_path); - let lib_dir = lib_dir(&info.env_path); + let bin_dir = bin_dir(&env.env_path); + let lib_dir = lib_dir(&env.env_path); fs_err::tokio::create_dir_all(&bin_dir).await?; fs_err::tokio::create_dir_all(&lib_dir).await?; @@ -98,7 +98,7 @@ impl LanguageImpl for Node { .arg("--install-links") .args(&*deps) .env(EnvVars::PATH, new_path) - .env(EnvVars::NPM_CONFIG_PREFIX, &info.env_path) + .env(EnvVars::NPM_CONFIG_PREFIX, &env.env_path) .env_remove(EnvVars::NPM_CONFIG_USERCONFIG) .env(EnvVars::NODE_PATH, &lib_dir) .check(true) @@ -106,26 +106,26 @@ impl LanguageImpl for Node { .await?; } - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { - let node = NodeResult::from_executables(info.toolchain.clone(), PathBuf::new()) + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { + let node = NodeResult::from_executables(env.toolchain.clone(), PathBuf::new()) .fill_version() .await .context("Failed to query node version")?; - if node.version().version != info.language_version { + if node.version().version != env.language_version { anyhow::bail!( "Node version mismatch: expected {}, found {}", - info.language_version, + env.language_version, node.version().version ); } diff --git a/crates/prek/src/languages/node/version.rs b/crates/prek/src/languages/node/version.rs index 02f6c8944..82262e47a 100644 --- a/crates/prek/src/languages/node/version.rs +++ b/crates/prek/src/languages/node/version.rs @@ -5,7 +5,7 @@ use std::str::FromStr; use serde::{Deserialize, Deserializer, Serialize}; use serde_json::Value; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone)] @@ -214,9 +214,9 @@ impl NodeRequest { } } - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { - let version = &install_info.language_version; - let tls = install_info + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { + let version = &installed_env.language_version; + let tls = installed_env .get_extra(EXTRA_KEY_LTS) .and_then(|s| serde_json::from_str(s).ok()) .unwrap_or(Lts::NotLts); @@ -226,7 +226,7 @@ impl NodeRequest { version: version.clone(), lts: tls, }, - Some(install_info.toolchain.as_ref()), + Some(installed_env.toolchain.as_ref()), ) } @@ -256,8 +256,7 @@ impl NodeRequest { mod tests { use super::{EXTRA_KEY_LTS, NodeRequest}; use crate::config::Language; - use crate::hook::InstallInfo; - use rustc_hash::FxHashSet; + use crate::hook::InstalledHookEnv; use std::path::PathBuf; use std::str::FromStr; @@ -310,45 +309,48 @@ mod tests { #[test] fn test_node_request_satisfied_by() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Node, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Node, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(12, 18, 3)) .with_toolchain(PathBuf::from("/usr/bin/node")) .with_extra(EXTRA_KEY_LTS, "\"Argon\""); let request = NodeRequest::Major(12); - assert!(request.satisfied_by(&install_info)); + assert!(request.satisfied_by(&installed_env)); let request = NodeRequest::MajorMinor(12, 18); - assert!(request.satisfied_by(&install_info)); + assert!(request.satisfied_by(&installed_env)); let request = NodeRequest::MajorMinorPatch(12, 18, 3); - assert!(request.satisfied_by(&install_info)); + assert!(request.satisfied_by(&installed_env)); let request = NodeRequest::Lts; - assert!(request.satisfied_by(&install_info)); + assert!(request.satisfied_by(&installed_env)); let request = NodeRequest::CodeName("Argon".to_string()); - assert!(request.satisfied_by(&install_info)); + assert!(request.satisfied_by(&installed_env)); let request = NodeRequest::CodeName("argon".to_string()); - assert!(request.satisfied_by(&install_info)); + assert!(request.satisfied_by(&installed_env)); let request = NodeRequest::CodeName("Boron".to_string()); - assert!(!request.satisfied_by(&install_info)); + assert!(!request.satisfied_by(&installed_env)); let request = NodeRequest::Path(PathBuf::from("/usr/bin/node")); - assert!(request.satisfied_by(&install_info)); + assert!(request.satisfied_by(&installed_env)); let request = NodeRequest::Path(PathBuf::from("/usr/bin/nodejs")); - assert!(!request.satisfied_by(&install_info)); + assert!(!request.satisfied_by(&installed_env)); let request = NodeRequest::Range(semver::VersionReq::parse(">=12.18").unwrap()); - assert!(request.satisfied_by(&install_info)); + assert!(request.satisfied_by(&installed_env)); let request = NodeRequest::Range(semver::VersionReq::parse(">=13.0").unwrap()); - assert!(!request.satisfied_by(&install_info)); + assert!(!request.satisfied_by(&installed_env)); Ok(()) } diff --git a/crates/prek/src/languages/pygrep/pygrep.rs b/crates/prek/src/languages/pygrep/pygrep.rs index 22fd68a77..9c276d718 100644 --- a/crates/prek/src/languages/pygrep/pygrep.rs +++ b/crates/prek/src/languages/pygrep/pygrep.rs @@ -8,7 +8,7 @@ use tokio::io::AsyncWriteExt; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::python::{Uv, python_exec, query_python_info_cached}; use crate::process::Cmd; @@ -154,25 +154,25 @@ impl LanguageImpl for Pygrep { anyhow::bail!("Failed to find or install a Python interpreter for `pygrep`."); }; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - info.with_toolchain(python); + env.with_toolchain(python); - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { - query_python_info_cached(&info.toolchain) + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { + query_python_info_cached(&env.toolchain) .await .context("Failed to query Python info")?; @@ -188,7 +188,7 @@ impl LanguageImpl for Pygrep { ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); - let info = hook.install_info().expect("Pygrep hook must be installed"); + let env = hook.installed_env().expect("Pygrep hook must be installed"); let cache = store.cache_path(CacheBucket::Python); fs_err::tokio::create_dir_all(&cache).await?; @@ -199,7 +199,7 @@ impl LanguageImpl for Pygrep { .context("Failed to write Python script")?; let args = Args::parse(&hook.args).context("Failed to parse `args`")?; - let mut cmd = Cmd::new(&info.toolchain, "python script") + let mut cmd = Cmd::new(&env.toolchain, "python script") .current_dir(hook.work_dir()) .envs(&hook.env) .arg("-I") // Isolate mode. diff --git a/crates/prek/src/languages/python/python.rs b/crates/prek/src/languages/python/python.rs index 4b37541b6..3a35de8df 100644 --- a/crates/prek/src/languages/python/python.rs +++ b/crates/prek/src/languages/python/python.rs @@ -15,7 +15,7 @@ use tracing::{debug, trace}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::config::PythonUvLockMode; use crate::hook::InstalledHook; -use crate::hook::{Hook, InstallInfo}; +use crate::hook::{Hook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::python::PythonRequest; use crate::languages::python::uv::Uv; @@ -111,21 +111,21 @@ impl LanguageImpl for Python { .await .context("Failed to install uv")?; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - debug!(%hook, target = %info.env_path.display(), "Installing environment"); + debug!(%hook, target = %env.env_path.display(), "Installing environment"); // Create venv (auto download Python if needed) - Self::create_venv(&uv, store, &info, &hook.language_request) + Self::create_venv(&uv, store, &env, &hook.language_request) .await .context("Failed to create Python virtual environment")?; // Install dependencies - let mut pip_install = Self::pip_install_command(&uv, store, &info.env_path); + let mut pip_install = Self::pip_install_command(&uv, store, &env.env_path); if let Some(repo_path) = hook.repo_path() { trace!( @@ -152,34 +152,34 @@ impl LanguageImpl for Python { debug!("No dependencies to install"); } - let python = python_exec(&info.env_path); + let python = python_exec(&env.env_path); let python_info = query_python_info(&python) .await .context("Failed to query Python info")?; - info.with_language_version(python_info.version) + env.with_language_version(python_info.version) .with_toolchain(python_info.python_exec); - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { - let python = python_exec(&info.env_path); + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { + let python = python_exec(&env.env_path); let python_info = query_python_info_cached(&python) .await .context("Failed to query Python info")?; - if python_info.version != info.language_version { + if python_info.version != env.language_version { anyhow::bail!( "Python version mismatch: expected {}, found {}", - info.language_version, + env.language_version, python_info.version ); } @@ -253,46 +253,46 @@ impl LanguageImpl for PythonUv { .await .context("Failed to install uv")?; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - debug!(%hook, target = %info.env_path.display(), "Installing python_uv environment"); + debug!(%hook, target = %env.env_path.display(), "Installing python_uv environment"); - Python::create_venv(&uv, store, &info, &hook.language_request) + Python::create_venv(&uv, store, &env, &hook.language_request) .await .context("Failed to create Python virtual environment")?; let uv_env = hook .python_uv_env() .expect("python_uv hook must have uv env options"); - Python::sync_project_environment_command(&uv, store, &info.env_path, uv_env) + Python::sync_project_environment_command(&uv, store, &env.env_path, uv_env) .output() .await .context("Failed to sync uv project environment")?; - let python = python_exec(&info.env_path); + let python = python_exec(&env.env_path); let python_info = query_python_info(&python) .await .context("Failed to query Python info")?; - info.with_language_version(python_info.version) + env.with_language_version(python_info.version) .with_toolchain(python_info.python_exec); - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { - Python.check_health(info).await + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { + Python.check_health(env).await } async fn run( @@ -392,11 +392,11 @@ impl Python { async fn create_venv( uv: &Uv, store: &Store, - info: &InstallInfo, + env: &InstalledHookEnv, python_request: &LanguageRequest, ) -> Result<()> { // Try creating venv without downloads first - match Self::create_venv_command(uv, store, info, python_request, false, false) + match Self::create_venv_command(uv, store, env, python_request, false, false) .check(true) .output() .await @@ -404,7 +404,7 @@ impl Python { Ok(_) => { debug!( "Venv created successfully with no downloads: `{}`", - info.env_path.display() + env.env_path.display() ); Ok(()) } @@ -419,9 +419,9 @@ impl Python { debug!( "Retrying venv creation with managed Python downloads: `{}`", - info.env_path.display() + env.env_path.display() ); - Self::create_venv_command(uv, store, info, python_request, true, true) + Self::create_venv_command(uv, store, env, python_request, true, true) .check(true) .output() .await?; @@ -431,7 +431,7 @@ impl Python { Err(e.into()) } Err(e) => { - debug!("Failed to create venv `{}`: {e}", info.env_path.display()); + debug!("Failed to create venv `{}`: {e}", env.env_path.display()); Err(e.into()) } } @@ -440,14 +440,14 @@ impl Python { fn create_venv_command( uv: &Uv, store: &Store, - info: &InstallInfo, + env: &InstalledHookEnv, python_request: &LanguageRequest, set_install_dir: bool, allow_downloads: bool, ) -> Cmd { let mut cmd = uv.cmd("create venv", store); cmd.arg("venv") - .arg(&info.env_path) + .arg(&env.env_path) .args(["--python-preference", "managed"]) // Avoid discovering a project or workspace .arg("--no-project") @@ -509,27 +509,30 @@ mod tests { use std::path::PathBuf; use prek_consts::env_vars::EnvVars; - use rustc_hash::FxHashSet; use super::Python; use crate::config::{Language, PythonUvLockMode, PythonUvOptions}; - use crate::hook::InstallInfo; + use crate::hook::InstalledHookEnv; use crate::hook_env::PythonUvEnv; use crate::languages::python::uv::Uv; use crate::languages::version::LanguageRequest; use crate::store::Store; - fn setup_test_install() -> (tempfile::TempDir, Uv, Store, InstallInfo) { + fn setup_test_install() -> (tempfile::TempDir, Uv, Store, InstalledHookEnv) { let temp = tempfile::tempdir().expect("create tempdir"); let hooks_dir = temp.path().join("hooks"); fs_err::create_dir_all(&hooks_dir).expect("create hooks dir"); - let info = InstallInfo::new(Language::Python, FxHashSet::default(), &hooks_dir) - .expect("create install info"); + let env = InstalledHookEnv::new( + Language::Python, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + &hooks_dir, + ) + .expect("create installed hook environment"); let store = Store::from_path(temp.path().join("store")); let uv = Uv::new(PathBuf::from("uv")); - (temp, uv, store, info) + (temp, uv, store, env) } fn env_map(cmd: &crate::process::Cmd) -> HashMap> { @@ -551,9 +554,9 @@ mod tests { #[test] fn create_venv_command_removes_uv_system_python_override() { - let (_temp, uv, store, info) = setup_test_install(); + let (_temp, uv, store, env) = setup_test_install(); let request = LanguageRequest::Any { system_only: false }; - let cmd = Python::create_venv_command(&uv, &store, &info, &request, false, false); + let cmd = Python::create_venv_command(&uv, &store, &env, &request, false, false); let envs = env_map(&cmd); assert_eq!(envs.get(EnvVars::UV_SYSTEM_PYTHON), Some(&None)); @@ -564,8 +567,8 @@ mod tests { #[test] fn pip_install_command_removes_uv_system_python_override() { - let (_temp, uv, store, info) = setup_test_install(); - let cmd = Python::pip_install_command(&uv, &store, &info.env_path); + let (_temp, uv, store, env) = setup_test_install(); + let cmd = Python::pip_install_command(&uv, &store, &env.env_path); let envs = env_map(&cmd); assert_eq!(envs.get(EnvVars::UV_SYSTEM_PYTHON), Some(&None)); @@ -584,7 +587,7 @@ mod tests { .expect("write pyproject"); fs_err::write(temp.path().join("uv.lock"), "version = 1\n").expect("write lockfile"); - let uv_env = PythonUvEnv::resolve( + let (uv_env, _) = PythonUvEnv::resolve( &PythonUvOptions { dependency_groups: Some(vec!["typecheck".to_string()]), extras: Some(vec!["typed".to_string()]), diff --git a/crates/prek/src/languages/python/version.rs b/crates/prek/src/languages/python/version.rs index f30555524..2d87dce09 100644 --- a/crates/prek/src/languages/python/version.rs +++ b/crates/prek/src/languages/python/version.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use std::str::FromStr; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Clone, PartialEq, Eq)] @@ -89,8 +89,8 @@ impl PythonRequest { } } - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { - let version = &install_info.language_version; + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { + let version = &installed_env.language_version; match self { PythonRequest::Any => true, PythonRequest::Major(major) => version.major == *major, @@ -101,7 +101,7 @@ impl PythonRequest { version.major == *major && version.minor == *minor && version.patch == *patch } // FIXME: consider resolving symlinks and normalizing paths before comparison - PythonRequest::Path(path) => path == &install_info.toolchain, + PythonRequest::Path(path) => path == &installed_env.toolchain, PythonRequest::Range(req, _) => req.matches(version), } } @@ -137,7 +137,6 @@ fn split_wheel_tag_version(mut version: Vec) -> Vec { mod tests { use super::*; use crate::config::Language; - use rustc_hash::FxHashSet; #[test] fn test_parse_python_request() { @@ -218,32 +217,35 @@ mod tests { #[test] fn test_satisfied_by() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Python, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Python, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(3, 12, 1)) .with_toolchain(PathBuf::from("/usr/bin/python3.12")); - assert!(PythonRequest::Any.satisfied_by(&install_info)); - assert!(PythonRequest::Major(3).satisfied_by(&install_info)); - assert!(PythonRequest::MajorMinor(3, 12).satisfied_by(&install_info)); - assert!(PythonRequest::MajorMinorPatch(3, 12, 1).satisfied_by(&install_info)); - assert!(!PythonRequest::MajorMinorPatch(3, 12, 2).satisfied_by(&install_info)); + assert!(PythonRequest::Any.satisfied_by(&installed_env)); + assert!(PythonRequest::Major(3).satisfied_by(&installed_env)); + assert!(PythonRequest::MajorMinor(3, 12).satisfied_by(&installed_env)); + assert!(PythonRequest::MajorMinorPatch(3, 12, 1).satisfied_by(&installed_env)); + assert!(!PythonRequest::MajorMinorPatch(3, 12, 2).satisfied_by(&installed_env)); assert!( - PythonRequest::Path(PathBuf::from("/usr/bin/python3.12")).satisfied_by(&install_info) + PythonRequest::Path(PathBuf::from("/usr/bin/python3.12")).satisfied_by(&installed_env) ); assert!( - !PythonRequest::Path(PathBuf::from("/usr/bin/python3.11")).satisfied_by(&install_info) + !PythonRequest::Path(PathBuf::from("/usr/bin/python3.11")).satisfied_by(&installed_env) ); let range_req = semver::VersionReq::parse(">=3.12").unwrap(); assert!( PythonRequest::Range(range_req.clone(), ">=3.12".to_string()) - .satisfied_by(&install_info) + .satisfied_by(&installed_env) ); let range_req = semver::VersionReq::parse(">=4.0").unwrap(); - assert!(!PythonRequest::Range(range_req, ">=4.0".to_string()).satisfied_by(&install_info)); + assert!(!PythonRequest::Range(range_req, ">=4.0".to_string()).satisfied_by(&installed_env)); Ok(()) } diff --git a/crates/prek/src/languages/ruby/ruby.rs b/crates/prek/src/languages/ruby/ruby.rs index 96f997735..d83305c97 100644 --- a/crates/prek/src/languages/ruby/ruby.rs +++ b/crates/prek/src/languages/ruby/ruby.rs @@ -9,7 +9,7 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::ruby::RubyRequest; use crate::languages::ruby::gem::{build_gemspecs, install_gems}; @@ -46,23 +46,23 @@ impl LanguageImpl for Ruby { .await .context("Failed to install Ruby")?; - // 2. Create InstallInfo - let mut info = InstallInfo::new( + // 2. Create InstalledHookEnv + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - info.with_toolchain(ruby.ruby_bin().to_path_buf()) + env.with_toolchain(ruby.ruby_bin().to_path_buf()) .with_language_version(ruby.version().clone()); // Store Ruby engine in metadata - info.with_extra("ruby_engine", ruby.engine()); + env.with_extra("ruby_engine", ruby.engine()); // 3. Create environment directories - let gem_home = gem_home(&info.env_path); + let gem_home = gem_home(&env.env_path); fs_err::tokio::create_dir_all(&gem_home).await?; - let gem_bin = gem_bin(&info.env_path); + let gem_bin = gem_bin(&env.env_path); fs_err::tokio::create_dir_all(&gem_bin).await?; // 4. Build gemspecs @@ -94,32 +94,32 @@ impl LanguageImpl for Ruby { .await .context("Failed to install Ruby executable into gem bin directory")?; - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { // 1. Verify Ruby runs and reports correct version - let (actual_version, _) = query_ruby_info(&info.toolchain) + let (actual_version, _) = query_ruby_info(&env.toolchain) .await .context("Failed to query Ruby info")?; - if actual_version != info.language_version { + if actual_version != env.language_version { anyhow::bail!( "Ruby version mismatch: expected {}, found {}", - info.language_version, + env.language_version, actual_version ); } // 2. Verify gem bin Ruby executable exists - let gem_bin = gem_bin(&info.env_path); + let gem_bin = gem_bin(&env.env_path); let gem_bin_ruby = gem_bin.join("ruby").with_extension(EXE_EXTENSION); if !gem_bin_ruby.exists() { anyhow::bail!( diff --git a/crates/prek/src/languages/ruby/version.rs b/crates/prek/src/languages/ruby/version.rs index 37a1e4f18..82cb14a55 100644 --- a/crates/prek/src/languages/ruby/version.rs +++ b/crates/prek/src/languages/ruby/version.rs @@ -2,7 +2,7 @@ use std::fmt; use std::path::{Path, PathBuf}; use std::str::FromStr; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::version::{Error, try_into_u64_slice}; /// Ruby version request parsed from `language_version` field @@ -122,10 +122,10 @@ impl RubyRequest { /// Check if this request is satisfied by the given Ruby installation /// /// This is used at runtime to verify an installation meets the requirements. - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { self.matches( - &install_info.language_version, - Some(&install_info.toolchain), + &installed_env.language_version, + Some(&installed_env.toolchain), ) } } @@ -134,7 +134,6 @@ impl RubyRequest { mod tests { use super::*; use crate::config::Language; - use rustc_hash::FxHashSet; #[test] fn test_parse_ruby_request() { @@ -179,36 +178,42 @@ mod tests { #[test] fn test_version_matching() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Ruby, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Ruby, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(3, 3, 6)) .with_toolchain(PathBuf::from("/usr/bin/ruby")); - assert!(RubyRequest::Any.satisfied_by(&install_info)); - assert!(RubyRequest::Exact(3, 3, 6).satisfied_by(&install_info)); - assert!(RubyRequest::MajorMinor(3, 3).satisfied_by(&install_info)); - assert!(RubyRequest::Major(3).satisfied_by(&install_info)); - assert!(!RubyRequest::Exact(3, 3, 7).satisfied_by(&install_info)); - assert!(!RubyRequest::Exact(3, 2, 6).satisfied_by(&install_info)); + assert!(RubyRequest::Any.satisfied_by(&installed_env)); + assert!(RubyRequest::Exact(3, 3, 6).satisfied_by(&installed_env)); + assert!(RubyRequest::MajorMinor(3, 3).satisfied_by(&installed_env)); + assert!(RubyRequest::Major(3).satisfied_by(&installed_env)); + assert!(!RubyRequest::Exact(3, 3, 7).satisfied_by(&installed_env)); + assert!(!RubyRequest::Exact(3, 2, 6).satisfied_by(&installed_env)); // Test path matching - assert!(RubyRequest::Path(PathBuf::from("/usr/bin/ruby")).satisfied_by(&install_info)); - assert!(!RubyRequest::Path(PathBuf::from("/usr/bin/ruby3.2")).satisfied_by(&install_info)); + assert!(RubyRequest::Path(PathBuf::from("/usr/bin/ruby")).satisfied_by(&installed_env)); + assert!(!RubyRequest::Path(PathBuf::from("/usr/bin/ruby3.2")).satisfied_by(&installed_env)); // Test range matching let req = semver::VersionReq::parse(">=3.2, <4.0")?; assert!( - RubyRequest::Range(req.clone(), ">=3.2, <4.0".to_string()).satisfied_by(&install_info) + RubyRequest::Range(req.clone(), ">=3.2, <4.0".to_string()).satisfied_by(&installed_env) ); let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Ruby, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Ruby, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(3, 1, 0)) .with_toolchain(PathBuf::from("/usr/bin/ruby3.1")); - assert!(!RubyRequest::Range(req, ">=3.2, <4.0".to_string()).satisfied_by(&install_info)); + assert!(!RubyRequest::Range(req, ">=3.2, <4.0".to_string()).satisfied_by(&installed_env)); Ok(()) } diff --git a/crates/prek/src/languages/rust/rust.rs b/crates/prek/src/languages/rust/rust.rs index 90a7a80df..16240bff1 100644 --- a/crates/prek/src/languages/rust/rust.rs +++ b/crates/prek/src/languages/rust/rust.rs @@ -13,7 +13,7 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::languages::rust::RustRequest; use crate::languages::rust::installer::RustInstaller; @@ -243,7 +243,7 @@ async fn copy_binaries(release_dir: &Path, dest_bin_dir: &Path) -> anyhow::Resul async fn install_local_project( hook_binary: &str, repo_path: &Path, - info: &InstallInfo, + env: &InstalledHookEnv, lib_deps: &[&String], cargo: &Path, cargo_home: &Path, @@ -286,7 +286,7 @@ async fn install_local_project( // For packages without lib deps, use `cargo install` directly Cmd::new(cargo, "install local") .args(["install", "--bins", "--root"]) - .arg(&info.env_path) + .arg(&env.env_path) .args(["--path", ".", "--locked"]) .current_dir(&package_dir) .env(EnvVars::PATH, new_path) @@ -297,7 +297,7 @@ async fn install_local_project( .await?; } else { // For packages with lib deps, copy manifest, modify, build and copy binaries - let manifest_dir = info.env_path.join("manifest"); + let manifest_dir = env.env_path.join("manifest"); fs_err::tokio::create_dir_all(&manifest_dir).await?; // Copy Cargo.toml @@ -347,7 +347,7 @@ async fn install_local_project( // Build using cargo build with --manifest-path pointing to modified manifest // but source files come from original package_dir - let target_dir = info.env_path.join("target"); + let target_dir = env.env_path.join("target"); let mut cmd = Cmd::new(cargo, "build local with deps"); cmd.args(["build", "--bins", "--release"]) .arg("--manifest-path") @@ -369,7 +369,7 @@ async fn install_local_project( .await?; // Copy compiled binaries to the bin directory - copy_binaries(&target_dir.join("release"), &bin_dir(&info.env_path)).await?; + copy_binaries(&target_dir.join("release"), &bin_dir(&env.env_path)).await?; // Clean up manifest and target directories fs_err::tokio::remove_dir_all(&manifest_dir).await?; @@ -381,7 +381,7 @@ async fn install_local_project( async fn install_cli_dependency( cli_dep: &str, - info: &InstallInfo, + env: &InstalledHookEnv, cargo: &Path, cargo_home: &Path, new_path: &OsStr, @@ -390,7 +390,7 @@ async fn install_cli_dependency( let mut cmd = Cmd::new(cargo, "install cli dep"); cmd.args(["install", "--bins", "--root"]) - .arg(&info.env_path) + .arg(&env.env_path) .args(dep.to_cargo_args()) .arg("--locked"); @@ -437,28 +437,28 @@ impl LanguageImpl for Rust { // Add toolchain bin to PATH, for cargo to use correct rustc let new_path = prepend_paths(&[&rustc_bin]).context("Failed to join PATH")?; - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - info.with_toolchain(rust.toolchain().to_path_buf()) + env.with_toolchain(rust.toolchain().to_path_buf()) .with_language_version(rust.version().deref().clone()); // Store the channel name for cache matching match version { RustRequest::Channel(channel) => { - info.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string()); + env.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string()); } RustRequest::Any => { let channel = rust.version().channel().unwrap_or(Channel::Stable); - info.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string()); + env.with_extra(EXTRA_KEY_CHANNEL, &channel.to_string()); } _ => {} } // 2. Create environment - fs_err::tokio::create_dir_all(bin_dir(&info.env_path)).await?; + fs_err::tokio::create_dir_all(bin_dir(&env.env_path)).await?; // 3. Install dependencies // Split dependencies by cli: prefix @@ -480,7 +480,7 @@ impl LanguageImpl for Rust { install_local_project( hook_bin, repo, - &info, + &env, &lib_deps, &cargo, &cargo_home, @@ -491,20 +491,20 @@ impl LanguageImpl for Rust { // Install CLI dependencies for cli_dep in cli_deps { - install_cli_dependency(cli_dep, &info, &cargo, &cargo_home, &new_path).await?; + install_cli_dependency(cli_dep, &env, &cargo, &cargo_home, &new_path).await?; } - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, _info: &InstallInfo) -> anyhow::Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> anyhow::Result<()> { Ok(()) } @@ -518,11 +518,11 @@ impl LanguageImpl for Rust { let progress = reporter.on_run_start(hook, filenames.len()); let env_dir = hook.env_path().expect("Rust hook must have env path"); - let info = hook.install_info().expect("Rust hook must be installed"); + let env = hook.installed_env().expect("Rust hook must be installed"); let rust_bin = bin_dir(env_dir); let cargo_home = store.cache_path(CacheBucket::Cargo); - let rustc_bin = bin_dir(&info.toolchain); + let rustc_bin = bin_dir(&env.toolchain); let new_path = prepend_paths(&[&rust_bin, &rustc_bin]).context("Failed to join PATH")?; diff --git a/crates/prek/src/languages/rust/version.rs b/crates/prek/src/languages/rust/version.rs index 192bd9a8f..dfee97ea2 100644 --- a/crates/prek/src/languages/rust/version.rs +++ b/crates/prek/src/languages/rust/version.rs @@ -3,7 +3,7 @@ use std::ops::Deref; use std::path::Path; use std::str::FromStr; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::version::{Error, try_into_u64_slice}; #[derive(Debug, Copy, Clone, Eq, PartialEq)] @@ -189,26 +189,26 @@ impl RustRequest { } } - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { match self { RustRequest::Any => { // Any request accepts any valid installation, or specifically "stable" - install_info + installed_env .get_extra(EXTRA_KEY_CHANNEL) .is_some_and(|ch| ch == "stable") - || install_info.language_version.major > 0 + || installed_env.language_version.major > 0 } RustRequest::Channel(requested_channel) => { - let channel = install_info + let channel = installed_env .get_extra(EXTRA_KEY_CHANNEL) .and_then(|ch| Channel::from_str(ch).ok()); channel.as_ref().is_some_and(|ch| ch == requested_channel) } _ => { - let version = &install_info.language_version; + let version = &installed_env.language_version; self.matches( &RustVersion::from_version(version), - Some(install_info.toolchain.as_ref()), + Some(installed_env.toolchain.as_ref()), ) } } @@ -239,8 +239,7 @@ impl RustRequest { mod tests { use super::*; use crate::config::Language; - use crate::hook::InstallInfo; - use rustc_hash::FxHashSet; + use crate::hook::InstalledHookEnv; use std::path::PathBuf; use std::str::FromStr; @@ -312,31 +311,34 @@ mod tests { } #[test] - fn test_request_satisfied_by_install_info() -> anyhow::Result<()> { + fn test_request_satisfied_by_installed_env() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; let toolchain_path = temp_dir.path().join("rust-toolchain"); std::fs::write(&toolchain_path, b"")?; - let mut install_info = - InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Rust, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(1, 71, 0)) .with_toolchain(toolchain_path.clone()); - assert!(RustRequest::Any.satisfied_by(&install_info)); - assert!(RustRequest::Major(1).satisfied_by(&install_info)); - assert!(RustRequest::MajorMinor(1, 71).satisfied_by(&install_info)); - assert!(RustRequest::MajorMinorPatch(1, 71, 0).satisfied_by(&install_info)); - assert!(!RustRequest::MajorMinorPatch(1, 71, 1).satisfied_by(&install_info)); + assert!(RustRequest::Any.satisfied_by(&installed_env)); + assert!(RustRequest::Major(1).satisfied_by(&installed_env)); + assert!(RustRequest::MajorMinor(1, 71).satisfied_by(&installed_env)); + assert!(RustRequest::MajorMinorPatch(1, 71, 0).satisfied_by(&installed_env)); + assert!(!RustRequest::MajorMinorPatch(1, 71, 1).satisfied_by(&installed_env)); let req = RustRequest::Range( semver::VersionReq::parse(">=1.70, <1.72")?, ">=1.70, <1.72".into(), ); - assert!(req.satisfied_by(&install_info)); + assert!(req.satisfied_by(&installed_env)); let req = RustRequest::Range(semver::VersionReq::parse(">=1.72")?, ">=1.72".into()); - assert!(!req.satisfied_by(&install_info)); + assert!(!req.satisfied_by(&installed_env)); Ok(()) } @@ -344,17 +346,20 @@ mod tests { #[test] fn test_satisfied_by_channel() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Rust, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")) .with_extra(EXTRA_KEY_CHANNEL, "stable"); // Channel request should match when extra is set - assert!(RustRequest::Channel(Channel::Stable).satisfied_by(&install_info)); - assert!(!RustRequest::Channel(Channel::Nightly).satisfied_by(&install_info)); - assert!(!RustRequest::Channel(Channel::Beta).satisfied_by(&install_info)); + assert!(RustRequest::Channel(Channel::Stable).satisfied_by(&installed_env)); + assert!(!RustRequest::Channel(Channel::Nightly).satisfied_by(&installed_env)); + assert!(!RustRequest::Channel(Channel::Beta).satisfied_by(&installed_env)); Ok(()) } @@ -362,15 +367,18 @@ mod tests { #[test] fn test_satisfied_by_any_with_stable_channel() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Rust, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")) .with_extra("rust_channel", "stable"); // Any request should match stable channel - assert!(RustRequest::Any.satisfied_by(&install_info)); + assert!(RustRequest::Any.satisfied_by(&installed_env)); Ok(()) } @@ -378,14 +386,17 @@ mod tests { #[test] fn test_satisfied_by_any_without_channel() -> anyhow::Result<()> { let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Rust, FxHashSet::default(), temp_dir.path())?; - install_info + let mut installed_env = InstalledHookEnv::new( + Language::Rust, + crate::hook_env::HookEnvIdentity::empty_dependencies(), + temp_dir.path(), + )?; + installed_env .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")); // No channel set - should still match Any if version > 0 - assert!(RustRequest::Any.satisfied_by(&install_info)); + assert!(RustRequest::Any.satisfied_by(&installed_env)); Ok(()) } diff --git a/crates/prek/src/languages/script.rs b/crates/prek/src/languages/script.rs index 9bdb76e2e..499ffdcec 100644 --- a/crates/prek/src/languages/script.rs +++ b/crates/prek/src/languages/script.rs @@ -6,7 +6,7 @@ use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; -use crate::hook::{Hook, InstallInfo}; +use crate::hook::{Hook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; @@ -25,7 +25,7 @@ impl LanguageImpl for Script { Ok(InstalledHook::NoNeedInstall(hook)) } - async fn check_health(&self, _info: &InstallInfo) -> Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> Result<()> { Ok(()) } diff --git a/crates/prek/src/languages/swift.rs b/crates/prek/src/languages/swift.rs index 69c550379..0aa30eb5b 100644 --- a/crates/prek/src/languages/swift.rs +++ b/crates/prek/src/languages/swift.rs @@ -9,7 +9,7 @@ use semver::Version; use tracing::debug; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; @@ -92,13 +92,13 @@ impl LanguageImpl for Swift { ) -> Result { let progress = reporter.on_install_start(&hook); - let mut info = InstallInfo::new( + let mut env = InstalledHookEnv::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_identity().into(), &store.hooks_dir(), )?; - debug!(%hook, target = %info.env_path.display(), "Installing Swift environment"); + debug!(%hook, target = %env.env_path.display(), "Installing Swift environment"); // Query swift info let swift_info = query_swift_info() @@ -109,7 +109,7 @@ impl LanguageImpl for Swift { if let Some(repo_path) = hook.repo_path() { if repo_path.join("Package.swift").exists() { debug!(%hook, "Building Swift package"); - let build_path = build_dir(&info.env_path); + let build_path = build_dir(&env.env_path); Cmd::new("swift", "swift build") .arg("build") .arg("-c") @@ -141,31 +141,31 @@ impl LanguageImpl for Swift { .trim() .to_string(); debug!(%hook, %bin_path, "Swift bin path"); - info.with_extra(BIN_PATH_KEY, &bin_path); + env.with_extra(BIN_PATH_KEY, &bin_path); } else { debug!(%hook, "No Package.swift found, skipping build"); } } - info.with_toolchain(swift_info.executable) + env.with_toolchain(swift_info.executable) .with_language_version(swift_info.version); - info.persist_env_path(); + env.persist(); reporter.on_install_complete(progress); Ok(InstalledHook::Installed { hook, - info: Arc::new(info), + env: Arc::new(env), }) } - async fn check_health(&self, info: &InstallInfo) -> Result<()> { + async fn check_health(&self, env: &InstalledHookEnv) -> Result<()> { // Verify swift still exists at the stored path - if !info.toolchain.exists() { + if !env.toolchain.exists() { anyhow::bail!( "Swift executable no longer exists at: {}", - info.toolchain.display() + env.toolchain.display() ); } @@ -181,9 +181,9 @@ impl LanguageImpl for Swift { ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); - // Get bin path from install info if a package was built + // Get bin path from the installed environment metadata if a package was built. let new_path = - if let Some(bin_path) = hook.install_info().and_then(|i| i.get_extra(BIN_PATH_KEY)) { + if let Some(bin_path) = hook.installed_env().and_then(|i| i.get_extra(BIN_PATH_KEY)) { prepend_paths(&[Path::new(bin_path)]).context("Failed to join PATH")? } else { EnvVars::var_os(EnvVars::PATH).unwrap_or_default() diff --git a/crates/prek/src/languages/system.rs b/crates/prek/src/languages/system.rs index c92d7f17e..1032b9068 100644 --- a/crates/prek/src/languages/system.rs +++ b/crates/prek/src/languages/system.rs @@ -5,7 +5,7 @@ use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; -use crate::hook::{Hook, InstallInfo, InstalledHook}; +use crate::hook::{Hook, InstalledHook, InstalledHookEnv}; use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; @@ -24,7 +24,7 @@ impl LanguageImpl for System { Ok(InstalledHook::NoNeedInstall(hook)) } - async fn check_health(&self, _info: &InstallInfo) -> Result<()> { + async fn check_health(&self, _env: &InstalledHookEnv) -> Result<()> { Ok(()) } diff --git a/crates/prek/src/languages/version.rs b/crates/prek/src/languages/version.rs index 4c7bd45bd..c180d38ee 100644 --- a/crates/prek/src/languages/version.rs +++ b/crates/prek/src/languages/version.rs @@ -1,7 +1,7 @@ use std::str::FromStr; use crate::config::Language; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::languages::bun::BunRequest; use crate::languages::deno::DenoRequest; use crate::languages::dotnet::DotnetRequest; @@ -113,18 +113,18 @@ impl LanguageRequest { }) } - pub(crate) fn satisfied_by(&self, install_info: &InstallInfo) -> bool { + pub(crate) fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { match self { LanguageRequest::Any { .. } => true, - LanguageRequest::Bun(req) => req.satisfied_by(install_info), - LanguageRequest::Dotnet(req) => req.satisfied_by(install_info), - LanguageRequest::Deno(req) => req.satisfied_by(install_info), - LanguageRequest::Golang(req) => req.satisfied_by(install_info), - LanguageRequest::Node(req) => req.satisfied_by(install_info), - LanguageRequest::Python(req) => req.satisfied_by(install_info), - LanguageRequest::Ruby(req) => req.satisfied_by(install_info), - LanguageRequest::Rust(req) => req.satisfied_by(install_info), - LanguageRequest::Semver(req) => req.satisfied_by(install_info), + LanguageRequest::Bun(req) => req.satisfied_by(installed_env), + LanguageRequest::Dotnet(req) => req.satisfied_by(installed_env), + LanguageRequest::Deno(req) => req.satisfied_by(installed_env), + LanguageRequest::Golang(req) => req.satisfied_by(installed_env), + LanguageRequest::Node(req) => req.satisfied_by(installed_env), + LanguageRequest::Python(req) => req.satisfied_by(installed_env), + LanguageRequest::Ruby(req) => req.satisfied_by(installed_env), + LanguageRequest::Rust(req) => req.satisfied_by(installed_env), + LanguageRequest::Semver(req) => req.satisfied_by(installed_env), } } } @@ -143,8 +143,8 @@ impl FromStr for SemverRequest { } impl SemverRequest { - fn satisfied_by(&self, install_info: &InstallInfo) -> bool { - self.0.matches(&install_info.language_version) + fn satisfied_by(&self, installed_env: &InstalledHookEnv) -> bool { + self.0.matches(&installed_env.language_version) } } diff --git a/crates/prek/src/store.rs b/crates/prek/src/store.rs index 49963d325..f3043c0fe 100644 --- a/crates/prek/src/store.rs +++ b/crates/prek/src/store.rs @@ -15,7 +15,7 @@ use tracing::{debug, warn}; use crate::config::{RemoteRepo, RemoteRepoKey}; use crate::fs::LockedFile; use crate::git::{self, TerminalPrompt}; -use crate::hook::InstallInfo; +use crate::hook::InstalledHookEnv; use crate::run::CONCURRENCY; use crate::warn_user; use crate::workspace::{HookInitReporter, WorkspaceCache}; @@ -288,7 +288,7 @@ impl Store { } /// Returns installed hooks in the store. - pub(crate) async fn installed_hooks(&self) -> Vec> { + pub(crate) async fn installed_hooks(&self) -> Vec> { let Ok(dirs) = fs_err::read_dir(self.hooks_dir()) else { return vec![]; }; @@ -302,14 +302,14 @@ impl Store { return None; } }; - let info = match InstallInfo::from_env_path(&path).await { - Ok(info) => info, + let env = match InstalledHookEnv::from_path(&path).await { + Ok(env) => env, Err(err) => { warn!(%err, path = %path.display(), "Skipping invalid installed hook"); return None; } }; - Some(info) + Some(env) }) .buffer_unordered(*CONCURRENCY); diff --git a/crates/prek/tests/cache.rs b/crates/prek/tests/cache.rs index 1d6006b8a..4b50a8b43 100644 --- a/crates/prek/tests/cache.rs +++ b/crates/prek/tests/cache.rs @@ -52,16 +52,21 @@ fn cache_gc_verbose_shows_removed_entries() { &serde_json::to_string_pretty(&json!({ "language": "python", "language_version": "3.12.0", - "dependencies": [ - "https://example.com/repo@v1.0.0", - "dep1", - "dep2", - "dep3", - "dep4", - "dep5", - "dep6", - "dep7", - ], + "identity": { + "kind": "dependencies", + "data": { + "dependencies": [ + "dep1", + "dep2", + "dep3", + "dep4", + "dep5", + "dep6", + "dep7", + ], + "remote_repo": "https://example.com/repo@v1.0.0", + }, + }, "env_path": home.child("hooks/hook-env-dead").path(), "toolchain": "/usr/bin/python3", "extra": {}, @@ -323,7 +328,12 @@ fn cache_gc_prunes_unused_tool_versions() -> anyhow::Result<()> { let marker_py = json!({ "language": "python", "language_version": "3.12.0", - "dependencies": [], + "identity": { + "kind": "dependencies", + "data": { + "dependencies": [], + }, + }, "env_path": env_py.path(), "toolchain": py_keep.child("bin/python").path(), "extra": {}, @@ -335,7 +345,12 @@ fn cache_gc_prunes_unused_tool_versions() -> anyhow::Result<()> { let marker_node = json!({ "language": "node", "language_version": "22.0.0", - "dependencies": [], + "identity": { + "kind": "dependencies", + "data": { + "dependencies": [], + }, + }, "env_path": env_node.path(), "toolchain": node_keep.child("bin/node").path(), "extra": {}, @@ -347,7 +362,12 @@ fn cache_gc_prunes_unused_tool_versions() -> anyhow::Result<()> { let marker_go = json!({ "language": "golang", "language_version": "1.24.0", - "dependencies": [], + "identity": { + "kind": "dependencies", + "data": { + "dependencies": [], + }, + }, "env_path": env_go.path(), "toolchain": go_keep.child("bin/go").path(), "extra": {}, @@ -433,7 +453,12 @@ fn cache_gc_prunes_tool_versions_without_positive_identification() -> anyhow::Re let marker_py = json!({ "language": "python", "language_version": "3.12.0", - "dependencies": [], + "identity": { + "kind": "dependencies", + "data": { + "dependencies": [], + }, + }, "env_path": env_py.path(), "toolchain": "/usr/bin/python3", "extra": {}, From 936415edf1816d5ceffea9908ab03e21f1646859 Mon Sep 17 00:00:00 2001 From: Jo <10510431+j178@users.noreply.github.com> Date: Fri, 8 May 2026 19:04:21 +0800 Subject: [PATCH 5/5] Compat with legacy hook env --- crates/prek/src/hook.rs | 186 ++++++++++++++++++++++++++++++++++-- crates/prek/src/hook_env.rs | 68 ++++++++++++- 2 files changed, 245 insertions(+), 9 deletions(-) diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index e8eedd01d..f14cc82c4 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -49,6 +49,7 @@ //! hook?". The latter includes the language and language-version request. use std::borrow::Cow; +use std::collections::BTreeSet; use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::path::{Path, PathBuf}; @@ -58,7 +59,8 @@ use anyhow::{Context, Result}; use prek_consts::PRE_COMMIT_HOOKS_YAML; use prek_identify::{TagSet, tags}; use rustc_hash::{FxHashMap, FxHashSet}; -use serde::{Deserialize, Serialize}; +use serde::de; +use serde::{Deserialize, Deserializer, Serialize}; use tempfile::TempDir; use thiserror::Error; use tracing::trace; @@ -682,7 +684,7 @@ impl InstalledHook { } } -#[derive(Debug, Deserialize, Serialize)] +#[derive(Debug, Serialize)] pub(crate) struct InstalledHookEnv { pub(crate) language: Language, pub(crate) language_version: semver::Version, @@ -691,6 +693,8 @@ pub(crate) struct InstalledHookEnv { pub(crate) toolchain: PathBuf, extra: FxHashMap, #[serde(skip, default)] + legacy_dependencies: Option>, + #[serde(skip, default)] temp_dir: Option, } @@ -703,11 +707,69 @@ impl Clone for InstalledHookEnv { env_path: self.env_path.clone(), toolchain: self.toolchain.clone(), extra: self.extra.clone(), + legacy_dependencies: self.legacy_dependencies.clone(), temp_dir: None, } } } +impl<'de> Deserialize<'de> for InstalledHookEnv { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + #[derive(Deserialize)] + struct Wire { + language: Language, + language_version: semver::Version, + identity: Option, + dependencies: Option>, + env_path: PathBuf, + toolchain: PathBuf, + #[serde(default)] + extra: FxHashMap, + } + + let Wire { + language, + language_version, + identity, + dependencies, + env_path, + toolchain, + extra, + } = Wire::deserialize(deserializer)?; + + // Older `.prek-hook.json` files stored the environment identity as a + // top-level `dependencies` list. Keep the original list for matching: + // a legacy remote repo marker and a PEP 508 direct URL dependency are + // ambiguous without the hook config that produced the environment. + let (identity, legacy_dependencies) = match (identity, dependencies) { + (Some(identity), _) => (identity, None), + (None, Some(dependencies)) => ( + HookEnvIdentity::from_legacy_dependencies(&dependencies), + Some(dependencies), + ), + (None, None) => { + return Err(de::Error::custom( + "missing field `identity` or legacy field `dependencies`", + )); + } + }; + + Ok(Self { + language, + language_version, + identity, + env_path, + toolchain, + extra, + legacy_dependencies, + temp_dir: None, + }) + } +} + impl InstalledHookEnv { pub(crate) fn new( language: Language, @@ -726,6 +788,7 @@ impl InstalledHookEnv { language_version: semver::Version::new(0, 0, 0), toolchain: PathBuf::new(), extra: FxHashMap::default(), + legacy_dependencies: None, temp_dir: Some(env_path), }) } @@ -772,16 +835,24 @@ impl InstalledHookEnv { /// Used when only the normalized request is available, such as cache GC scanning /// configured hooks without building full hook instances. pub(crate) fn satisfies(&self, request: HookEnvRequestRef<'_>) -> bool { - request.language.supports_install_env() - && self.language == request.language - && HookEnvIdentityRef::from(&self.identity) == request.identity - && request.language_request.satisfied_by(self) + if !request.language.supports_install_env() || self.language != request.language { + return false; + } + + let identity_satisfied = if let Some(dependencies) = &self.legacy_dependencies { + HookEnvIdentity::legacy_dependencies_satisfy(dependencies, request.identity) + } else { + self.identity.satisfies(request.identity) + }; + + identity_satisfied && request.language_request.satisfied_by(self) } } #[cfg(test)] mod tests { use std::borrow::Cow; + use std::collections::BTreeSet; use std::path::PathBuf; use std::sync::Arc; @@ -795,11 +866,110 @@ mod tests { RemoteHook, Shell, Stage, Stages, }; use crate::hook::HookSpec; - use crate::hook_env::{HookEnvIdentity, HookEnvIdentityRef}; + use crate::hook_env::{ + DependencyEnvIdentity, HookEnvIdentity, HookEnvIdentityRef, HookEnvRequestRef, + }; use crate::languages::version::LanguageRequest; use crate::workspace::Project; - use super::{Hook, HookBuilder, Repo}; + use super::{Hook, HookBuilder, InstalledHookEnv, Repo}; + + fn legacy_installed_env(dependencies: &[&str]) -> Result { + let temp = tempfile::tempdir()?; + let content = serde_json::json!({ + "language": "python", + "language_version": "3.12.0", + "dependencies": dependencies, + "env_path": temp.path(), + "toolchain": "/usr/bin/python3", + "extra": {}, + }); + + Ok(serde_json::from_value(content)?) + } + + fn python_env_request<'a>( + identity: &'a DependencyEnvIdentity, + language_request: &'a LanguageRequest, + ) -> HookEnvRequestRef<'a> { + HookEnvRequestRef { + language: Language::Python, + identity: HookEnvIdentityRef::Dependencies(identity), + language_request, + } + } + + #[test] + fn installed_hook_env_reads_legacy_dependencies_marker() -> Result<()> { + let remote_repo = "https://example.com/hooks@v1.0.0"; + let env = legacy_installed_env(&["dep1", remote_repo])?; + let HookEnvIdentity::Dependencies(identity) = &env.identity else { + panic!("expected dependency identity"); + }; + + assert_eq!(identity.remote_repo.as_deref(), Some(remote_repo)); + assert_eq!(identity.dependencies, BTreeSet::from(["dep1".to_string()])); + assert_eq!( + env.legacy_dependencies.as_ref(), + Some(&BTreeSet::from([ + "dep1".to_string(), + remote_repo.to_string() + ])) + ); + + Ok(()) + } + + #[test] + fn installed_hook_env_serializes_legacy_marker_as_identity() -> Result<()> { + let env = legacy_installed_env(&["dep1"])?; + let value = serde_json::to_value(&env)?; + + assert!(value.get("dependencies").is_none()); + assert_eq!( + value + .get("identity") + .and_then(|identity| identity.get("kind")), + Some(&serde_json::json!("dependencies")) + ); + + Ok(()) + } + + #[test] + fn installed_hook_env_legacy_dependencies_satisfy_remote_request() -> Result<()> { + let remote_repo = "https://example.com/hooks@v1.0.0"; + let env = legacy_installed_env(&["dep1", remote_repo])?; + let identity = DependencyEnvIdentity { + dependencies: BTreeSet::from(["dep1".to_string()]), + remote_repo: Some(remote_repo.to_string()), + }; + let language_request = LanguageRequest::parse(Language::Python, "3.12")?; + + assert!(env.satisfies(python_env_request(&identity, &language_request))); + + Ok(()) + } + + #[test] + fn installed_hook_env_legacy_dependencies_keep_direct_url_dependencies() -> Result<()> { + let direct_url = "sample @ https://example.com/sample-1.0.0.whl"; + let env = legacy_installed_env(&[direct_url])?; + let HookEnvIdentity::Dependencies(env_identity) = &env.identity else { + panic!("expected dependency identity"); + }; + let identity = DependencyEnvIdentity { + dependencies: BTreeSet::from([direct_url.to_string()]), + remote_repo: None, + }; + let language_request = LanguageRequest::parse(Language::Python, "3.12")?; + + assert_eq!(env_identity.remote_repo, None); + assert_eq!(env_identity.dependencies, identity.dependencies); + assert!(env.satisfies(python_env_request(&identity, &language_request))); + + Ok(()) + } #[tokio::test] async fn hook_builder_build_fills_and_merges_attributes() -> Result<()> { diff --git a/crates/prek/src/hook_env.rs b/crates/prek/src/hook_env.rs index 1ea4391fd..7150bb609 100644 --- a/crates/prek/src/hook_env.rs +++ b/crates/prek/src/hook_env.rs @@ -93,6 +93,26 @@ impl HookEnvIdentity { pub(crate) fn empty_dependencies() -> Self { Self::Dependencies(DependencyEnvIdentity::new(&FxHashSet::default(), None)) } + + pub(crate) fn from_legacy_dependencies(dependencies: &BTreeSet) -> Self { + Self::Dependencies(DependencyEnvIdentity::from_legacy_dependencies( + dependencies, + )) + } + + pub(crate) fn satisfies(&self, request: HookEnvIdentityRef<'_>) -> bool { + HookEnvIdentityRef::from(self) == request + } + + pub(crate) fn legacy_dependencies_satisfy( + dependencies: &BTreeSet, + request: HookEnvIdentityRef<'_>, + ) -> bool { + match request { + HookEnvIdentityRef::Dependencies(identity) => identity.matches_legacy(dependencies), + HookEnvIdentityRef::PythonUv(_) => false, + } + } } /// Borrowed view of an environment identity. @@ -126,7 +146,7 @@ impl<'a> From<&'a HookEnvIdentity> for HookEnvIdentityRef<'a> { impl Eq for HookEnvIdentityRef<'_> {} -impl<'a, 'b> PartialEq> for HookEnvIdentityRef<'a> { +impl<'b> PartialEq> for HookEnvIdentityRef<'_> { fn eq(&self, other: &HookEnvIdentityRef<'b>) -> bool { match (self, other) { (Self::Dependencies(left), HookEnvIdentityRef::Dependencies(right)) => left == right, @@ -154,6 +174,52 @@ impl DependencyEnvIdentity { remote_repo: remote_repo.map(str::to_string), } } + + fn from_legacy_dependencies(dependencies: &BTreeSet) -> Self { + let remote_repo = dependencies + .iter() + .find(|dependency| is_legacy_remote_repo_dependency(dependency)) + .cloned(); + let mut dependencies = dependencies.clone(); + if let Some(remote_repo) = &remote_repo { + dependencies.remove(remote_repo); + } + + Self { + dependencies, + remote_repo, + } + } + + // Match an old `.prek-hook.json` dependency set without trusting the + // lossy split performed by `from_legacy_dependencies`. + // + // Older markers stored both user dependencies and the remote repo marker in + // one flat list. When reading them, we build a best-effort structured + // identity for display and future serialization, but reuse decisions should + // still preserve the old exact-list semantics. + fn matches_legacy(&self, legacy_dependencies: &BTreeSet) -> bool { + let expected_len = self.dependencies.len() + usize::from(self.remote_repo.is_some()); + legacy_dependencies.len() == expected_len + && self + .dependencies + .iter() + .all(|dependency| legacy_dependencies.contains(dependency)) + && self + .remote_repo + .as_ref() + .is_none_or(|remote_repo| legacy_dependencies.contains(remote_repo)) + } +} + +fn is_legacy_remote_repo_dependency(dependency: &str) -> bool { + !dependency.contains(" @ ") + && dependency.contains('@') + && (dependency.contains("://") + || dependency.starts_with("git@") + || dependency.starts_with('/') + || dependency.starts_with("..") + || dependency.starts_with('.')) } /// Identity for `language: python_uv` environments.