diff --git a/crates/prek/src/cli/cache_gc.rs b/crates/prek/src/cli/cache_gc.rs index c885f69fd..40f7cbd98 100644 --- a/crates/prek/src/cli/cache_gc.rs +++ b/crates/prek/src/cli/cache_gc.rs @@ -694,7 +694,7 @@ fn truncate_end(s: &str, max_chars: usize) -> String { out } -fn split_repo_dependency(deps: &FxHashSet) -> (Option, Vec) { +fn split_repo_dependency(deps: &[String]) -> (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; @@ -714,7 +714,6 @@ fn split_repo_dependency(deps: &FxHashSet) -> (Option, Vec>(); + let additional_dependencies = options.additional_dependencies.unwrap_or_default(); let language_request = LanguageRequest::parse(self.hook_spec.language, &language_version) .map_err(|e| Error::Hook { @@ -482,7 +478,7 @@ pub(crate) struct Hook { project: Arc, repo: Arc, // Cached computed dependencies. - dependencies: OnceLock>, + dependencies: OnceLock>, /// The index of the hook defined in the configuration file. pub idx: usize, @@ -496,7 +492,7 @@ pub(crate) struct Hook { pub types: Vec, pub types_or: Vec, pub exclude_types: Vec, - pub additional_dependencies: FxHashSet, + pub additional_dependencies: Vec, pub args: Vec, pub env: FxHashMap, pub always_run: bool, @@ -558,7 +554,7 @@ 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 { + pub(crate) fn env_key_dependencies(&self) -> &[String] { if !self.is_remote() { return &self.additional_dependencies; } @@ -586,10 +582,11 @@ impl Hook { /// /// For remote hooks, this includes the local path to the cloned repository so that /// installers can install the hook's package/project itself. - pub(crate) fn install_dependencies(&self) -> Cow<'_, FxHashSet> { + pub(crate) fn install_dependencies(&self) -> Cow<'_, [String]> { if let Some(repo_path) = self.repo_path() { - let mut deps = self.additional_dependencies.clone(); - deps.insert(repo_path.to_string_lossy().to_string()); + let mut deps = Vec::with_capacity(self.additional_dependencies.len() + 1); + deps.push(repo_path.to_string_lossy().to_string()); + deps.extend(self.additional_dependencies.iter().cloned()); Cow::Owned(deps) } else { Cow::Borrowed(&self.additional_dependencies) @@ -600,7 +597,7 @@ impl Hook { #[derive(Debug, Clone)] pub(crate) struct HookEnvKey { pub(crate) language: Language, - pub(crate) dependencies: FxHashSet, + pub(crate) dependencies: Vec, pub(crate) language_request: LanguageRequest, } @@ -609,25 +606,24 @@ pub(crate) struct HookEnvKey { #[derive(Debug, Clone, Copy)] pub(crate) struct HookEnvKeyRef<'a> { pub(crate) language: Language, - pub(crate) dependencies: &'a FxHashSet, + pub(crate) dependencies: &'a [String], pub(crate) language_request: &'a LanguageRequest, } -/// Builds the dependency set used to identify a hook environment. +/// Builds the dependency list 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, + additional_dependencies: &[String], remote_repo_dependency: Option<&str>, -) -> FxHashSet { - let mut deps = FxHashSet::with_capacity_and_hasher( +) -> Vec { + let mut deps = Vec::with_capacity( 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.push(dep.to_string()); } deps } @@ -636,7 +632,7 @@ fn env_key_dependencies( /// environment described by [`InstallInfo`]. fn matches_install_info( language: Language, - dependencies: &FxHashSet, + dependencies: &[String], language_request: &LanguageRequest, info: &InstallInfo, ) -> bool { @@ -674,11 +670,11 @@ impl HookEnvKey { ) })?; - let additional_dependencies: FxHashSet = hook_spec + let additional_dependencies: Vec = hook_spec .options .additional_dependencies - .as_ref() - .map_or_else(FxHashSet::default, |deps| deps.iter().cloned().collect()); + .clone() + .unwrap_or_default(); let dependencies = env_key_dependencies(&additional_dependencies, remote_repo_dependency); @@ -786,7 +782,7 @@ impl InstalledHook { pub(crate) struct InstallInfo { pub(crate) language: Language, pub(crate) language_version: semver::Version, - pub(crate) dependencies: FxHashSet, + pub(crate) dependencies: Vec, pub(crate) env_path: PathBuf, pub(crate) toolchain: PathBuf, extra: FxHashMap, @@ -811,7 +807,7 @@ impl Clone for InstallInfo { impl InstallInfo { pub(crate) fn new( language: Language, - dependencies: FxHashSet, + dependencies: Vec, hooks_dir: &Path, ) -> Result { let env_path = tempfile::Builder::new() @@ -1004,7 +1000,7 @@ mod tests { ], types_or: [], exclude_types: [], - additional_dependencies: {}, + additional_dependencies: [], args: [ "--flag", ], @@ -1039,4 +1035,83 @@ mod tests { Ok(()) } + + #[tokio::test] + async fn hook_builder_preserves_additional_dependency_order_and_duplicates() -> 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 expected = vec![ + "--index-url".to_string(), + "https://pypi.org/simple".to_string(), + "pyecho-cli".to_string(), + "pyecho-cli".to_string(), + ]; + + let hook_spec = HookSpec { + id: "ordered-deps".to_string(), + name: "ordered-deps".to_string(), + entry: "python -c 'print(1)'".to_string(), + language: Language::Python, + priority: None, + options: HookOptions { + additional_dependencies: Some(expected.clone()), + ..Default::default() + }, + }; + + let hook = HookBuilder::new(project.clone(), repo.clone(), hook_spec, 0) + .build() + .await?; + assert_eq!(hook.additional_dependencies, expected); + + let hook_a = HookBuilder::new( + project.clone(), + repo.clone(), + HookSpec { + id: "a".to_string(), + name: "a".to_string(), + entry: "python -c 'print(1)'".to_string(), + language: Language::Python, + priority: None, + options: HookOptions { + additional_dependencies: Some(vec!["foo".to_string(), "bar".to_string()]), + ..Default::default() + }, + }, + 1, + ) + .build() + .await?; + + let hook_b = HookBuilder::new( + project, + repo, + HookSpec { + id: "b".to_string(), + name: "b".to_string(), + entry: "python -c 'print(1)'".to_string(), + language: Language::Python, + priority: None, + options: HookOptions { + additional_dependencies: Some(vec!["bar".to_string(), "foo".to_string()]), + ..Default::default() + }, + }, + 2, + ) + .build() + .await?; + + assert_ne!(hook_a.env_key_dependencies(), hook_b.env_key_dependencies()); + + Ok(()) + } } diff --git a/crates/prek/src/languages/bun/bun.rs b/crates/prek/src/languages/bun/bun.rs index 285142862..2c697906a 100644 --- a/crates/prek/src/languages/bun/bun.rs +++ b/crates/prek/src/languages/bun/bun.rs @@ -53,7 +53,7 @@ impl LanguageImpl for Bun { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; diff --git a/crates/prek/src/languages/docker.rs b/crates/prek/src/languages/docker.rs index 21bc6aee5..05db453ec 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}; @@ -323,8 +322,7 @@ impl Docker { info.language.hash(&mut hasher); info.language_version.hash(&mut hasher); - let deps = info.dependencies.iter().collect::>(); - deps.hash(&mut hasher); + info.dependencies.hash(&mut hasher); let digest = hex::encode(hasher.finish().to_le_bytes()); format!("prek-{digest}") @@ -436,7 +434,7 @@ impl LanguageImpl for Docker { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; diff --git a/crates/prek/src/languages/golang/golang.rs b/crates/prek/src/languages/golang/golang.rs index f77a25958..0545e93dd 100644 --- a/crates/prek/src/languages/golang/golang.rs +++ b/crates/prek/src/languages/golang/golang.rs @@ -45,7 +45,7 @@ impl LanguageImpl for Golang { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; info.with_toolchain(go.bin().to_path_buf()) diff --git a/crates/prek/src/languages/haskell.rs b/crates/prek/src/languages/haskell.rs index 4a095d7c4..b41c5f05d 100644 --- a/crates/prek/src/languages/haskell.rs +++ b/crates/prek/src/languages/haskell.rs @@ -33,7 +33,7 @@ impl LanguageImpl for Haskell { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; diff --git a/crates/prek/src/languages/julia.rs b/crates/prek/src/languages/julia.rs index 24edbc19f..18dbf0cf0 100644 --- a/crates/prek/src/languages/julia.rs +++ b/crates/prek/src/languages/julia.rs @@ -26,7 +26,7 @@ impl LanguageImpl for Julia { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; diff --git a/crates/prek/src/languages/lua.rs b/crates/prek/src/languages/lua.rs index 014976e16..9aa012184 100644 --- a/crates/prek/src/languages/lua.rs +++ b/crates/prek/src/languages/lua.rs @@ -65,7 +65,7 @@ impl LanguageImpl for Lua { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; diff --git a/crates/prek/src/languages/node/node.rs b/crates/prek/src/languages/node/node.rs index 3fa42edc1..1c9d89354 100644 --- a/crates/prek/src/languages/node/node.rs +++ b/crates/prek/src/languages/node/node.rs @@ -54,7 +54,7 @@ impl LanguageImpl for Node { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; diff --git a/crates/prek/src/languages/node/version.rs b/crates/prek/src/languages/node/version.rs index 02f6c8944..4ceff4d76 100644 --- a/crates/prek/src/languages/node/version.rs +++ b/crates/prek/src/languages/node/version.rs @@ -257,7 +257,6 @@ mod tests { use super::{EXTRA_KEY_LTS, NodeRequest}; use crate::config::Language; use crate::hook::InstallInfo; - use rustc_hash::FxHashSet; use std::path::PathBuf; use std::str::FromStr; @@ -310,8 +309,7 @@ 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())?; + let mut install_info = InstallInfo::new(Language::Node, Vec::new(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(12, 18, 3)) .with_toolchain(PathBuf::from("/usr/bin/node")) diff --git a/crates/prek/src/languages/pygrep/pygrep.rs b/crates/prek/src/languages/pygrep/pygrep.rs index 632c920c1..371561442 100644 --- a/crates/prek/src/languages/pygrep/pygrep.rs +++ b/crates/prek/src/languages/pygrep/pygrep.rs @@ -156,7 +156,7 @@ impl LanguageImpl for Pygrep { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; info.with_toolchain(python); diff --git a/crates/prek/src/languages/python/pep723.rs b/crates/prek/src/languages/python/pep723.rs index c704362a4..261c1cb4e 100644 --- a/crates/prek/src/languages/python/pep723.rs +++ b/crates/prek/src/languages/python/pep723.rs @@ -273,7 +273,7 @@ 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(); + hook.additional_dependencies = dependencies; } if let Some(language_request) = script.metadata.requires_python { if !hook.language_request.is_any() { diff --git a/crates/prek/src/languages/python/python.rs b/crates/prek/src/languages/python/python.rs index 8059df441..82fa9bf35 100644 --- a/crates/prek/src/languages/python/python.rs +++ b/crates/prek/src/languages/python/python.rs @@ -14,7 +14,7 @@ use tracing::{debug, trace}; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; -use crate::hook::{Hook, InstallInfo}; +use crate::hook::{Hook, InstallInfo, Repo}; use crate::languages::LanguageImpl; use crate::languages::python::PythonRequest; use crate::languages::python::uv::Uv; @@ -109,7 +109,7 @@ impl LanguageImpl for Python { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; @@ -153,14 +153,29 @@ impl LanguageImpl for Python { .output() .await?; } else if !hook.additional_dependencies.is_empty() { - trace!( - "Installing additional dependencies: {:?}", - hook.additional_dependencies - ); - pip_install() - .args(&hook.additional_dependencies) - .output() - .await?; + if matches!(hook.repo(), Repo::Local { .. }) { + let local_repo = store.local_repo_path()?; + trace!( + "Installing dependencies from synthetic local repo path: {}", + local_repo.display() + ); + pip_install() + .arg("--directory") + .arg(local_repo) + .arg(".") + .args(&hook.additional_dependencies) + .output() + .await?; + } else { + trace!( + "Installing additional dependencies: {:?}", + hook.additional_dependencies + ); + pip_install() + .args(&hook.additional_dependencies) + .output() + .await?; + } } else { debug!("No dependencies to install"); } diff --git a/crates/prek/src/languages/python/version.rs b/crates/prek/src/languages/python/version.rs index f30555524..432f6411e 100644 --- a/crates/prek/src/languages/python/version.rs +++ b/crates/prek/src/languages/python/version.rs @@ -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,8 +217,7 @@ 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())?; + let mut install_info = InstallInfo::new(Language::Python, Vec::new(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(3, 12, 1)) .with_toolchain(PathBuf::from("/usr/bin/python3.12")); diff --git a/crates/prek/src/languages/ruby/gem.rs b/crates/prek/src/languages/ruby/gem.rs index 712d59424..646c22056 100644 --- a/crates/prek/src/languages/ruby/gem.rs +++ b/crates/prek/src/languages/ruby/gem.rs @@ -3,7 +3,6 @@ use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use prek_consts::env_vars::EnvVars; -use rustc_hash::FxHashSet; use tracing::debug; use crate::languages::ruby::installer::RubyResult; @@ -84,7 +83,7 @@ pub(crate) async fn install_gems( ruby: &RubyResult, gem_home: &Path, repo_path: Option<&Path>, - additional_dependencies: &FxHashSet, + additional_dependencies: &[String], ) -> Result<()> { let mut gem_files = Vec::new(); diff --git a/crates/prek/src/languages/ruby/ruby.rs b/crates/prek/src/languages/ruby/ruby.rs index d3d131aea..9414312b0 100644 --- a/crates/prek/src/languages/ruby/ruby.rs +++ b/crates/prek/src/languages/ruby/ruby.rs @@ -47,7 +47,7 @@ impl LanguageImpl for Ruby { // 2. Create InstallInfo let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; diff --git a/crates/prek/src/languages/ruby/version.rs b/crates/prek/src/languages/ruby/version.rs index cf0409f11..f8ad4b9fe 100644 --- a/crates/prek/src/languages/ruby/version.rs +++ b/crates/prek/src/languages/ruby/version.rs @@ -120,7 +120,6 @@ impl RubyRequest { mod tests { use super::*; use crate::config::Language; - use rustc_hash::FxHashSet; #[test] fn test_parse_ruby_request() { @@ -165,8 +164,7 @@ 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())?; + let mut install_info = InstallInfo::new(Language::Ruby, Vec::new(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(3, 3, 6)) .with_toolchain(PathBuf::from("/usr/bin/ruby")); @@ -189,8 +187,7 @@ mod tests { ); let temp_dir = tempfile::tempdir()?; - let mut install_info = - InstallInfo::new(Language::Ruby, FxHashSet::default(), temp_dir.path())?; + let mut install_info = InstallInfo::new(Language::Ruby, Vec::new(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(3, 1, 0)) .with_toolchain(PathBuf::from("/usr/bin/ruby3.1")); diff --git a/crates/prek/src/languages/rust/rust.rs b/crates/prek/src/languages/rust/rust.rs index ee284884e..c2c720d9d 100644 --- a/crates/prek/src/languages/rust/rust.rs +++ b/crates/prek/src/languages/rust/rust.rs @@ -346,7 +346,7 @@ impl LanguageImpl for Rust { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; info.with_toolchain(rust.toolchain().to_path_buf()) diff --git a/crates/prek/src/languages/rust/version.rs b/crates/prek/src/languages/rust/version.rs index 04db99b6a..fc9a53029 100644 --- a/crates/prek/src/languages/rust/version.rs +++ b/crates/prek/src/languages/rust/version.rs @@ -236,7 +236,6 @@ mod tests { use super::*; use crate::config::Language; use crate::hook::InstallInfo; - use rustc_hash::FxHashSet; use std::path::PathBuf; use std::str::FromStr; @@ -313,8 +312,7 @@ mod tests { 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())?; + let mut install_info = InstallInfo::new(Language::Rust, Vec::new(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(1, 71, 0)) .with_toolchain(toolchain_path.clone()); @@ -340,8 +338,7 @@ 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())?; + let mut install_info = InstallInfo::new(Language::Rust, Vec::new(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")) @@ -358,8 +355,7 @@ 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())?; + let mut install_info = InstallInfo::new(Language::Rust, Vec::new(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")) @@ -374,8 +370,7 @@ 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())?; + let mut install_info = InstallInfo::new(Language::Rust, Vec::new(), temp_dir.path())?; install_info .with_language_version(semver::Version::new(1, 75, 0)) .with_toolchain(PathBuf::from("/some/path")); diff --git a/crates/prek/src/languages/swift.rs b/crates/prek/src/languages/swift.rs index 5e1004c9b..7c0382dbd 100644 --- a/crates/prek/src/languages/swift.rs +++ b/crates/prek/src/languages/swift.rs @@ -94,7 +94,7 @@ impl LanguageImpl for Swift { let mut info = InstallInfo::new( hook.language, - hook.env_key_dependencies().clone(), + hook.env_key_dependencies().to_vec(), &store.hooks_dir(), )?; diff --git a/crates/prek/src/store.rs b/crates/prek/src/store.rs index 4c9989ed3..874900c11 100644 --- a/crates/prek/src/store.rs +++ b/crates/prek/src/store.rs @@ -42,6 +42,8 @@ fn expand_tilde(path: PathBuf) -> PathBuf { } pub(crate) const REPO_MARKER: &str = ".prek-repo.json"; +const LOCAL_REPO_DIR: &str = "local-repo"; +const LOCAL_PYTHON_SETUP_PY: &str = "from setuptools import setup\n\n\nsetup(name='prek-placeholder-package', version='0.0.0', py_modules=[])\n"; /// A store for managing repos. #[derive(Debug)] @@ -189,6 +191,19 @@ impl Store { self.path.join("repos") } + /// Returns the synthetic local repository path for Python `repo: local` hooks. + pub(crate) fn local_repo_path(&self) -> Result { + let local_repo = self.path.join(LOCAL_REPO_DIR); + fs_err::create_dir_all(&local_repo)?; + + let setup_py = local_repo.join("setup.py"); + if !setup_py.try_exists()? { + fs_err::write(setup_py, LOCAL_PYTHON_SETUP_PY)?; + } + + Ok(local_repo) + } + pub(crate) fn hooks_dir(&self) -> PathBuf { self.path.join("hooks") } diff --git a/crates/prek/tests/languages/python.rs b/crates/prek/tests/languages/python.rs index 3aa720b31..d472ed532 100644 --- a/crates/prek/tests/languages/python.rs +++ b/crates/prek/tests/languages/python.rs @@ -1,5 +1,5 @@ use assert_fs::assert::PathAssert; -use assert_fs::fixture::{FileWriteStr, PathChild}; +use assert_fs::fixture::{FileWriteStr, PathChild, PathCreateDir}; use prek_consts::PRE_COMMIT_HOOKS_YAML; use prek_consts::env_vars::EnvVars; @@ -321,6 +321,87 @@ fn additional_dependencies_in_remote_repo() -> anyhow::Result<()> { Ok(()) } +#[test] +fn local_additional_dependencies_dot_uses_placeholder_repo() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + // This must not run. Local Python hook installs should use a synthetic placeholder repo. + context + .work_dir() + .child("setup.py") + .write_str(indoc::indoc! {r#" + import sys + sys.exit("ERROR: project setup.py should not run for repo: local installs") + "#})?; + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: check-dot-extra + name: check-dot-extra + language: python + entry: python -c "print('ok')" + additional_dependencies: ["."] + always_run: true + "#}); + context.git_add("."); + + let output = context.run().output()?; + assert!(output.status.success()); + + Ok(()) +} + +/// For `repo: local`, relative path dependencies are resolved in the synthetic +/// placeholder repository (pre-commit-compatible), not the project root. +#[test] +fn local_additional_dependencies_relative_path_not_from_project_root() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let pkg_dir = context.work_dir().child("local_pkg"); + pkg_dir.create_dir_all()?; + pkg_dir.child("setup.py").write_str(indoc::indoc! {r#" + from setuptools import setup + + setup( + name="local-pkg", + version="0.1.0", + py_modules=["local_pkg"], + ) + "#})?; + pkg_dir + .child("local_pkg.py") + .write_str("def hello():\n print('hello')\n")?; + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: local-relative-dep + name: local-relative-dep + language: python + entry: python -c "import local_pkg; local_pkg.hello()" + pass_filenames: false + always_run: true + additional_dependencies: ["./local_pkg"] + "#}); + context.git_add("."); + + let output = context.run().output()?; + assert!( + !output.status.success(), + "Expected local relative dependency install to fail because it should not resolve from project root" + ); + + let stderr = String::from_utf8_lossy(&output.stderr); + assert!(stderr.contains("Failed to install hook `local-relative-dep`")); + + Ok(()) +} + /// Ensure that stderr from hooks is captured and shown to the user. #[test] fn hook_stderr() -> anyhow::Result<()> { @@ -428,14 +509,14 @@ fn pep723_script() -> anyhow::Result<()> { /// Regression test for #[test] fn git_env_vars_not_leaked_to_pip_install() -> anyhow::Result<()> { + let repo = TestContext::new(); + repo.init_project(); + let context = TestContext::new(); - context.init_project(); + let repo_path = repo.work_dir(); // setup.py that fails if GIT_DIR leaks into pip install - context - .work_dir() - .child("setup.py") - .write_str(indoc::indoc! {r#" + repo_path.child("setup.py").write_str(indoc::indoc! {r#" import os, sys from setuptools import setup if os.environ.get("GIT_DIR"): @@ -443,17 +524,29 @@ fn git_env_vars_not_leaked_to_pip_install() -> anyhow::Result<()> { setup(name="test", version="0.1.0", extras_require={"test": []}) "#})?; - context.write_pre_commit_config(indoc::indoc! {r#" + repo_path + .child(PRE_COMMIT_HOOKS_YAML) + .write_str(indoc::indoc! {r#" + - id: check-no-git-dir + name: check-no-git-dir + language: python + entry: python -c "print('ok')" + additional_dependencies: [".[test]"] + "#})?; + repo.git_add("."); + repo.git_commit("Add hook"); + repo.git_tag("v0.1.0"); + + context.init_project(); + + context.write_pre_commit_config(&indoc::formatdoc! {r" repos: - - repo: local + - repo: {} + rev: v0.1.0 hooks: - id: check-no-git-dir - name: check-no-git-dir - language: python - entry: python -c "print('ok')" - additional_dependencies: [".[test]"] always_run: true - "#}); + ", repo_path.display()}); context.git_add("."); diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index 25ab70188..63430212e 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -2280,27 +2280,12 @@ fn selectors_completion() -> Result<()> { Ok(()) } -/// Test reusing hook environments only when dependencies are exactly same. (ignore order) +/// Test reusing hook environments only when dependencies are exactly the same. #[test] fn reuse_env() -> Result<()> { let context = TestContext::new(); context.init_project(); - let pkg_dir = context.work_dir().child("local_pkg"); - pkg_dir.create_dir_all()?; - pkg_dir.child("setup.py").write_str(indoc::indoc! {r#" - from setuptools import setup - - setup( - name="local-pkg", - version="0.1.0", - py_modules=["local_pkg"], - ) - "#})?; - pkg_dir - .child("local_pkg.py") - .write_str("def hello():\n print('hello')\n")?; - context.write_pre_commit_config(indoc::indoc! {r#" repos: - repo: local @@ -2308,9 +2293,9 @@ fn reuse_env() -> Result<()> { - id: reuse-env name: reuse-env language: python - entry: python -c "import local_pkg; local_pkg.hello()" + entry: pyecho hello pass_filenames: false - additional_dependencies: ["./local_pkg"] + additional_dependencies: ["pyecho-cli"] verbose: true "#}); context.git_add(".");