diff --git a/crates/prek/src/cli/exec.rs b/crates/prek/src/cli/exec.rs new file mode 100644 index 000000000..43c4d05a2 --- /dev/null +++ b/crates/prek/src/cli/exec.rs @@ -0,0 +1,91 @@ +use std::ffi::OsString; +use std::path::PathBuf; +use std::sync::Arc; + +use anyhow::{Context, Result}; + +use crate::cli::ExitStatus; +use crate::cli::reporter::{HookInitReporter, HookInstallReporter}; +use crate::cli::run::{InstallCache, Selectors, install_hooks}; +use crate::fs::CWD; +use crate::printer::Printer; +use crate::store::Store; +use crate::workspace::{HookInitFilters, Workspace}; + +pub(crate) async fn exec( + store: &Store, + config: Option, + selector: String, + command: Vec, + refresh: bool, + printer: Printer, +) -> Result { + let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?; + let selectors = Selectors::from_include(&selector, &workspace_root)?; + let workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?; + + let init_reporter = HookInitReporter::new(printer); + let lock = store.lock_async().await?; + store.track_configs(workspace.config_files())?; + + let hooks = workspace + .init_hooks( + store, + HookInitFilters::new(Some(&selectors), None), + Some(&init_reporter), + ) + .await + .context("Failed to init hooks")? + .into_iter() + .filter(|hook| selectors.matches_hook(hook)) + .map(Arc::new) + .collect::>(); + + let hook = match hooks.as_slice() { + [] => anyhow::bail!("Hook selector `{selector}` did not match any hooks"), + [hook] => Arc::clone(hook), + _ => { + let matches = hooks + .iter() + .map(|hook| format!(" - {}", hook.full_id())) + .collect::>() + .join("\n"); + anyhow::bail!( + "Hook selector `{selector}` matched multiple hooks:\n{matches}\nUse a `project-path:hook-id` selector to select one hook" + ); + } + }; + hook.language.ensure_exec_supported(&hook)?; + + let install_reporter = HookInstallReporter::new(printer); + let mut install_cache = InstallCache::new(); + let installed_hooks = + install_hooks(vec![hook], store, &install_reporter, &mut install_cache).await?; + install_reporter.on_complete(); + let installed_hook = installed_hooks + .into_iter() + .next() + .context("Failed to prepare the selected hook environment")?; + drop(lock); + + let status = installed_hook + .language + .exec(store, &installed_hook, &CWD, &command) + .await?; + Ok(external_exit_status(status)) +} + +fn external_exit_status(status: std::process::ExitStatus) -> ExitStatus { + let code = status.code().and_then(|code| u8::try_from(code).ok()); + + #[cfg(unix)] + let code = code.or_else(|| { + use std::os::unix::process::ExitStatusExt; + + status + .signal() + .and_then(|signal| u8::try_from(128 + signal).ok()) + }); + + code.unwrap_or(1).into() +} diff --git a/crates/prek/src/cli/hook_impl.rs b/crates/prek/src/cli/hook_impl.rs index 46c5c2f61..81b87c1f5 100644 --- a/crates/prek/src/cli/hook_impl.rs +++ b/crates/prek/src/cli/hook_impl.rs @@ -215,7 +215,7 @@ async fn run_legacy( return Ok(0); } - let entry = resolve_command(vec![legacy_hook.into_os_string()], None); + let entry = resolve_command(vec![legacy_hook.into_os_string()], None, &CWD); let mut cmd = Cmd::new(&entry[0]); cmd.check(false).args(&entry[1..]).args(args); cmd.env(EnvVars::PREK_RUNNING_LEGACY, "1"); diff --git a/crates/prek/src/cli/mod.rs b/crates/prek/src/cli/mod.rs index 7d1080dae..2b7d3a8ee 100644 --- a/crates/prek/src/cli/mod.rs +++ b/crates/prek/src/cli/mod.rs @@ -17,6 +17,7 @@ mod cache_clean; mod cache_gc; mod cache_size; mod completion; +mod exec; mod hook_impl; mod identify; mod install; @@ -36,6 +37,7 @@ pub(crate) use cache_clean::cache_clean; pub(crate) use cache_gc::cache_gc; pub(crate) use cache_size::cache_size; use completion::selector_completer; +pub(crate) use exec::exec; pub(crate) use hook_impl::hook_impl; pub(crate) use identify::identify; pub(crate) use install::{init_template_dir, install, prepare_hooks, uninstall}; @@ -259,6 +261,8 @@ pub(crate) enum Command { PrepareHooks(PrepareHooksArgs), /// Run configured hooks. Run(Box), + /// Run a command in the environment prepared for a configured hook. + Exec(ExecArgs), /// List configured hooks. List(ListArgs), /// Uninstall prek Git hook shims. @@ -295,6 +299,30 @@ pub(crate) enum Command { Self_(SelfNamespace), } +#[derive(Debug, Args)] +pub(crate) struct ExecArgs { + /// Hook whose execution environment should be used. + /// + /// Supports `hook-id` and `project-path:hook-id` selectors and must resolve + /// to exactly one configured hook. + #[arg( + value_name = "HOOK", + value_hint = ValueHint::Other, + add = ArgValueCompleter::new(selector_completer) + )] + pub(crate) selector: String, + + /// Command and arguments to execute. + #[arg( + value_name = "COMMAND", + required = true, + num_args = 1.., + last = true, + value_hint = ValueHint::CommandWithArguments + )] + pub(crate) command: Vec, +} + #[derive(Debug, Args)] pub(crate) struct InstallArgs { /// Include the specified hooks or projects. diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index ffb400699..469425dac 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -88,12 +88,7 @@ pub(crate) async fn run( let reporter = HookInitReporter::new(printer); let hooks = { let _lock = store.lock_async().await?; - store.track_configs( - workspace - .projects() - .iter() - .map(|project| project.config_file()), - )?; + store.track_configs(workspace.config_files())?; workspace .init_hooks( diff --git a/crates/prek/src/cli/run/selector.rs b/crates/prek/src/cli/run/selector.rs index ec5d9491c..a1313dd4c 100644 --- a/crates/prek/src/cli/run/selector.rs +++ b/crates/prek/src/cli/run/selector.rs @@ -193,6 +193,23 @@ pub(crate) struct Selectors { } impl Selectors { + /// Create selectors for one explicit include without applying skip environment variables. + pub(crate) fn from_include(include: &str, workspace_root: &Path) -> Result { + let include = parse_single_selector( + include, + workspace_root, + SelectorSource::CliArg, + RealFileSystem, + )?; + trace!("Include selector: `{include}`"); + + Ok(Self { + includes: vec![include], + skips: Vec::new(), + usage: Arc::default(), + }) + } + /// Load include and skip selectors from CLI args and environment variables. pub(crate) fn load( includes: &[String], diff --git a/crates/prek/src/hook_entry.rs b/crates/prek/src/hook_entry.rs index 64bd9fd46..fa9bde1a2 100644 --- a/crates/prek/src/hook_entry.rs +++ b/crates/prek/src/hook_entry.rs @@ -16,7 +16,7 @@ pub(crate) struct PreparedHookEntry { } impl PreparedHookEntry { - fn direct(argv: Vec) -> Self { + pub(crate) fn direct(argv: Vec) -> Self { Self { argv, _temp_dir: None, @@ -65,11 +65,12 @@ impl HookEntry { pub(crate) fn resolve( &self, env_path: Option<&OsStr>, + cwd: &Path, store: &Store, ) -> Result { match self { - Self::Direct(entry) => entry.resolve(env_path), - Self::Shell(entry) => entry.resolve(env_path, store), + Self::Direct(entry) => entry.resolve(env_path, cwd), + Self::Shell(entry) => entry.resolve(env_path, cwd, store), } } @@ -81,11 +82,12 @@ impl HookEntry { &self, repo_path: &Path, env_path: Option<&OsStr>, + cwd: &Path, store: &Store, ) -> Result { match self { - Self::Direct(entry) => entry.resolve_script(repo_path, env_path), - Self::Shell(entry) => entry.resolve(env_path, store), + Self::Direct(entry) => entry.resolve_script(repo_path, env_path, cwd), + Self::Shell(entry) => entry.resolve(env_path, cwd, store), } } @@ -120,10 +122,12 @@ pub(crate) struct DirectHookEntry { impl DirectHookEntry { /// Split the entry and resolve the command by parsing its shebang. - fn resolve(&self, env_path: Option<&OsStr>) -> Result { + fn resolve(&self, env_path: Option<&OsStr>, cwd: &Path) -> Result { let split = self.split()?; - Ok(PreparedHookEntry::direct(resolve_command(split, env_path))) + Ok(PreparedHookEntry::direct(resolve_command( + split, env_path, cwd, + ))) } /// Resolve a direct `language: script` entry. @@ -131,12 +135,15 @@ impl DirectHookEntry { &self, repo_path: &Path, env_path: Option<&OsStr>, + cwd: &Path, ) -> Result { let mut split = self.split()?; let cmd = repo_path.join(&split[0]); split[0] = cmd.into_os_string(); - Ok(PreparedHookEntry::direct(resolve_command(split, env_path))) + Ok(PreparedHookEntry::direct(resolve_command( + split, env_path, cwd, + ))) } /// Split the entry into a list of commands. @@ -174,7 +181,12 @@ pub(crate) struct ShellHookEntry { } impl ShellHookEntry { - fn resolve(&self, env_path: Option<&OsStr>, store: &Store) -> Result { + fn resolve( + &self, + env_path: Option<&OsStr>, + cwd: &Path, + store: &Store, + ) -> Result { let temp_dir = tempfile::tempdir_in(store.scratch_path())?; let script_path = temp_dir .path() @@ -185,7 +197,7 @@ impl ShellHookEntry { error: anyhow::anyhow!(err).context("Failed to write shell entry script"), })?; - let argv = resolve_command(self.shell.argv_for_script(&script_path), env_path); + let argv = resolve_command(self.shell.argv_for_script(&script_path), env_path, cwd); Ok(PreparedHookEntry::shell(argv, temp_dir)) } } diff --git a/crates/prek/src/languages/bun/bun.rs b/crates/prek/src/languages/bun/bun.rs index 54ba3536d..bf65e57e3 100644 --- a/crates/prek/src/languages/bun/bun.rs +++ b/crates/prek/src/languages/bun/bun.rs @@ -1,6 +1,4 @@ use std::ffi::OsStr; -use std::path::Path; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -9,14 +7,12 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; -use crate::languages::LanguageBackend; use crate::languages::bun::BunRequest; use crate::languages::bun::installer::{BunInstaller, BunResult, bin_dir, lib_dir}; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] @@ -118,44 +114,20 @@ impl LanguageBackend for Bun { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("Bun must have env path"); let bun_bin = hook.toolchain_dir().expect("Bun binary must have parent"); let new_path = prepend_paths(&[&bin_dir(env_dir), bun_bin]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path), store)?; - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::BUN_INSTALL, env_dir) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::BUN_INSTALL, env_dir); + Ok(environment) } } diff --git a/crates/prek/src/languages/conda.rs b/crates/prek/src/languages/conda.rs index f43f2765d..27239d41d 100644 --- a/crates/prek/src/languages/conda.rs +++ b/crates/prek/src/languages/conda.rs @@ -1,5 +1,4 @@ use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -8,11 +7,9 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] @@ -87,45 +84,21 @@ impl LanguageBackend for Conda { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("Conda must have env path"); let new_path = conda_path(env_dir).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path), store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::CONDA_PREFIX, env_dir) - .env_remove(EnvVars::PYTHONHOME) - .env_remove(EnvVars::VIRTUAL_ENV) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::CONDA_PREFIX, env_dir) + .env_remove(EnvVars::PYTHONHOME) + .env_remove(EnvVars::VIRTUAL_ENV); + Ok(environment) } } diff --git a/crates/prek/src/languages/coursier.rs b/crates/prek/src/languages/coursier.rs index fedf37146..9a757b0d4 100644 --- a/crates/prek/src/languages/coursier.rs +++ b/crates/prek/src/languages/coursier.rs @@ -1,6 +1,5 @@ use std::io::ErrorKind; use std::path::Path; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -9,11 +8,9 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::{CacheBucket, Store}; const PRE_COMMIT_CHANNEL_DIR: &str = ".pre-commit-channel"; @@ -153,44 +150,20 @@ impl LanguageBackend for Coursier { Ok(()) } - async fn run( + fn execution_environment( &self, store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_path = hook.env_path().expect("Coursier must have env path"); - let coursier_cache = store.cache_path(CacheBucket::Coursier); let path_env = prepend_paths(&[env_path]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&path_env), store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .env(EnvVars::PATH, &path_env) - .env(EnvVars::COURSIER_CACHE, &coursier_cache) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); + let coursier_cache = store.cache_path(CacheBucket::Coursier); - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&path_env) + .env(EnvVars::COURSIER_CACHE, &coursier_cache); + Ok(environment) } } diff --git a/crates/prek/src/languages/dart.rs b/crates/prek/src/languages/dart.rs index 4c90cce72..28b4b7007 100644 --- a/crates/prek/src/languages/dart.rs +++ b/crates/prek/src/languages/dart.rs @@ -14,7 +14,6 @@ use std::collections::BTreeMap; use std::env::consts::EXE_EXTENSION; use std::ffi::{OsStr, OsString}; use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -24,11 +23,10 @@ use serde::{Deserialize, Serialize}; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::hook_entry::PreparedHookEntry; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] @@ -151,21 +149,32 @@ impl LanguageBackend for Dart { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); + ) -> Result { + let env_dir = hook.env_path().expect("Dart must have env path"); + let new_path = prepend_paths(&[&bin_path(env_dir)]).context("Failed to join PATH")?; + + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::PUB_CACHE, env_dir); + Ok(environment) + } + fn prepare_hook_entry( + &self, + store: &Store, + hook: &InstalledHook, + environment: &ExecutionEnvironment, + ) -> Result { let env_dir = hook.env_path().expect("Dart must have env path"); - let bin_path = bin_path(env_dir); - let new_path = prepend_paths(&[&bin_path]).context("Failed to join PATH")?; let packages_path = package_config_path(env_dir); - - let mut entry = hook.entry.resolve(Some(&new_path), store)?; + let mut entry = hook + .entry + .resolve(environment.path(hook), hook.work_dir(), store)?; // `dart pub get` writes the hook env's dependency graph here. Dart's // VM-level `--packages` flag makes `Platform.packageConfig` and package // imports resolve against this env instead of the hook work dir. @@ -177,31 +186,7 @@ impl LanguageBackend for Dart { format!("--packages={}", packages_path.display()).into(), ); } - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::PUB_CACHE, env_dir) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + Ok(entry) } } diff --git a/crates/prek/src/languages/deno/deno.rs b/crates/prek/src/languages/deno/deno.rs index e0c3155fb..3f6bf38a1 100644 --- a/crates/prek/src/languages/deno/deno.rs +++ b/crates/prek/src/languages/deno/deno.rs @@ -1,5 +1,3 @@ -use std::path::Path; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -8,13 +6,11 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; use crate::languages::deno::DenoRequest; use crate::languages::deno::installer::{DenoInstaller, DenoResult, bin_dir}; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::{CacheBucket, Store, ToolBucket}; fn is_valid_install_name(name: &str) -> bool { @@ -169,50 +165,23 @@ impl LanguageBackend for Deno { Ok(()) } - async fn run( + fn execution_environment( &self, store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { 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 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")?; - - let entry = hook.entry.resolve(Some(&new_path), store)?; - - let run = async |batch: &[&Path]| { - let mut cmd = Cmd::new(&entry[0]); - let output = cmd - .current_dir(hook.work_dir()) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::DENO_DIR, &deno_cache_dir) - .env(EnvVars::DENO_NO_UPDATE_CHECK, "1") - .envs(&hook.env) - .args(&entry[1..]) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + let new_path = prepend_paths(&[&bin_dir(&info.env_path), deno_bin_dir]) + .context("Failed to join PATH")?; + + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::DENO_DIR, &deno_cache_dir) + .env(EnvVars::DENO_NO_UPDATE_CHECK, "1"); + Ok(environment) } } diff --git a/crates/prek/src/languages/dotnet/dotnet.rs b/crates/prek/src/languages/dotnet/dotnet.rs index 34fddf601..4dd9a223d 100644 --- a/crates/prek/src/languages/dotnet/dotnet.rs +++ b/crates/prek/src/languages/dotnet/dotnet.rs @@ -1,5 +1,4 @@ use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -8,13 +7,11 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; use crate::languages::dotnet::DotnetRequest; use crate::languages::dotnet::installer::{DotnetInstaller, DotnetResult}; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] @@ -102,15 +99,11 @@ impl LanguageBackend for Dotnet { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("dotnet hook must have env path"); let tools_dir = tools_dir(env_dir); let dotnet = &hook @@ -118,34 +111,13 @@ impl LanguageBackend for Dotnet { .expect("dotnet hook must have install info") .toolchain; let dotnet_root = resolve_dotnet_root(dotnet).context("Failed to resolve DOTNET_ROOT")?; - let new_path = prepend_paths(&[&tools_dir, &dotnet_root]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path), store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::DOTNET_ROOT, &dotnet_root) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::DOTNET_ROOT, &dotnet_root); + Ok(environment) } } diff --git a/crates/prek/src/languages/golang/golang.rs b/crates/prek/src/languages/golang/golang.rs index 2e75bacb3..9a03c68d0 100644 --- a/crates/prek/src/languages/golang/golang.rs +++ b/crates/prek/src/languages/golang/golang.rs @@ -1,6 +1,5 @@ use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::Context; @@ -8,14 +7,11 @@ use prek_consts::env_vars::EnvVars; use prek_consts::prepend_paths; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::git::GitCommandExt; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; use crate::languages::golang::GoRequest; use crate::languages::golang::installer::GoInstaller; -use crate::process::Cmd; -use crate::run::run_by_batch; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::store::{CacheBucket, Store, ToolBucket}; #[derive(Debug, Copy, Clone)] @@ -115,59 +111,31 @@ impl LanguageBackend for Golang { Ok(()) } - async fn run( + fn execution_environment( &self, store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> anyhow::Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - - let env_dir = hook.env_path().expect("Node hook must have env path"); - + ) -> anyhow::Result { + let env_dir = hook.env_path().expect("Go hook must have env path"); let go_bin = bin_dir(env_dir); let go_tools = store.tools_path(ToolBucket::Go); let go_root_bin = hook.toolchain_dir().expect("Go root should exist"); let go_root = go_root_bin.parent().expect("Go root should exist"); let go_cache = store.cache_path(CacheBucket::Go); - - // Only set GOROOT and GOPATH if using the Go installed by prek - let go_envs = if go_root_bin.starts_with(go_tools) { - vec![(EnvVars::GOROOT, go_root), (EnvVars::GOPATH, &go_cache)] - } else { - vec![] - }; let new_path = prepend_paths(&[&go_bin, go_root_bin]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path), store)?; - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::GOTOOLCHAIN, "local") - .env(EnvVars::GOBIN, &go_bin) - .env(EnvVars::GOFLAGS, "-modcacherw") - .envs(go_envs.iter().copied()) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::GOTOOLCHAIN, "local") + .env(EnvVars::GOBIN, &go_bin) + .env(EnvVars::GOFLAGS, "-modcacherw"); + if go_root_bin.starts_with(go_tools) { + environment + .env(EnvVars::GOROOT, go_root) + .env(EnvVars::GOPATH, &go_cache); + } + Ok(environment) } } diff --git a/crates/prek/src/languages/haskell.rs b/crates/prek/src/languages/haskell.rs index 55b75d20d..565509008 100644 --- a/crates/prek/src/languages/haskell.rs +++ b/crates/prek/src/languages/haskell.rs @@ -1,5 +1,3 @@ -use std::path::Path; -use std::process::Stdio; use std::sync::{Arc, LazyLock}; use anyhow::{Context, Result}; @@ -9,11 +7,9 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; static CABAL_UPDATE_ONCE: OnceCell<()> = OnceCell::new(); @@ -109,43 +105,16 @@ impl LanguageBackend for Haskell { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("Haskell must have env path"); - let bin_dir = env_dir.join("bin"); - let new_path = prepend_paths(&[&bin_dir]).context("Failed to join PATH")?; - - let entry = hook.entry.resolve(Some(&new_path), store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); + let new_path = prepend_paths(&[&env_dir.join("bin")]).context("Failed to join PATH")?; - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment.set_path(&new_path); + Ok(environment) } } diff --git a/crates/prek/src/languages/lua.rs b/crates/prek/src/languages/lua.rs index 4451a2898..f3aa7a575 100644 --- a/crates/prek/src/languages/lua.rs +++ b/crates/prek/src/languages/lua.rs @@ -1,5 +1,4 @@ use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -9,11 +8,9 @@ use semver::Version; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] @@ -116,53 +113,25 @@ impl LanguageBackend for Lua { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("Lua must have env path"); let new_path = prepend_paths(&[&env_dir.join("bin")]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path), store)?; - let version = &hook .install_info() .expect("Lua must have install info") .language_version; - // version without patch, e.g. 5.4 let version = format!("{}.{}", version.major, version.minor); - let lua_path = Lua::get_lua_path(env_dir, &version); - let lua_cpath = Lua::get_lua_cpath(env_dir, &version); - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::LUA_PATH, &lua_path) - .env(EnvVars::LUA_CPATH, &lua_cpath) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::LUA_PATH, Lua::get_lua_path(env_dir, &version)) + .env(EnvVars::LUA_CPATH, Lua::get_lua_cpath(env_dir, &version)); + Ok(environment) } } diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index 4078fde34..041f4c4aa 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -2,10 +2,11 @@ use std::ffi::{OsStr, OsString}; use std::future::Future; use std::path::Path; use std::pin::Pin; +use std::process::Stdio; use std::str::FromStr; use std::sync::Arc; -use anyhow::Result; +use anyhow::{Context, Result}; use prek_consts::env_vars::{EnvVars, EnvVarsRead}; use prek_identify::parse_shebang; use tracing::{instrument, trace}; @@ -13,9 +14,12 @@ use tracing::{instrument, trace}; use crate::cli::reporter::HookInstallReporter; use crate::cli::run::HookRunReporter; use crate::config::Language; -use crate::fs::CWD; +use crate::fs::PathClean; use crate::hook::{Hook, InstallInfo, InstalledHook, Repo}; +use crate::hook_entry::PreparedHookEntry; use crate::hooks::{self, HookOutput}; +use crate::process::Cmd; +use crate::run::run_by_batch; use crate::store::{CacheBucket, Store, ToolBucket}; mod bun; @@ -57,13 +61,159 @@ trait LanguageBackend: Sync { async fn check_health(&self, info: &InstallInfo) -> Result<()>; + fn execution_environment( + &self, + _store: &Store, + _hook: &InstalledHook, + ) -> Result { + Ok(ExecutionEnvironment::default()) + } + + fn prepare_hook_entry( + &self, + store: &Store, + hook: &InstalledHook, + environment: &ExecutionEnvironment, + ) -> Result { + Ok(hook + .entry + .resolve(environment.path(hook), hook.work_dir(), store)?) + } + async fn run( &self, store: &Store, hook: &InstalledHook, filenames: &[&Path], reporter: &HookRunReporter, - ) -> Result<(i32, Vec)>; + ) -> Result<(i32, Vec)> { + let progress = reporter.on_run_start(hook, filenames.len()); + + let environment = self.execution_environment(store, hook)?; + let entry = self.prepare_hook_entry(store, hook, &environment)?; + let run = async |batch: &[&Path]| { + let output = environment + .command(hook, hook.work_dir(), entry.argv())? + .args(&hook.args) + .file_args(batch) + .stdin(Stdio::null()) + .pty_output_with_sink(reporter.output_sink(progress)) + .await?; + + reporter.on_run_progress(progress, batch.len() as u64); + + anyhow::Ok(output) + }; + + let output = run_by_batch(hook, filenames, entry.argv(), run).await?; + + reporter.on_run_complete(progress); + + Ok(output) + } +} + +/// Process environment shared by normal hook runs and `prek exec`. +/// +/// Language defaults are applied before the hook's configured `env`. +#[derive(Debug, Default)] +pub(crate) struct ExecutionEnvironment { + path: Option, + patch: EnvironmentPatch, +} + +#[derive(Debug, Default)] +struct EnvironmentPatch { + set: Vec<(OsString, OsString)>, + remove: Vec, +} + +impl EnvironmentPatch { + fn set(&mut self, key: impl AsRef, value: impl AsRef) { + self.set + .push((key.as_ref().to_os_string(), value.as_ref().to_os_string())); + } + + fn remove(&mut self, key: impl AsRef) { + self.remove.push(key.as_ref().to_os_string()); + } + + fn apply(&self, cmd: &mut Cmd) { + for key in &self.remove { + cmd.env_remove(key); + } + cmd.envs(self.set.iter().map(|(key, value)| (key, value))); + } +} + +impl ExecutionEnvironment { + pub(crate) fn new() -> Self { + Self::default() + } + + pub(crate) fn set_path(&mut self, path: impl AsRef) -> &mut Self { + let path = path.as_ref().to_os_string(); + self.patch.set(EnvVars::PATH, &path); + self.path = Some(path); + self + } + + pub(crate) fn env(&mut self, key: impl AsRef, value: impl AsRef) -> &mut Self { + self.patch.set(key, value); + self + } + + pub(crate) fn envs(&mut self, vars: I) -> &mut Self + where + I: IntoIterator, + K: AsRef, + V: AsRef, + { + for (key, value) in vars { + self.env(key, value); + } + self + } + + pub(crate) fn env_remove(&mut self, key: impl AsRef) -> &mut Self { + self.patch.remove(key); + self + } + + pub(crate) fn command( + &self, + hook: &InstalledHook, + cwd: &Path, + command: &[OsString], + ) -> Result { + let command = resolve_command(command.to_vec(), self.path(hook), cwd); + let (program, args) = command.split_first().context("Command cannot be empty")?; + let mut cmd = Cmd::new(program); + cmd.current_dir(cwd).args(args).check(false); + self.patch.apply(&mut cmd); + cmd.envs(&hook.env); + Ok(cmd) + } + + /// Effective PATH after applying the hook's configured environment. + pub(crate) fn path<'a>(&'a self, hook: &'a InstalledHook) -> Option<&'a OsStr> { + hook.env + .iter() + .find_map(|(key, value)| is_path_env(key).then_some(OsStr::new(value))) + .or(self.path.as_deref()) + } +} + +fn is_path_env(key: impl AsRef) -> bool { + let key = key.as_ref(); + #[cfg(windows)] + { + key.to_string_lossy().eq_ignore_ascii_case(EnvVars::PATH) + } + #[cfg(not(windows))] + { + key == OsStr::new(EnvVars::PATH) + } } type LanguageFuture<'a, T> = Pin> + 'a>>; @@ -314,6 +464,39 @@ impl Language { } } + pub(crate) fn ensure_exec_supported(self, hook: &Hook) -> Result<()> { + match hook.repo() { + Repo::Meta { .. } => anyhow::bail!("`prek exec` does not support meta hooks"), + Repo::Builtin { .. } => anyhow::bail!("`prek exec` does not support builtin hooks"), + Repo::Remote { .. } | Repo::Local { .. } => {} + } + + match self { + Self::Bun + | Self::Conda + | Self::Coursier + | Self::Dart + | Self::Deno + | Self::Dotnet + | Self::Golang + | Self::Haskell + | Self::Lua + | Self::Node + | Self::Perl + | Self::Php + | Self::Python + | Self::R + | Self::Ruby + | Self::Rust + | Self::Script + | Self::Swift + | Self::System => Ok(()), + Self::Docker | Self::DockerImage | Self::Fail | Self::Julia | Self::Pygrep => { + anyhow::bail!("`prek exec` does not support hooks with language `{self}`") + } + } + } + pub(crate) fn install<'a>( &'a self, store: &'a Store, @@ -366,6 +549,24 @@ impl Language { } } } + + #[instrument(skip_all, fields(hook_id=%hook.id, language=%hook.language))] + pub(crate) async fn exec( + &self, + store: &Store, + hook: &InstalledHook, + cwd: &Path, + command: &[OsString], + ) -> Result { + self.ensure_exec_supported(hook)?; + + let environment = self.backend().execution_environment(store, hook)?; + environment + .command(hook, cwd, command)? + .status() + .await + .map_err(Into::into) + } } /// Try to extract metadata from the given hook. @@ -399,7 +600,11 @@ pub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> { } /// Resolve the actual process invocation, honoring shebangs and PATH lookups. -pub(crate) fn resolve_command(mut cmds: Vec, paths: Option<&OsStr>) -> Vec { +pub(crate) fn resolve_command( + mut cmds: Vec, + paths: Option<&OsStr>, + cwd: &Path, +) -> Vec { let Some(candidate) = cmds.first() else { return cmds; }; @@ -411,8 +616,19 @@ pub(crate) fn resolve_command(mut cmds: Vec, paths: Option<&OsStr>) -> }; let paths = paths.or(env_path.as_deref()); - let resolved_binary = - which::which_in(candidate, paths, &*CWD).unwrap_or_else(|_| Path::new(candidate).into()); + let resolved_binary = which::which_in(candidate, paths, cwd).unwrap_or_else(|_| { + let candidate = Path::new(candidate); + let has_parent = candidate + .parent() + .is_some_and(|parent| !parent.as_os_str().is_empty()); + // `which_in` only returns executable files. Resolve an explicit relative + // path against `cwd` so its shebang can still be inspected. + if candidate.is_absolute() || has_parent { + cwd.join(candidate).clean() + } else { + candidate.into() + } + }); let Ok(shebang_argv) = parse_shebang(&resolved_binary) else { cmds[0] = resolved_binary.into_os_string(); @@ -441,7 +657,7 @@ pub(crate) fn resolve_command(mut cmds: Vec, paths: Option<&OsStr>) -> }; // Resolve the interpreter path, converting "python3" to "python3.exe" on Windows. - if let Ok(path) = which::which_in(interpreter, paths, &*CWD) { + if let Ok(path) = which::which_in(interpreter, paths, cwd) { shebang_argv[0] = path.into_os_string(); } shebang_argv.push(resolved_binary.into_os_string()); @@ -461,7 +677,7 @@ mod tests { use tempfile::tempdir; use super::resolve_command; - use crate::fs::make_executable; + use crate::fs::{CWD, PathClean, make_executable}; fn write_file(path: &Path, contents: &str) { fs_err::write(path, contents).expect("write test file"); @@ -470,13 +686,24 @@ mod tests { #[test] fn resolve_command_passthrough_when_not_found() { let cmd = OsString::from("__prek_nonexistent_command__"); - let resolved = resolve_command(vec![cmd.clone()], None); + let resolved = resolve_command(vec![cmd.clone()], None, &CWD); assert_eq!(resolved, vec![cmd]); } + #[test] + fn resolve_command_resolves_missing_relative_path_from_cwd() { + let cmd = OsString::from("./__prek_nonexistent_command__"); + let paths = OsString::new(); + let resolved = resolve_command(vec![cmd.clone()], Some(paths.as_os_str()), &CWD); + assert_eq!(resolved, vec![CWD.join(cmd).clean().into_os_string()]); + } + #[test] fn resolve_command_passthrough_when_empty() { - assert_eq!(resolve_command(Vec::new(), None), Vec::::new()); + assert_eq!( + resolve_command(Vec::new(), None, &CWD), + Vec::::new() + ); } #[cfg(unix)] @@ -486,7 +713,7 @@ mod tests { let arg = OsString::from_vec(vec![b'f', b'o', 0x80]); assert_eq!( - resolve_command(vec![cmd.clone(), arg.clone()], Some(OsStr::new("")),), + resolve_command(vec![cmd.clone(), arg.clone()], Some(OsStr::new("")), &CWD,), vec![cmd, arg] ); } @@ -513,6 +740,7 @@ mod tests { let resolved = resolve_command( vec![script.clone(), OsString::from("--from-entry")], Some(paths.as_os_str()), + &CWD, ); assert_eq!( @@ -526,6 +754,40 @@ mod tests { ); } + #[test] + fn resolve_command_resolves_relative_shebang_from_cwd() { + let dir = tempdir().expect("create temp dir"); + let script_path = dir.path().join("hook-script"); + write_file( + &script_path, + "#!/usr/bin/env -S prek-test-interpreter --from-shebang\necho hi\n", + ); + + #[cfg(windows)] + let interpreter_path = dir.path().join("prek-test-interpreter.exe"); + #[cfg(not(windows))] + let interpreter_path = dir.path().join("prek-test-interpreter"); + + write_file(&interpreter_path, ""); + make_executable(&interpreter_path).expect("set executable bit"); + + let paths = OsString::from(dir.path().as_os_str()); + let resolved = resolve_command( + vec![OsString::from("./hook-script")], + Some(paths.as_os_str()), + dir.path(), + ); + + assert_eq!( + resolved, + vec![ + interpreter_path.into_os_string(), + OsString::from("--from-shebang"), + script_path.into_os_string(), + ] + ); + } + #[cfg(windows)] #[test] fn resolve_command_windows_rewrites_bin_sh_to_path_sh() { @@ -540,6 +802,7 @@ mod tests { let resolved = resolve_command( vec![script_path.as_os_str().to_owned()], Some(paths.as_os_str()), + &CWD, ); assert_eq!(resolved[0].as_os_str(), sh_path.as_os_str()); @@ -567,6 +830,7 @@ mod tests { let resolved = resolve_command( vec![script_path.as_os_str().to_owned()], Some(paths.as_os_str()), + &CWD, ); let resolved_interp = Path::new(&resolved[0]); diff --git a/crates/prek/src/languages/node/node.rs b/crates/prek/src/languages/node/node.rs index bb41a1ec4..48b3e233c 100644 --- a/crates/prek/src/languages/node/node.rs +++ b/crates/prek/src/languages/node/node.rs @@ -1,5 +1,4 @@ use std::path::Path; -use std::process::Stdio; use std::str; use std::sync::Arc; @@ -11,15 +10,13 @@ use tracing::debug; use url::Url; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; -use crate::languages::LanguageBackend; use crate::languages::node::NodeRequest; use crate::languages::node::installer::{NodeInstaller, bin_dir, lib_dir, query_node_version}; use crate::languages::node::version::EXTRA_KEY_LTS; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::{CacheBucket, Store, ToolBucket}; #[derive(Debug, Copy, Clone)] @@ -213,49 +210,28 @@ impl LanguageBackend for Node { Ok(()) } - async fn run( + fn execution_environment( &self, store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("Node must have env path"); let node_bin = hook.toolchain_dir().expect("Node binary must have parent"); let new_path = prepend_paths(&[&bin_dir(env_dir), node_bin]).context("Failed to join PATH")?; - - let entry = hook.entry.resolve(Some(&new_path), store)?; let npm_cache = store.cache_path(CacheBucket::Npm); - let run = async |batch: &[&Path]| { - let mut cmd = Cmd::new(&entry[0]); - cmd.current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::NODE_PATH, lib_dir(env_dir)) - .envs(&hook.env); - apply_npm_config_env(&mut cmd, env_dir, &npm_cache); - let output = cmd - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::NODE_PATH, lib_dir(env_dir)); + for key in NPM_CONFIG_ENVS_TO_REMOVE { + environment.env_remove(key); + } + environment + .env(NPM_CONFIG_PREFIX_ENV, env_dir) + .env(NPM_CONFIG_CACHE_ENV, &npm_cache); + Ok(environment) } } diff --git a/crates/prek/src/languages/perl.rs b/crates/prek/src/languages/perl.rs index efaea5d03..53e9a4cbc 100644 --- a/crates/prek/src/languages/perl.rs +++ b/crates/prek/src/languages/perl.rs @@ -1,6 +1,5 @@ use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -9,11 +8,9 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] @@ -73,43 +70,17 @@ impl LanguageBackend for Perl { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("Perl must have env path"); let new_path = prepend_paths(&[&bin_dir(env_dir)]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path), store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .envs(perl_env(env_dir)?) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment.set_path(&new_path).envs(perl_env(env_dir)?); + Ok(environment) } } diff --git a/crates/prek/src/languages/php.rs b/crates/prek/src/languages/php.rs index 51fccfa5f..bd3e87f4a 100644 --- a/crates/prek/src/languages/php.rs +++ b/crates/prek/src/languages/php.rs @@ -1,6 +1,5 @@ use std::ffi::OsStr; use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -12,11 +11,9 @@ use serde_json::{Map, Value, json}; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; const COMPOSER_JSON: &str = "composer.json"; @@ -234,45 +231,21 @@ impl LanguageBackend for Php { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); + ) -> Result { let env_path = hook.env_path().expect("PHP must have env path"); let php_bin = hook .toolchain_dir() .expect("PHP executable must have a parent directory"); let path_env = prepend_paths(&[&bin_dir(env_path), php_bin]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&path_env), store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &path_env) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment.set_path(&path_env); + Ok(environment) } } diff --git a/crates/prek/src/languages/python/python.rs b/crates/prek/src/languages/python/python.rs index f6df23505..d3d4b7fc5 100644 --- a/crates/prek/src/languages/python/python.rs +++ b/crates/prek/src/languages/python/python.rs @@ -1,6 +1,5 @@ use std::env::consts::EXE_EXTENSION; use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::{Arc, LazyLock}; use anyhow::{Context, Result}; @@ -12,17 +11,15 @@ use serde::Deserialize; use tracing::{debug, trace}; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::git::GitCommandExt; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; -use crate::languages::LanguageBackend; use crate::languages::python::PythonRequest; use crate::languages::python::uv::Uv; use crate::languages::version::LanguageRequest; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] @@ -181,44 +178,20 @@ impl LanguageBackend for Python { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("Python must have env path"); let new_path = prepend_paths(&[&bin_dir(env_dir)]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path), store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::VIRTUAL_ENV, env_dir) - .env(EnvVars::PATH, &new_path) - .env_remove(EnvVars::PYTHONHOME) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::VIRTUAL_ENV, env_dir) + .env_remove(EnvVars::PYTHONHOME); + Ok(environment) } } diff --git a/crates/prek/src/languages/r.rs b/crates/prek/src/languages/r.rs index 3daa2e090..165cfa2f9 100644 --- a/crates/prek/src/languages/r.rs +++ b/crates/prek/src/languages/r.rs @@ -1,7 +1,6 @@ use std::env::consts::EXE_EXTENSION; use std::ffi::OsString; use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::str; use std::sync::Arc; @@ -10,11 +9,10 @@ use prek_consts::env_vars::{EnvVars, EnvVarsRead}; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::hook_entry::PreparedHookEntry; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] @@ -121,46 +119,28 @@ impl LanguageBackend for R { Ok(()) } - async fn run( + fn execution_environment( &self, _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_path = hook.env_path().expect("R must have env path"); let activate = env_path.join("activate.R"); - let entry = r_hook_entry(hook)?; - - let run = async |batch: &[&Path]| { - let mut cmd = Cmd::new(&entry[0]); - cmd.current_dir(hook.work_dir()) - .args(&entry[1..]) - .env_remove(EnvVars::RENV_PROJECT) - .env(EnvVars::R_PROFILE_USER, &activate) - .stdin(Stdio::null()); - - cmd.envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false); - - let output = cmd - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, &entry, run).await?; - reporter.on_run_complete(progress); + let mut environment = ExecutionEnvironment::new(); + environment + .env_remove(EnvVars::RENV_PROJECT) + .env(EnvVars::R_PROFILE_USER, &activate); + Ok(environment) + } - Ok(output) + fn prepare_hook_entry( + &self, + _store: &Store, + hook: &InstalledHook, + _environment: &ExecutionEnvironment, + ) -> Result { + Ok(PreparedHookEntry::direct(r_hook_entry(hook)?)) } } diff --git a/crates/prek/src/languages/ruby/ruby.rs b/crates/prek/src/languages/ruby/ruby.rs index eba66b974..0cb9a3171 100644 --- a/crates/prek/src/languages/ruby/ruby.rs +++ b/crates/prek/src/languages/ruby/ruby.rs @@ -1,6 +1,5 @@ use std::env::consts::EXE_EXTENSION; use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -9,14 +8,11 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; use crate::languages::ruby::RubyRequest; use crate::languages::ruby::gem::{build_gemspecs, install_gems}; use crate::languages::ruby::installer::{RubyInstaller, query_ruby_version}; -use crate::process::Cmd; -use crate::run::run_by_batch; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::store::{Store, ToolBucket}; #[derive(Debug, Copy, Clone)] @@ -121,57 +117,27 @@ impl LanguageBackend for Ruby { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> Result { let env_dir = hook.env_path().expect("Ruby hook must have env path"); - - // Prepare PATH let gem_home = gem_home(env_dir); let gem_bin = gem_bin(env_dir); let ruby_bin = hook .toolchain_dir() .expect("Ruby toolchain should have parent"); - let new_path = prepend_paths(&[&gem_bin, ruby_bin]).context("Failed to join PATH")?; - // Resolve entry point - let entry = hook.entry.resolve(Some(&new_path), store)?; - - // Execute in batches - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::GEM_HOME, &gem_home) - .env(EnvVars::BUNDLE_IGNORE_CONFIG, "1") - .env_remove(EnvVars::GEM_PATH) - .env_remove(EnvVars::BUNDLE_GEMFILE) - .envs(&hook.env) - .args(&entry[1..]) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::GEM_HOME, &gem_home) + .env(EnvVars::BUNDLE_IGNORE_CONFIG, "1") + .env_remove(EnvVars::GEM_PATH) + .env_remove(EnvVars::BUNDLE_GEMFILE); + Ok(environment) } } diff --git a/crates/prek/src/languages/rust/rust.rs b/crates/prek/src/languages/rust/rust.rs index 1b863ef18..9b734ca48 100644 --- a/crates/prek/src/languages/rust/rust.rs +++ b/crates/prek/src/languages/rust/rust.rs @@ -2,7 +2,6 @@ use std::env::consts::EXE_EXTENSION; use std::ffi::OsStr; use std::ops::Deref; use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::str::FromStr; use std::sync::Arc; @@ -13,17 +12,15 @@ use prek_consts::prepend_paths; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::fs::is_executable; use crate::git::GitCommandExt; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; use crate::languages::rust::RustRequest; use crate::languages::rust::installer::RustInstaller; use crate::languages::rust::rustup::Rustup; use crate::languages::rust::version::{Channel, EXTRA_KEY_CHANNEL}; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::{CacheBucket, Store, ToolBucket}; fn format_cargo_dependency(dep: &str) -> String { @@ -487,50 +484,24 @@ impl LanguageBackend for Rust { Ok(()) } - async fn run( + fn execution_environment( &self, store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> anyhow::Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - + ) -> anyhow::Result { 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 rust_bin = bin_dir(env_dir); let cargo_home = store.cache_path(CacheBucket::Cargo); let rustc_bin = bin_dir(&info.toolchain); - let new_path = prepend_paths(&[&rust_bin, &rustc_bin]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path), store)?; - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .env(EnvVars::CARGO_HOME, &cargo_home) - .env(EnvVars::RUSTUP_AUTO_INSTALL, "0") - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment + .set_path(&new_path) + .env(EnvVars::CARGO_HOME, &cargo_home) + .env(EnvVars::RUSTUP_AUTO_INSTALL, "0"); + Ok(environment) } } diff --git a/crates/prek/src/languages/script.rs b/crates/prek/src/languages/script.rs index 18af88135..6364749ad 100644 --- a/crates/prek/src/languages/script.rs +++ b/crates/prek/src/languages/script.rs @@ -1,16 +1,12 @@ -use std::path::Path; -use std::process::Stdio; use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; -use crate::languages::LanguageBackend; -use crate::process::Cmd; -use crate::run::run_by_batch; +use crate::hook_entry::PreparedHookEntry; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::store::Store; #[derive(Debug, Copy, Clone)] @@ -31,43 +27,18 @@ impl LanguageBackend for Script { Ok(()) } - async fn run( + fn prepare_hook_entry( &self, store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { + environment: &ExecutionEnvironment, + ) -> Result { // For `language: script`, the `entry[0]` is a script path. // For remote hooks, the path is relative to the repo root. // For local hooks, the path is relative to the current working directory. - - let progress = reporter.on_run_start(hook, filenames.len()); - let repo_path = hook.repo_path().unwrap_or(hook.work_dir()); - let entry = hook.entry.resolve_script(repo_path, None, store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .envs(&hook.env) - .args(&entry[1..]) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + Ok(hook + .entry + .resolve_script(repo_path, environment.path(hook), hook.work_dir(), store)?) } } diff --git a/crates/prek/src/languages/swift.rs b/crates/prek/src/languages/swift.rs index 77c001a41..489d316fc 100644 --- a/crates/prek/src/languages/swift.rs +++ b/crates/prek/src/languages/swift.rs @@ -1,5 +1,4 @@ use std::path::{Path, PathBuf}; -use std::process::Stdio; use std::sync::Arc; use anyhow::{Context, Result}; @@ -9,11 +8,9 @@ use semver::Version; use tracing::debug; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; -use crate::languages::LanguageBackend; +use crate::languages::{ExecutionEnvironment, LanguageBackend}; use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] @@ -170,16 +167,11 @@ impl LanguageBackend for Swift { Ok(()) } - async fn run( + fn execution_environment( &self, - store: &Store, + _store: &Store, hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - - // Get bin path from install info if a package was built + ) -> Result { let new_path = if let Some(bin_path) = hook.install_info().and_then(|i| i.get_extra(BIN_PATH_KEY)) { prepend_paths(&[Path::new(bin_path)]).context("Failed to join PATH")? @@ -187,31 +179,9 @@ impl LanguageBackend for Swift { EnvVars.var_os(EnvVars::PATH).unwrap_or_default() }; - let entry = hook.entry.resolve(Some(&new_path), store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .args(&entry[1..]) - .env(EnvVars::PATH, &new_path) - .envs(&hook.env) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) + let mut environment = ExecutionEnvironment::new(); + environment.set_path(&new_path); + Ok(environment) } } diff --git a/crates/prek/src/languages/system.rs b/crates/prek/src/languages/system.rs index 9fc3ea3c5..8cb196d81 100644 --- a/crates/prek/src/languages/system.rs +++ b/crates/prek/src/languages/system.rs @@ -1,15 +1,10 @@ -use std::path::Path; -use std::process::Stdio; use std::sync::Arc; use anyhow::Result; use crate::cli::reporter::HookInstallReporter; -use crate::cli::run::HookRunReporter; use crate::hook::{Hook, InstallInfo, InstalledHook}; use crate::languages::LanguageBackend; -use crate::process::Cmd; -use crate::run::run_by_batch; use crate::store::Store; #[derive(Debug, Copy, Clone)] @@ -29,39 +24,4 @@ impl LanguageBackend for System { async fn check_health(&self, _info: &InstallInfo) -> Result<()> { Ok(()) } - - async fn run( - &self, - store: &Store, - hook: &InstalledHook, - filenames: &[&Path], - reporter: &HookRunReporter, - ) -> Result<(i32, Vec)> { - let progress = reporter.on_run_start(hook, filenames.len()); - - let entry = hook.entry.resolve(None, store)?; - - let run = async |batch: &[&Path]| { - let output = Cmd::new(&entry[0]) - .current_dir(hook.work_dir()) - .envs(&hook.env) - .args(&entry[1..]) - .args(&hook.args) - .file_args(batch) - .check(false) - .stdin(Stdio::null()) - .pty_output_with_sink(reporter.output_sink(progress)) - .await?; - - reporter.on_run_progress(progress, batch.len() as u64); - - anyhow::Ok(output) - }; - - let output = run_by_batch(hook, filenames, entry.argv(), run).await?; - - reporter.on_run_complete(progress); - - Ok(output) - } } diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index 5dfb7ea11..023ea9b9a 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -298,6 +298,19 @@ async fn run(cli: Cli) -> Result { ) .await } + Command::Exec(args) => { + show_settings!(args); + + cli::exec( + &store, + cli.globals.config, + args.selector, + args.command, + cli.globals.refresh, + printer, + ) + .await + } Command::List(args) => { show_settings!(args); diff --git a/crates/prek/src/workspace.rs b/crates/prek/src/workspace.rs index 822e10bf0..457133a80 100644 --- a/crates/prek/src/workspace.rs +++ b/crates/prek/src/workspace.rs @@ -970,6 +970,11 @@ impl Workspace { &self.projects } + /// Iterate over configuration files for the selected projects. + pub(crate) fn config_files(&self) -> impl Iterator { + self.projects.iter().map(|project| project.config_file()) + } + pub(crate) fn all_projects(&self) -> &[Arc] { &self.all_projects } @@ -1030,11 +1035,7 @@ impl Workspace { /// Check if all configuration files are staged in git. pub(crate) async fn check_configs_staged(&self) -> Result<()> { - let config_files = self - .projects - .iter() - .map(|project| project.config_file()) - .collect::>(); + let config_files = self.config_files().collect::>(); let non_staged = git::files_not_staged(&config_files).await?; let git_root = GIT_ROOT.as_ref()?; diff --git a/crates/prek/tests/common/mod.rs b/crates/prek/tests/common/mod.rs index ea145f1e6..2810e0a39 100644 --- a/crates/prek/tests/common/mod.rs +++ b/crates/prek/tests/common/mod.rs @@ -230,6 +230,12 @@ impl TestContext { command } + pub fn exec(&self) -> Command { + let mut command = self.command(); + command.arg("exec"); + command + } + pub fn validate_config(&self) -> Command { let mut command = self.command(); command.arg("validate-config"); diff --git a/crates/prek/tests/exec.rs b/crates/prek/tests/exec.rs new file mode 100644 index 000000000..4bfeaff4f --- /dev/null +++ b/crates/prek/tests/exec.rs @@ -0,0 +1,215 @@ +mod common; + +use anyhow::Result; +use indoc::indoc; + +#[cfg(unix)] +use crate::common::make_executable; +use crate::common::{TestContext, cmd_snapshot}; + +fn config() -> &'static str { + indoc! {r#" + repos: + - repo: local + hooks: + - id: exec-test + name: Exec test + entry: command-that-must-not-run + language: system + args: [argument-that-must-not-be-passed] + env: + GIT_AUTHOR_NAME: Prek Exec + GIT_AUTHOR_EMAIL: exec@prek.dev + GIT_AUTHOR_DATE: "2000-01-01T00:00:00+00:00" + "#} +} + +fn context_with_config() -> TestContext { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(config()); + context +} + +#[test] +fn exec_inherits_stdin_and_stdout() { + let context = context_with_config(); + + cmd_snapshot!(context.filters(), context.exec() + .args(["exec-test", "--", "git", "stripspace"]) + .pass_stdin("hello from prek exec\n"), @r" + success: true + exit_code: 0 + ----- stdout ----- + hello from prek exec + + ----- stderr ----- + "); +} + +#[test] +fn exec_applies_hook_environment_without_entry_or_args() { + let context = context_with_config(); + + cmd_snapshot!(context.filters(), context.exec().args([ + "exec-test", + "--", + "git", + "var", + "GIT_AUTHOR_IDENT", + ]), @r" + success: true + exit_code: 0 + ----- stdout ----- + Prek Exec 946684800 +0000 + + ----- stderr ----- + "); +} + +#[test] +fn exec_explicit_selector_ignores_skip_environment() { + let context = context_with_config(); + + cmd_snapshot!(context.filters(), context.exec() + .env("PREK_SKIP", "exec-test") + .args([ + "exec-test", + "--", + "git", + "rev-parse", + "--is-inside-work-tree", + ]), @r" + success: true + exit_code: 0 + ----- stdout ----- + true + + ----- stderr ----- + "); +} + +#[test] +fn exec_propagates_child_exit_status() { + let context = context_with_config(); + + cmd_snapshot!(context.filters(), context.exec().args([ + "exec-test", + "--", + "git", + "-c", + "alias.exit-42=!exit 42", + "exit-42", + ]), @r" + success: false + exit_code: 42 + ----- stdout ----- + + ----- stderr ----- + "); +} + +#[test] +fn exec_rejects_ambiguous_hook_selector() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + context.setup_workspace(&["frontend"], config())?; + + cmd_snapshot!(context.filters(), context.exec().args([ + "exec-test", + "--", + "git", + "rev-parse", + "--is-inside-work-tree", + ]), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Hook selector `exec-test` matched multiple hooks: + - frontend:exec-test + - .:exec-test + Use a `project-path:hook-id` selector to select one hook + "); + Ok(()) +} + +#[test] +fn exec_keeps_current_working_directory() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + context.setup_workspace(&["frontend"], config())?; + + cmd_snapshot!(context.filters(), context.exec().args([ + "frontend:exec-test", + "--", + "git", + "rev-parse", + "--show-prefix", + ]), @r" + success: true + exit_code: 0 + ----- stdout ----- + + + ----- stderr ----- + "); + Ok(()) +} + +#[cfg(unix)] +#[test] +fn exec_resolves_relative_command_from_current_working_directory() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + context.setup_workspace(&["frontend"], config())?; + + let command = context.work_dir().join("exec-tool"); + fs_err::write(&command, "#!/bin/sh\necho relative command ok\n")?; + make_executable(&command)?; + + cmd_snapshot!(context.filters(), context.exec().args([ + "frontend:exec-test", + "--", + "./exec-tool", + ]), @r" + success: true + exit_code: 0 + ----- stdout ----- + relative command ok + + ----- stderr ----- + "); + Ok(()) +} + +#[test] +fn exec_rejects_unsupported_language_before_install() { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc! {r" + repos: + - repo: local + hooks: + - id: julia-hook + name: Julia hook + entry: hook.jl + language: julia + "}); + + cmd_snapshot!(context.filters(), context.exec().args([ + "julia-hook", + "--", + "git", + "rev-parse", + "--is-inside-work-tree", + ]), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: `prek exec` does not support hooks with language `julia` + "); +} diff --git a/crates/prek/tests/languages/node.rs b/crates/prek/tests/languages/node.rs index e8bdbe3cd..bb66e0ecc 100644 --- a/crates/prek/tests/languages/node.rs +++ b/crates/prek/tests/languages/node.rs @@ -5,6 +5,56 @@ use prek_consts::env_vars::{EnvVars, EnvVarsRead}; use crate::common::{TestContext, cmd_snapshot, make_executable, remove_bin_from_path}; +#[test] +fn exec_uses_installed_node_environment() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + + let package = context.work_dir().child("node-env-tool"); + package.create_dir_all()?; + package.child("package.json").write_str(indoc::indoc! {r#" + { + "name": "node-env-tool", + "version": "1.0.0", + "bin": { + "node-env-tool": "cli.js" + } + } + "#})?; + let cli = package.child("cli.js"); + cli.write_str(indoc::indoc! {r#" + #!/usr/bin/env node + console.log("exec node env ok"); + "#})?; + make_executable(cli.path())?; + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: node + name: node + language: node + entry: command-that-must-not-run + additional_dependencies: ["./node-env-tool"] + "#}); + + cmd_snapshot!(context.filters(), context.exec().args([ + "node", + "--", + "node-env-tool", + ]), @r" + success: true + exit_code: 0 + ----- stdout ----- + exec node env ok + + ----- stderr ----- + "); + + Ok(()) +} + /// Test `language_version` parsing and auto downloading works correctly. /// We use `setup-node` action to install node 20 in CI, so node 19 should be downloaded by prek. #[test] diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index bf208f913..cf4e997cb 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -3356,6 +3356,7 @@ fn selectors_completion() -> Result<()> { install Install prek Git hook shims prepare-hooks Prepare environments for configured hooks run Run configured hooks + exec Run a command in the environment prepared for a configured hook list List configured hooks uninstall Uninstall prek Git hook shims validate-config Validate prek configuration files diff --git a/docs/reference/cli.md b/docs/reference/cli.md index e2b3ae597..00fdc9f8c 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -15,6 +15,7 @@ prek [OPTIONS] [HOOK|PROJECT]... [COMMAND]
prek install

Install prek Git hook shims

prek prepare-hooks

Prepare environments for configured hooks

prek run

Run configured hooks

+
prek exec

Run a command in the environment prepared for a configured hook

prek list

List configured hooks

prek uninstall

Uninstall prek Git hook shims

prek validate-config

Validate prek configuration files

@@ -280,6 +281,44 @@ prek run [OPTIONS] [HOOK|PROJECT]...
--version, -V

Display the prek version

+## prek exec + +Run a command in the environment prepared for a configured hook + +

Usage

+ +``` +prek exec [OPTIONS] -- ... +``` + +

Arguments

+ +
HOOK

Hook whose execution environment should be used.

+

Supports hook-id and project-path:hook-id selectors and must resolve to exactly one configured hook.

+
COMMAND

Command and arguments to execute

+
+ +

Options

+ +
--cd, -C dir

Change to directory before running

+
--color color

Whether to use color in output

+

May also be set with the PREK_COLOR environment variable.

[default: auto]

Possible values:

+
    +
  • auto: Enables colored output only when the output is going to a terminal or TTY with support
  • +
  • always: Enables colored output regardless of the detected environment
  • +
  • never: Disables colored output
  • +
--config, -c config

Path to alternate config file

+
--help, -h

Display the concise help for this command

+
--log-file log-file

Write trace logs to the specified file. If not specified, trace logs will be written to $PREK_HOME/prek.log

+
--no-progress

Hide all progress outputs.

+

For example, spinners or progress bars.

+
--quiet, -q

Use quiet output.

+

Repeating this option, e.g., -qq, will enable a silent mode in which prek will write no output to stdout.

+

May also be set with the PREK_QUIET environment variable.

--refresh

Refresh all cached data

+
--verbose, -v

Use verbose output

+
--version, -V

Display the prek version

+
+ ## prek list List configured hooks diff --git a/docs/usage.md b/docs/usage.md index 798b6b6cd..4b3a512db 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -138,6 +138,34 @@ Inspect what would run without executing hooks or changing files: prek run --dry-run ``` +## Run a command in a hook environment + +Use `prek exec` to run an explicit command with the toolchain, installed +dependencies, and environment variables prepared for one configured hook. The +hook environment is prepared first if necessary: + +```bash +prek exec prettier -- prettier --stdin-filepath src/app.js < src/app.js +``` + +The hook selector must resolve to exactly one hook. In a workspace, use a +project-qualified selector when needed, for example: + +```bash +prek exec frontend:prettier -- prettier --version +``` + +Everything after `--` is the command to execute. It replaces the hook's +configured `entry` and `args`; `prek exec` does not select files, schedule other +hooks, or stash changes. The child process keeps the current working directory +after applying `--cd`, inherits the terminal's standard input, output, and error +streams, and its exit status becomes the exit status of `prek exec`. + +Backends whose hook entry defines a special execution mechanism, including +`docker`, `docker_image`, `fail`, `julia`, and `pygrep`, are not supported. +Builtin and meta hooks are also unsupported; `prek exec` reports an error for +these cases. + ## Skip hooks for one commit When the repository's policy permits it, Git can bypass the `pre-commit` and