diff --git a/.config/nextest.toml b/.config/nextest.toml index b36a9199b..a2b183f62 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -1,6 +1,8 @@ [profile.ci-core] -# Exclude language-specific integration tests from the main CI runs (except unimplemented/unsupported/script/fail/pygrep). -default-filter = "not binary_id(prek::languages) or (binary_id(prek::languages) and (test(unimplemented::) or test(unsupported::) or test(script::) or test(fail::) or test(pygrep::)))" +# Exclude heavy language-specific integration tests from the main CI runs. +# Keep this as a deny-list so new language test modules run in ci-core until +# they are deliberately moved to the language-test matrix. +default-filter = "not binary_id(prek::languages) or (binary_id(prek::languages) and not (test(bun::) or test(deno::) or test(docker::) or test(docker_image::) or test(dotnet::) or test(golang::) or test(haskell::) or test(julia::) or test(lua::) or test(node::) or test(python::) or test(ruby::) or test(rust::) or test(swift::)))" status-level = "skip" final-status-level = "slow" failure-output = "immediate" diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index 6e35bd110..efcb6601f 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -532,6 +532,19 @@ impl<'de> Deserialize<'de> for PassFilenames { } } +/// A predefined shell adapter used to run hook entries as shell source. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "lowercase")] +#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] +#[cfg_attr(feature = "schemars", schemars(rename_all = "lowercase"))] +pub(crate) enum Shell { + Sh, + Bash, + Pwsh, + Powershell, + Cmd, +} + /// Common hook options. #[derive(Debug, Clone, Default, Deserialize)] #[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))] @@ -575,6 +588,8 @@ pub(crate) struct HookOptions { pub language_version: Option, /// Write the output of the hook to a file when the hook fails or verbose is enabled. pub log_file: Option, + /// Run the hook entry through a predefined shell adapter. + pub shell: Option, /// This hook will execute using a single process instead of in parallel. /// Default is false. pub require_serial: Option, @@ -620,6 +635,7 @@ impl HookOptions { description, language_version, log_file, + shell, require_serial, stages, verbose, diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index c1a3b071b..d0f5f47c9 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -1,5 +1,4 @@ use std::borrow::Cow; -use std::ffi::OsStr; use std::fmt::{Display, Formatter}; use std::ops::Deref; use std::path::{Path, PathBuf}; @@ -18,8 +17,9 @@ use crate::config::{ self, BuiltinHook, Config, FilePattern, HookOptions, Language, LocalHook, ManifestHook, MetaHook, PassFilenames, RemoteHook, Stages, read_manifest, }; +use crate::hook_entry::HookEntry; use crate::languages::version::LanguageRequest; -use crate::languages::{extract_metadata, resolve_command}; +use crate::languages::{ShellSupport, extract_metadata}; use crate::store::Store; use crate::workspace::Project; @@ -266,6 +266,7 @@ impl HookBuilder { let HookOptions { language_version, additional_dependencies, + shell, .. } = &self.hook_spec.options; @@ -310,6 +311,37 @@ impl HookBuilder { } } + if shell.is_some() { + match self.repo.as_ref() { + Repo::Meta { .. } => { + return Err(Error::Hook { + hook: self.hook_spec.id.clone(), + error: anyhow::anyhow!( + "Hook specified `shell` but meta hooks do not support shell execution", + ), + }); + } + Repo::Builtin { .. } => { + return Err(Error::Hook { + hook: self.hook_spec.id.clone(), + error: anyhow::anyhow!( + "Hook specified `shell` but builtin hooks do not support shell execution", + ), + }); + } + Repo::Remote { .. } | Repo::Local { .. } => {} + } + + if let ShellSupport::Unsupported(reason) = language.shell_support() { + return Err(Error::Hook { + hook: self.hook_spec.id.clone(), + error: anyhow::anyhow!( + "Hook specified `shell` but the language `{language}` does not support shell execution: {reason}", + ), + }); + } + } + Ok(()) } @@ -333,6 +365,7 @@ impl HookBuilder { let require_serial = options.require_serial.unwrap_or(false); let verbose = options.verbose.unwrap_or(false); let stages = options.stages.unwrap_or(Stages::ALL); + let shell = options.shell; let additional_dependencies = options .additional_dependencies .unwrap_or_default() @@ -345,7 +378,7 @@ impl HookBuilder { error: anyhow::anyhow!(e), })?; - let entry = Entry::new(self.hook_spec.id.clone(), self.hook_spec.entry); + let entry = HookEntry::new(self.hook_spec.id.clone(), self.hook_spec.entry, shell); let priority = self .hook_spec @@ -397,45 +430,6 @@ impl HookBuilder { } } -#[derive(Debug, Clone)] -pub(crate) struct Entry { - hook: String, - entry: String, -} - -impl Entry { - pub(crate) fn new(hook: String, entry: String) -> Self { - Self { hook, entry } - } - - /// Split the entry and resolve the command by parsing its shebang. - pub(crate) fn resolve(&self, env_path: Option<&OsStr>) -> Result, Error> { - let split = self.split()?; - - Ok(resolve_command(split, env_path)) - } - - /// Split the entry into a list of commands. - pub(crate) fn split(&self) -> Result, Error> { - let splits = shlex::split(&self.entry).ok_or_else(|| Error::Hook { - hook: self.hook.clone(), - error: anyhow::anyhow!("Failed to parse entry `{}` as commands", &self.entry), - })?; - if splits.is_empty() { - return Err(Error::Hook { - hook: self.hook.clone(), - error: anyhow::anyhow!("Failed to parse entry: entry is empty"), - }); - } - Ok(splits) - } - - /// Get the original entry string. - pub(crate) fn raw(&self) -> &str { - &self.entry - } -} - #[allow(clippy::struct_excessive_bools)] #[derive(Debug, Clone)] pub(crate) struct Hook { @@ -448,7 +442,7 @@ pub(crate) struct Hook { pub idx: usize, pub id: String, pub name: String, - pub entry: Entry, + pub entry: HookEntry, pub language: Language, pub alias: String, pub files: Option, @@ -843,7 +837,9 @@ mod tests { use prek_identify::tags; use rustc_hash::FxHashMap; - use crate::config::{Config, HookOptions, Language, PassFilenames, RemoteHook, Stage, Stages}; + use crate::config::{ + Config, HookOptions, Language, PassFilenames, RemoteHook, Shell, Stage, Stages, + }; use crate::hook::HookSpec; use crate::languages::version::LanguageRequest; use crate::workspace::Project; @@ -872,7 +868,7 @@ mod tests { )?); let repo = Arc::new(Repo::Local { hooks: vec![] }); - // Base hook spec (e.g. from a manifest): minimal options, one env var. + // Base hook spec (e.g. from a manifest): options that config can merge or override. let mut base_env = FxHashMap::default(); base_env.insert("BASE".to_string(), "1".to_string()); @@ -884,6 +880,7 @@ mod tests { priority: None, options: HookOptions { env: Some(base_env), + shell: Some(Shell::Sh), ..Default::default() }, }; @@ -907,6 +904,7 @@ mod tests { pass_filenames: Some(PassFilenames::None), verbose: Some(true), description: Some("desc".to_string()), + shell: Some(Shell::Bash), ..Default::default() }, }; @@ -952,10 +950,13 @@ mod tests { idx: 7, id: "test-hook", name: "override-name", - entry: Entry { - hook: "test-hook", - entry: "python3 -c 'print(2)'", - }, + entry: Shell( + ShellHookEntry { + hook: "test-hook", + entry: "python3 -c 'print(2)'", + shell: Bash, + }, + ), language: Python, alias: "alias-1", files: None, diff --git a/crates/prek/src/hook_entry.rs b/crates/prek/src/hook_entry.rs new file mode 100644 index 000000000..b142955b9 --- /dev/null +++ b/crates/prek/src/hook_entry.rs @@ -0,0 +1,243 @@ +use std::ffi::OsStr; +use std::ops::Deref; +use std::path::Path; + +use tempfile::TempDir; + +use crate::config::Shell; +use crate::hook::Error; +use crate::languages::resolve_command; +use crate::store::Store; + +#[derive(Debug)] +pub(crate) struct PreparedHookEntry { + argv: Vec, + _temp_dir: Option, +} + +impl PreparedHookEntry { + fn direct(argv: Vec) -> Self { + Self { + argv, + _temp_dir: None, + } + } + + fn shell(argv: Vec, temp_dir: TempDir) -> Self { + Self { + argv, + _temp_dir: Some(temp_dir), + } + } + + pub(crate) fn argv(&self) -> &[String] { + &self.argv + } +} + +impl Deref for PreparedHookEntry { + type Target = [String]; + + fn deref(&self) -> &Self::Target { + &self.argv + } +} + +#[derive(Debug, Clone)] +pub(crate) enum HookEntry { + Direct(DirectHookEntry), + Shell(ShellHookEntry), +} + +impl HookEntry { + pub(crate) fn new(hook: String, entry: String, shell: Option) -> Self { + match shell { + Some(shell) => Self::Shell(ShellHookEntry { hook, entry, shell }), + None => Self::Direct(DirectHookEntry { hook, entry }), + } + } + + /// Split the entry and resolve the command by parsing its shebang. + pub(crate) fn resolve( + &self, + env_path: Option<&OsStr>, + store: &Store, + ) -> Result { + match self { + Self::Direct(entry) => entry.resolve(env_path), + Self::Shell(entry) => entry.resolve(env_path, store), + } + } + + /// Resolve a `language: script` entry. + /// + /// Without `shell`, the first token is a repository-relative script path. With `shell`, + /// the entry is shell source and is not rewritten as a script path. + pub(crate) fn resolve_script( + &self, + repo_path: &Path, + env_path: Option<&OsStr>, + store: &Store, + ) -> Result { + match self { + Self::Direct(entry) => entry.resolve_script(repo_path, env_path), + Self::Shell(entry) => entry.resolve(env_path, store), + } + } + + /// Return the argv-style entry for execution paths that reject `shell` during validation. + /// + /// Panicking here means validation and execution support have diverged. + pub(crate) fn expect_direct(&self) -> &DirectHookEntry { + match self { + Self::Direct(entry) => entry, + Self::Shell(entry) => { + panic!( + "Hook `{}` specified `shell`, but this execution path requires an argv-style entry", + entry.hook, + ); + } + } + } + + pub(crate) fn shell(&self) -> Option { + match self { + Self::Direct(_) => None, + Self::Shell(entry) => Some(entry.shell), + } + } +} + +#[derive(Debug, Clone)] +pub(crate) struct DirectHookEntry { + hook: String, + entry: String, +} + +impl DirectHookEntry { + /// Split the entry and resolve the command by parsing its shebang. + fn resolve(&self, env_path: Option<&OsStr>) -> Result { + let split = self.split()?; + + Ok(PreparedHookEntry::direct(resolve_command(split, env_path))) + } + + /// Resolve a direct `language: script` entry. + fn resolve_script( + &self, + repo_path: &Path, + env_path: Option<&OsStr>, + ) -> Result { + let mut split = self.split()?; + let cmd = repo_path.join(&split[0]); + split[0] = cmd.to_string_lossy().to_string(); + + Ok(PreparedHookEntry::direct(resolve_command(split, env_path))) + } + + /// Split the entry into a list of commands. + pub(crate) fn split(&self) -> Result, Error> { + let splits = shlex::split(&self.entry).ok_or_else(|| Error::Hook { + hook: self.hook.clone(), + error: anyhow::anyhow!("Failed to parse entry `{}` as commands", &self.entry), + })?; + if splits.is_empty() { + return Err(Error::Hook { + hook: self.hook.clone(), + error: anyhow::anyhow!("Failed to parse entry: entry is empty"), + }); + } + Ok(splits) + } + + /// Get the original entry string. + pub(crate) fn raw(&self) -> &str { + &self.entry + } +} + +#[derive(Debug, Clone)] +pub(crate) struct ShellHookEntry { + hook: String, + entry: String, + shell: Shell, +} + +impl ShellHookEntry { + fn resolve(&self, env_path: Option<&OsStr>, store: &Store) -> Result { + let temp_dir = tempfile::tempdir_in(store.scratch_path())?; + let script_path = temp_dir + .path() + .join("entry") + .with_extension(self.shell.extension()); + fs_err::write(&script_path, &self.entry).map_err(|err| Error::Hook { + hook: self.hook.clone(), + error: anyhow::anyhow!(err).context("Failed to write shell entry script"), + })?; + + let argv = resolve_command(self.shell.argv_for_script(&script_path), env_path); + Ok(PreparedHookEntry::shell(argv, temp_dir)) + } +} + +impl Shell { + fn extension(self) -> &'static str { + match self { + Self::Sh | Self::Bash => "sh", + Self::Pwsh | Self::Powershell => "ps1", + Self::Cmd => "cmd", + } + } + + fn argv_for_script(self, script_path: &Path) -> Vec { + let script = script_path.to_string_lossy().to_string(); + match self { + Self::Sh => vec!["sh".to_string(), "-e".to_string(), script], + Self::Bash => bash_argv(script), + Self::Pwsh => powershell_argv("pwsh", script), + Self::Powershell => powershell_argv("powershell", script), + Self::Cmd => cmd_argv(script), + } + } +} + +fn bash_argv(script: String) -> Vec { + // Avoid user startup files for deterministic hook behavior. `-e` fails on the first + // failing command, and `-o pipefail` makes failing pipeline segments fail the script. + const BASH_ARGV_PREFIX: &[&str] = &["bash", "--noprofile", "--norc", "-eo", "pipefail"]; + + let mut argv = BASH_ARGV_PREFIX + .iter() + .map(ToString::to_string) + .collect::>(); + argv.push(script); + argv +} + +fn powershell_argv(command: &str, script: String) -> Vec { + let mut argv = vec![ + command.to_string(), + // Avoid user profile scripts and prompts in hook execution. + "-NoProfile".to_string(), + "-NonInteractive".to_string(), + ]; + #[cfg(windows)] + // Allow running prek's temporary script without changing the user's execution policy. + argv.extend(["-ExecutionPolicy".to_string(), "Bypass".to_string()]); + argv.extend(["-File".to_string(), script]); + argv +} + +fn cmd_argv(script: String) -> Vec { + // `/D` disables AutoRun, `/E:ON` enables command extensions, `/V:OFF` disables + // delayed expansion, `/S` normalizes quote handling, `/C` runs and exits, and + // `CALL` executes the temporary script while preserving `%*` argument access. + const CMD_ARGV_PREFIX: &[&str] = &["cmd", "/D", "/E:ON", "/V:OFF", "/S", "/C", "CALL"]; + + let mut argv = CMD_ARGV_PREFIX + .iter() + .map(ToString::to_string) + .collect::>(); + argv.push(script); + argv +} diff --git a/crates/prek/src/hooks/pre_commit_hooks/check_added_large_files.rs b/crates/prek/src/hooks/pre_commit_hooks/check_added_large_files.rs index b12fc1380..cdb298493 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/check_added_large_files.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/check_added_large_files.rs @@ -37,7 +37,7 @@ pub(crate) async fn check_added_large_files( hook: &Hook, filenames: &[&Path], ) -> anyhow::Result<(i32, Vec)> { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; let filter = if args.enforce_all { FileFilter::NoFilter diff --git a/crates/prek/src/hooks/pre_commit_hooks/check_merge_conflict.rs b/crates/prek/src/hooks/pre_commit_hooks/check_merge_conflict.rs index 6f0250392..463ea568b 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/check_merge_conflict.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/check_merge_conflict.rs @@ -27,7 +27,7 @@ pub(crate) async fn check_merge_conflict( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; // Check if we're in a merge state or assuming merge if !args.assume_in_merge && !is_in_merge().await? { diff --git a/crates/prek/src/hooks/pre_commit_hooks/check_vcs_permalinks.rs b/crates/prek/src/hooks/pre_commit_hooks/check_vcs_permalinks.rs index 57bc0106e..36da803be 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/check_vcs_permalinks.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/check_vcs_permalinks.rs @@ -27,7 +27,8 @@ struct GithubPermalinkMatcher { impl GithubPermalinkMatcher { fn from_hook(hook: &Hook) -> Result { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = + Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; Ok(Self::new(args.additional_github_domains)) } diff --git a/crates/prek/src/hooks/pre_commit_hooks/check_yaml.rs b/crates/prek/src/hooks/pre_commit_hooks/check_yaml.rs index 6540fee03..c93065388 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/check_yaml.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/check_yaml.rs @@ -20,7 +20,7 @@ struct Args { } pub(crate) async fn check_yaml(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { check_file( diff --git a/crates/prek/src/hooks/pre_commit_hooks/file_contents_sorter.rs b/crates/prek/src/hooks/pre_commit_hooks/file_contents_sorter.rs index 2acdf6002..d092dce48 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/file_contents_sorter.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/file_contents_sorter.rs @@ -23,7 +23,7 @@ pub(crate) async fn file_contents_sorter( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; let file_base = hook.project().relative_path(); run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { diff --git a/crates/prek/src/hooks/pre_commit_hooks/fix_trailing_whitespace.rs b/crates/prek/src/hooks/pre_commit_hooks/fix_trailing_whitespace.rs index 5ccb35d6d..1a8bbe9c4 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/fix_trailing_whitespace.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/fix_trailing_whitespace.rs @@ -72,7 +72,7 @@ pub(crate) async fn fix_trailing_whitespace( hook: &Hook, filenames: &[&Path], ) -> Result<(i32, Vec)> { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; let force_markdown = args.force_markdown(); let markdown_exts = args.markdown_exts()?; diff --git a/crates/prek/src/hooks/pre_commit_hooks/mixed_line_ending.rs b/crates/prek/src/hooks/pre_commit_hooks/mixed_line_ending.rs index 5d02ca225..383ec109c 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/mixed_line_ending.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/mixed_line_ending.rs @@ -42,7 +42,7 @@ enum FixMode { } pub(crate) async fn mixed_line_ending(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { fix_file(hook.project().relative_path(), filename, args.fix) diff --git a/crates/prek/src/hooks/pre_commit_hooks/no_commit_to_branch.rs b/crates/prek/src/hooks/pre_commit_hooks/no_commit_to_branch.rs index 5eb2c80f3..a612996f6 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/no_commit_to_branch.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/no_commit_to_branch.rs @@ -40,7 +40,7 @@ impl Args { } pub(crate) async fn no_commit_to_branch(hook: &Hook) -> Result<(i32, Vec)> { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; let output = git_cmd("get current branch")? .arg("symbolic-ref") diff --git a/crates/prek/src/hooks/pre_commit_hooks/pretty_format_json.rs b/crates/prek/src/hooks/pre_commit_hooks/pretty_format_json.rs index 3724b9187..d8e801cf6 100644 --- a/crates/prek/src/hooks/pre_commit_hooks/pretty_format_json.rs +++ b/crates/prek/src/hooks/pre_commit_hooks/pretty_format_json.rs @@ -68,7 +68,7 @@ impl From<&Args> for PreparedArgs { } pub(crate) async fn pretty_format_json(hook: &Hook, filenames: &[&Path]) -> Result<(i32, Vec)> { - let args = Args::try_parse_from(hook.entry.split()?.iter().chain(&hook.args))?; + let args = Args::try_parse_from(hook.entry.expect_direct().split()?.iter().chain(&hook.args))?; let prepared = PreparedArgs::from(&args); run_concurrent_file_checks(filenames.iter().copied(), *CONCURRENCY, |filename| { diff --git a/crates/prek/src/languages/bun/bun.rs b/crates/prek/src/languages/bun/bun.rs index 285142862..a850222b3 100644 --- a/crates/prek/src/languages/bun/bun.rs +++ b/crates/prek/src/languages/bun/bun.rs @@ -120,7 +120,7 @@ impl LanguageImpl for Bun { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); @@ -130,7 +130,7 @@ impl LanguageImpl for Bun { let new_path = prepend_paths(&[&bin_dir(env_dir), bun_bin]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "bun hook") .current_dir(hook.work_dir()) @@ -152,7 +152,7 @@ impl LanguageImpl for Bun { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/deno/deno.rs b/crates/prek/src/languages/deno/deno.rs index cb16d0262..5e3c5a0a1 100644 --- a/crates/prek/src/languages/deno/deno.rs +++ b/crates/prek/src/languages/deno/deno.rs @@ -193,7 +193,7 @@ impl LanguageImpl for Deno { 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))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut cmd = Cmd::new(&entry[0], "deno hook"); @@ -218,7 +218,7 @@ impl LanguageImpl for Deno { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/docker.rs b/crates/prek/src/languages/docker.rs index 21bc6aee5..74aec3a78 100644 --- a/crates/prek/src/languages/docker.rs +++ b/crates/prek/src/languages/docker.rs @@ -481,7 +481,7 @@ impl LanguageImpl for Docker { ) .await .context("Failed to build docker image")?; - let entry = hook.entry.split()?; + let entry = hook.entry.expect_direct().split()?; let run = async |batch: &[&Path]| { // docker run [OPTIONS] IMAGE [COMMAND] [ARG...] diff --git a/crates/prek/src/languages/docker_image.rs b/crates/prek/src/languages/docker_image.rs index 7cb1b5907..2a1c5106f 100644 --- a/crates/prek/src/languages/docker_image.rs +++ b/crates/prek/src/languages/docker_image.rs @@ -44,7 +44,7 @@ impl LanguageImpl for DockerImage { .flat_map(|(key, value)| ["-e".to_owned(), format!("{key}={value}")]) .collect(); - let entry = hook.entry.split()?; + let entry = hook.entry.expect_direct().split()?; let run = async |batch: &[&Path]| { let mut cmd = Docker::docker_run_cmd(hook.work_dir()); let mut output = cmd diff --git a/crates/prek/src/languages/dotnet/dotnet.rs b/crates/prek/src/languages/dotnet/dotnet.rs index 735109064..c3eaa66aa 100644 --- a/crates/prek/src/languages/dotnet/dotnet.rs +++ b/crates/prek/src/languages/dotnet/dotnet.rs @@ -114,7 +114,7 @@ impl LanguageImpl for Dotnet { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); @@ -128,7 +128,7 @@ impl LanguageImpl for Dotnet { 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))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "run dotnet hook") @@ -151,7 +151,7 @@ impl LanguageImpl for Dotnet { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/fail.rs b/crates/prek/src/languages/fail.rs index 6ad88544e..35d5d71c6 100644 --- a/crates/prek/src/languages/fail.rs +++ b/crates/prek/src/languages/fail.rs @@ -34,7 +34,7 @@ impl LanguageImpl for Fail { _reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let mut out = Vec::new(); - writeln!(out, "{}\n", hook.entry.raw())?; + writeln!(out, "{}\n", hook.entry.expect_direct().raw())?; for f in filenames { out.extend(f.to_string_lossy().as_bytes()); out.push(b'\n'); diff --git a/crates/prek/src/languages/golang/golang.rs b/crates/prek/src/languages/golang/golang.rs index f77a25958..e2f89de2e 100644 --- a/crates/prek/src/languages/golang/golang.rs +++ b/crates/prek/src/languages/golang/golang.rs @@ -142,7 +142,7 @@ impl LanguageImpl for Golang { }; let new_path = prepend_paths(&[&go_bin, go_root_bin]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "go hook") .current_dir(hook.work_dir()) @@ -167,7 +167,7 @@ impl LanguageImpl for Golang { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/haskell.rs b/crates/prek/src/languages/haskell.rs index 4a095d7c4..44d48810a 100644 --- a/crates/prek/src/languages/haskell.rs +++ b/crates/prek/src/languages/haskell.rs @@ -112,7 +112,7 @@ impl LanguageImpl for Haskell { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); @@ -121,7 +121,7 @@ impl LanguageImpl for Haskell { 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))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "run haskell hook") @@ -143,7 +143,7 @@ impl LanguageImpl for Haskell { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/julia.rs b/crates/prek/src/languages/julia.rs index 24edbc19f..b7a558fff 100644 --- a/crates/prek/src/languages/julia.rs +++ b/crates/prek/src/languages/julia.rs @@ -108,7 +108,7 @@ impl LanguageImpl for Julia { let env_dir = hook.env_path().expect("Julia must have env path"); - let mut entry = hook.entry.split()?; + let mut entry = hook.entry.expect_direct().split()?; if let Some(repo_path) = hook.repo_path() { let jl_path = repo_path.join(&entry[0]); if jl_path.exists() { diff --git a/crates/prek/src/languages/lua.rs b/crates/prek/src/languages/lua.rs index 014976e16..5dac5a617 100644 --- a/crates/prek/src/languages/lua.rs +++ b/crates/prek/src/languages/lua.rs @@ -127,14 +127,14 @@ impl LanguageImpl for Lua { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); 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))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let version = &hook .install_info() @@ -167,7 +167,7 @@ impl LanguageImpl for Lua { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index da8a7bcc4..f936783f1 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -33,7 +33,7 @@ mod rust; mod script; mod swift; mod system; -pub mod version; +pub(crate) mod version; static BUN: bun::Bun = bun::Bun; static DENO: deno::Deno = deno::Deno; @@ -80,6 +80,12 @@ struct UnimplementedError(String); struct Unimplemented; +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ShellSupport { + Supported, + Unsupported(&'static str), +} + impl LanguageImpl for Unimplemented { async fn install( &self, @@ -129,38 +135,84 @@ impl LanguageImpl for Unimplemented { // system: only system version, no env, no additional deps impl Language { - pub fn supported(lang: Language) -> bool { - matches!( - lang, + pub(crate) fn supported(lang: Language) -> bool { + match lang { Self::Bun - | Self::Deno - | Self::Docker - | Self::DockerImage - | Self::Dotnet - | Self::Fail - | Self::Golang - | Self::Haskell - | Self::Julia - | Self::Lua - | Self::Node - | Self::Pygrep - | Self::Python - | Self::Ruby - | Self::Rust - | Self::Script - | Self::Swift - | Self::System - ) + | Self::Deno + | Self::Docker + | Self::DockerImage + | Self::Dotnet + | Self::Fail + | Self::Golang + | Self::Haskell + | Self::Julia + | Self::Lua + | Self::Node + | Self::Pygrep + | Self::Python + | Self::Ruby + | Self::Rust + | Self::Script + | Self::Swift + | Self::System => true, + Self::Conda | Self::Coursier | Self::Dart | Self::Perl | Self::R => false, + } } - pub fn supports_install_env(self) -> bool { - !matches!( - self, - Self::DockerImage | Self::Fail | Self::Script | Self::System - ) + pub(crate) fn supports_install_env(self) -> bool { + match self { + Self::Bun + | Self::Conda + | Self::Coursier + | Self::Dart + | Self::Deno + | Self::Docker + | Self::Dotnet + | Self::Golang + | Self::Haskell + | Self::Julia + | Self::Lua + | Self::Node + | Self::Perl + | Self::Pygrep + | Self::Python + | Self::R + | Self::Ruby + | Self::Rust + | Self::Swift => true, + Self::DockerImage | Self::Fail | Self::Script | Self::System => false, + } } - pub fn tool_buckets(self) -> &'static [ToolBucket] { + pub(crate) fn shell_support(self) -> ShellSupport { + match self { + Self::Bun + | Self::Deno + | Self::Dotnet + | Self::Golang + | Self::Haskell + | Self::Lua + | Self::Node + | Self::Python + | Self::Ruby + | Self::Script + | Self::Swift + | Self::System => ShellSupport::Supported, + Self::Conda | Self::Coursier | Self::Dart | Self::Perl | Self::R => { + ShellSupport::Unsupported("no runner is implemented yet") + } + Self::Docker | Self::DockerImage => ShellSupport::Unsupported( + "`entry` participates in container image or entrypoint selection", + ), + Self::Fail => ShellSupport::Unsupported("`entry` is the failure message body"), + Self::Julia | Self::Rust => ShellSupport::Unsupported( + "`entry` participates in install/runtime package resolution and is split before execution", + ), + Self::Pygrep => ShellSupport::Unsupported("`entry` is the regex pattern"), + } + } + + pub(crate) fn tool_buckets(self) -> &'static [ToolBucket] { match self { Self::Bun => &[ToolBucket::Bun], Self::Deno => &[ToolBucket::Deno], @@ -170,55 +222,114 @@ impl Language { Self::Python | Self::Pygrep => &[ToolBucket::Uv, ToolBucket::Python], Self::Ruby => &[ToolBucket::Ruby], Self::Rust => &[ToolBucket::Rustup], - _ => &[], + Self::Conda + | Self::Coursier + | Self::Dart + | Self::Docker + | Self::DockerImage + | Self::Fail + | Self::Haskell + | Self::Julia + | Self::Lua + | Self::Perl + | Self::R + | Self::Script + | Self::Swift + | Self::System => &[], } } - pub fn cache_buckets(self) -> &'static [CacheBucket] { + pub(crate) fn cache_buckets(self) -> &'static [CacheBucket] { match self { Self::Deno => &[CacheBucket::Deno], Self::Golang => &[CacheBucket::Go], Self::Python | Self::Pygrep => &[CacheBucket::Uv, CacheBucket::Python], Self::Rust => &[CacheBucket::Cargo], - _ => &[], + Self::Bun + | Self::Conda + | Self::Coursier + | Self::Dart + | Self::Docker + | Self::DockerImage + | Self::Dotnet + | Self::Fail + | Self::Haskell + | Self::Julia + | Self::Lua + | Self::Node + | Self::Perl + | Self::R + | Self::Ruby + | Self::Script + | Self::Swift + | Self::System => &[], } } /// Return whether the language allows specifying the version, e.g. we can install a specific /// requested language version. /// See - pub fn supports_language_version(self) -> bool { - matches!( - self, + pub(crate) fn supports_language_version(self) -> bool { + match self { Self::Bun - | Self::Deno - | Self::Dotnet - | Self::Golang - | Self::Node - | Self::Python - | Self::Ruby - | Self::Rust - ) + | Self::Deno + | Self::Dotnet + | Self::Golang + | Self::Node + | Self::Python + | Self::Ruby + | Self::Rust => true, + Self::Conda + | Self::Coursier + | Self::Dart + | Self::Docker + | Self::DockerImage + | Self::Fail + | Self::Haskell + | Self::Julia + | Self::Lua + | Self::Perl + | Self::Pygrep + | Self::R + | Self::Script + | Self::Swift + | Self::System => false, + } } /// Whether the language supports installing dependencies. /// /// For example, Python and Node.js support installing dependencies, while /// System and Fail do not. - pub fn supports_dependency(self) -> bool { - !matches!( - self, - Self::DockerImage - | Self::Fail - | Self::Pygrep - | Self::Script - | Self::System - | Self::Docker - | Self::Swift - ) + pub(crate) fn supports_dependency(self) -> bool { + match self { + Self::Bun + | Self::Conda + | Self::Coursier + | Self::Dart + | Self::Deno + | Self::Dotnet + | Self::Golang + | Self::Haskell + | Self::Julia + | Self::Lua + | Self::Node + | Self::Perl + | Self::Python + | Self::R + | Self::Ruby + | Self::Rust => true, + Self::Docker + | Self::DockerImage + | Self::Fail + | Self::Pygrep + | Self::Script + | Self::Swift + | Self::System => false, + } } - pub async fn install( + pub(crate) async fn install( &self, hook: Arc, store: &Store, @@ -243,11 +354,13 @@ impl Language { Self::Script => SCRIPT.install(hook, store, reporter).await, Self::Swift => SWIFT.install(hook, store, reporter).await, Self::System => SYSTEM.install(hook, store, reporter).await, - _ => UNIMPLEMENTED.install(hook, store, reporter).await, + Self::Conda | Self::Coursier | Self::Dart | Self::Perl | Self::R => { + UNIMPLEMENTED.install(hook, store, reporter).await + } } } - pub async fn check_health(&self, info: &InstallInfo) -> Result<()> { + pub(crate) async fn check_health(&self, info: &InstallInfo) -> Result<()> { match self { Self::Bun => BUN.check_health(info).await, Self::Deno => DENO.check_health(info).await, @@ -267,12 +380,14 @@ impl Language { Self::Script => SCRIPT.check_health(info).await, Self::Swift => SWIFT.check_health(info).await, Self::System => SYSTEM.check_health(info).await, - _ => UNIMPLEMENTED.check_health(info).await, + Self::Conda | Self::Coursier | Self::Dart | Self::Perl | Self::R => { + UNIMPLEMENTED.check_health(info).await + } } } #[instrument(level = "trace", skip_all, fields(hook_id = %hook.id, language = %hook.language))] - pub async fn run( + pub(crate) async fn run( &self, hook: &InstalledHook, filenames: &[&Path], @@ -320,7 +435,9 @@ impl Language { Self::Script => SCRIPT.run(hook, filenames, store, reporter).await, Self::Swift => SWIFT.run(hook, filenames, store, reporter).await, Self::System => SYSTEM.run(hook, filenames, store, reporter).await, - _ => UNIMPLEMENTED.run(hook, filenames, store, reporter).await, + Self::Conda | Self::Coursier | Self::Dart | Self::Perl | Self::R => { + UNIMPLEMENTED.run(hook, filenames, store, reporter).await + } } } } @@ -330,7 +447,27 @@ pub(crate) async fn extract_metadata(hook: &mut Hook) -> Result<()> { match hook.language { Language::Python => python::extract_metadata(hook).await, Language::Golang => golang::extract_go_mod_metadata(hook).await, - _ => Ok(()), + Language::Bun + | Language::Conda + | Language::Coursier + | Language::Dart + | Language::Deno + | Language::Docker + | Language::DockerImage + | Language::Dotnet + | Language::Fail + | Language::Haskell + | Language::Julia + | Language::Lua + | Language::Node + | Language::Perl + | Language::Pygrep + | Language::R + | Language::Ruby + | Language::Rust + | Language::Script + | Language::Swift + | Language::System => Ok(()), } } diff --git a/crates/prek/src/languages/node/node.rs b/crates/prek/src/languages/node/node.rs index 3fa42edc1..e7cba503b 100644 --- a/crates/prek/src/languages/node/node.rs +++ b/crates/prek/src/languages/node/node.rs @@ -137,7 +137,7 @@ impl LanguageImpl for Node { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); @@ -147,7 +147,7 @@ impl LanguageImpl for Node { let new_path = prepend_paths(&[&bin_dir(env_dir), node_bin]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "node hook") .current_dir(hook.work_dir()) @@ -171,7 +171,7 @@ impl LanguageImpl for Node { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/pygrep/pygrep.rs b/crates/prek/src/languages/pygrep/pygrep.rs index aedbab40f..22fd68a77 100644 --- a/crates/prek/src/languages/pygrep/pygrep.rs +++ b/crates/prek/src/languages/pygrep/pygrep.rs @@ -207,7 +207,7 @@ impl LanguageImpl for Pygrep { .arg(py_script.path()) .args(args.to_args()) .arg(CONCURRENCY.to_string()) - .arg(hook.entry.raw()) + .arg(hook.entry.expect_direct().raw()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::piped()) diff --git a/crates/prek/src/languages/python/pep723.rs b/crates/prek/src/languages/python/pep723.rs index c704362a4..53019d45f 100644 --- a/crates/prek/src/languages/python/pep723.rs +++ b/crates/prek/src/languages/python/pep723.rs @@ -256,6 +256,13 @@ impl ScriptTag { /// Effectively, we are implementing a new `python-script` language which works like `script`. /// But we don't want to introduce a new language just for this for now. pub(crate) async fn extract_pep723_metadata(hook: &mut Hook) -> Result<()> { + if hook.entry.shell().is_some() { + trace!( + "Skipping reading PEP 723 metadata for hook `{hook}` because `shell` treats `entry` as shell source", + ); + return Ok(()); + } + if !hook.additional_dependencies.is_empty() { trace!( "Skipping reading PEP 723 metadata for hook `{hook}` because it already has `additional_dependencies`", @@ -265,7 +272,7 @@ pub(crate) async fn extract_pep723_metadata(hook: &mut Hook) -> Result<()> { let repo_path = hook.repo_path().unwrap_or(hook.work_dir()); - let split = hook.entry.split()?; + let split = hook.entry.expect_direct().split()?; let file = repo_path.join(&split[0]); let Some(script) = Pep723Script::read(&file).await? else { diff --git a/crates/prek/src/languages/python/python.rs b/crates/prek/src/languages/python/python.rs index e4905cc16..a2ac5414b 100644 --- a/crates/prek/src/languages/python/python.rs +++ b/crates/prek/src/languages/python/python.rs @@ -187,14 +187,14 @@ impl LanguageImpl for Python { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); 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))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "python hook") @@ -218,7 +218,7 @@ impl LanguageImpl for Python { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/ruby/ruby.rs b/crates/prek/src/languages/ruby/ruby.rs index 734a922f0..ed072ed4a 100644 --- a/crates/prek/src/languages/ruby/ruby.rs +++ b/crates/prek/src/languages/ruby/ruby.rs @@ -143,7 +143,7 @@ impl LanguageImpl for Ruby { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); @@ -160,7 +160,7 @@ impl LanguageImpl for Ruby { 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))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; // Execute in batches let run = async |batch: &[&Path]| { @@ -187,7 +187,7 @@ impl LanguageImpl for Ruby { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/rust/rust.rs b/crates/prek/src/languages/rust/rust.rs index fa4d0ef44..90a7a80df 100644 --- a/crates/prek/src/languages/rust/rust.rs +++ b/crates/prek/src/languages/rust/rust.rs @@ -472,7 +472,7 @@ impl LanguageImpl for Rust { }); // Use the hook entry as the binary name to find the package, this could be improved by allowing an explicit binary name in the hook config. - let hook_entry = hook.entry.split()?; + let hook_entry = hook.entry.expect_direct().split()?; let hook_bin = &hook_entry[0]; // Install library dependencies and local project @@ -526,7 +526,7 @@ impl LanguageImpl for Rust { let new_path = prepend_paths(&[&rust_bin, &rustc_bin]).context("Failed to join PATH")?; - let entry = hook.entry.resolve(Some(&new_path))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "rust hook") .current_dir(hook.work_dir()) @@ -549,7 +549,7 @@ impl LanguageImpl for Rust { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/script.rs b/crates/prek/src/languages/script.rs index 4c8739056..9bdb76e2e 100644 --- a/crates/prek/src/languages/script.rs +++ b/crates/prek/src/languages/script.rs @@ -7,7 +7,7 @@ use anyhow::Result; use crate::cli::reporter::{HookInstallReporter, HookRunReporter}; use crate::hook::InstalledHook; use crate::hook::{Hook, InstallInfo}; -use crate::languages::{LanguageImpl, resolve_command}; +use crate::languages::LanguageImpl; use crate::process::Cmd; use crate::run::run_by_batch; use crate::store::Store; @@ -33,7 +33,7 @@ impl LanguageImpl for Script { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { // For `language: script`, the `entry[0]` is a script path. @@ -43,11 +43,7 @@ impl LanguageImpl for Script { let progress = reporter.on_run_start(hook, filenames.len()); let repo_path = hook.repo_path().unwrap_or(hook.work_dir()); - let mut split = hook.entry.split()?; - - let cmd = repo_path.join(&split[0]); - split[0] = cmd.to_string_lossy().to_string(); - let entry = resolve_command(split, None); + let entry = hook.entry.resolve_script(repo_path, None, store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "run script command") @@ -68,7 +64,7 @@ impl LanguageImpl for Script { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/swift.rs b/crates/prek/src/languages/swift.rs index 5e1004c9b..69c550379 100644 --- a/crates/prek/src/languages/swift.rs +++ b/crates/prek/src/languages/swift.rs @@ -176,7 +176,7 @@ impl LanguageImpl for Swift { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); @@ -189,7 +189,7 @@ impl LanguageImpl for Swift { EnvVars::var_os(EnvVars::PATH).unwrap_or_default() }; - let entry = hook.entry.resolve(Some(&new_path))?; + let entry = hook.entry.resolve(Some(&new_path), store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "swift hook") @@ -211,7 +211,7 @@ impl LanguageImpl for Swift { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/system.rs b/crates/prek/src/languages/system.rs index 313d21bd7..c92d7f17e 100644 --- a/crates/prek/src/languages/system.rs +++ b/crates/prek/src/languages/system.rs @@ -32,12 +32,12 @@ impl LanguageImpl for System { &self, hook: &InstalledHook, filenames: &[&Path], - _store: &Store, + store: &Store, reporter: &HookRunReporter, ) -> Result<(i32, Vec)> { let progress = reporter.on_run_start(hook, filenames.len()); - let entry = hook.entry.resolve(None)?; + let entry = hook.entry.resolve(None, store)?; let run = async |batch: &[&Path]| { let mut output = Cmd::new(&entry[0], "run system command") @@ -58,7 +58,7 @@ impl LanguageImpl for System { anyhow::Ok((code, output.stdout)) }; - let results = run_by_batch(hook, filenames, &entry, run).await?; + let results = run_by_batch(hook, filenames, entry.argv(), run).await?; reporter.on_run_complete(progress); diff --git a/crates/prek/src/languages/version.rs b/crates/prek/src/languages/version.rs index 44dd3f2f0..ddc17f3e9 100644 --- a/crates/prek/src/languages/version.rs +++ b/crates/prek/src/languages/version.rs @@ -56,7 +56,15 @@ impl LanguageRequest { pub(crate) fn allows_download(&self) -> bool { match self { LanguageRequest::Any { system_only } => !system_only, - _ => true, + LanguageRequest::Bun(_) + | LanguageRequest::Dotnet(_) + | LanguageRequest::Deno(_) + | LanguageRequest::Golang(_) + | LanguageRequest::Node(_) + | LanguageRequest::Python(_) + | LanguageRequest::Ruby(_) + | LanguageRequest::Rust(_) + | LanguageRequest::Semver(_) => true, } } @@ -87,7 +95,21 @@ impl LanguageRequest { Language::Python => Self::Python(request.parse()?), Language::Ruby => Self::Ruby(request.parse()?), Language::Rust => Self::Rust(request.parse()?), - _ => Self::Semver(request.parse()?), + Language::Conda + | Language::Coursier + | Language::Dart + | Language::Docker + | Language::DockerImage + | Language::Fail + | Language::Haskell + | Language::Julia + | Language::Lua + | Language::Perl + | Language::Pygrep + | Language::R + | Language::Script + | Language::Swift + | Language::System => Self::Semver(request.parse()?), }) } diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index 692ed8ee2..744b60b5a 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -35,6 +35,7 @@ mod config; mod fs; mod git; mod hook; +mod hook_entry; mod hooks; mod http; mod install_source; diff --git a/crates/prek/src/snapshots/prek__config__tests__language_version.snap b/crates/prek/src/snapshots/prek__config__tests__language_version.snap index 60be4eeb2..a1fb461ec 100644 --- a/crates/prek/src/snapshots/prek__config__tests__language_version.snap +++ b/crates/prek/src/snapshots/prek__config__tests__language_version.snap @@ -33,6 +33,7 @@ Ok( "default", ), log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -64,6 +65,7 @@ Ok( "system", ), log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -95,6 +97,7 @@ Ok( "3.8", ), log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap b/crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap index 175bc2c42..8e825914c 100644 --- a/crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap +++ b/crates/prek/src/snapshots/prek__config__tests__meta_hooks-5.snap @@ -39,6 +39,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -77,6 +78,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -104,6 +106,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: Some( diff --git a/crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap b/crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap index ce2d6613b..f5526db74 100644 --- a/crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap +++ b/crates/prek/src/snapshots/prek__config__tests__numeric_rev_is_parsed_as_string.snap @@ -31,6 +31,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap b/crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap index 43848e068..f5b438ff2 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_hooks-3.snap @@ -30,6 +30,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap b/crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap index 69b6ca53f..12dd498c7 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos-3.snap @@ -34,6 +34,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap b/crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap index 4b18d76a2..a2eabb522 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos-4.snap @@ -31,6 +31,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap b/crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap index 4b18d76a2..a2eabb522 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos-6.snap @@ -31,6 +31,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap b/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap index 645c12f9f..453a69735 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap @@ -30,6 +30,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap b/crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap index 701b9d6e3..9a7b52039 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_config_with_merge_keys.snap @@ -35,6 +35,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -69,6 +70,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap b/crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap index ad6b08ad9..b3a0d7527 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_config_with_nested_merge_keys.snap @@ -32,6 +32,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: Some( true, ), diff --git a/crates/prek/src/snapshots/prek__config__tests__read_manifest.snap b/crates/prek/src/snapshots/prek__config__tests__read_manifest.snap index 2e947c03d..86e3b8cba 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_manifest.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_manifest.snap @@ -37,6 +37,7 @@ Manifest { ), language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -79,6 +80,7 @@ Manifest { ), language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -124,6 +126,7 @@ Manifest { ), language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap b/crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap index 496adfa90..06ec8ba6d 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_toml_config.snap @@ -30,6 +30,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -68,6 +69,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -102,6 +104,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap b/crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap index d340750e1..17d8d4a74 100644 --- a/crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap +++ b/crates/prek/src/snapshots/prek__config__tests__read_yaml_config.snap @@ -31,6 +31,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -71,6 +72,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -114,6 +116,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -161,6 +164,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -204,6 +208,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -242,6 +247,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, @@ -276,6 +282,7 @@ Config { description: None, language_version: None, log_file: None, + shell: None, require_serial: None, stages: None, verbose: None, diff --git a/crates/prek/tests/languages/main.rs b/crates/prek/tests/languages/main.rs index cb5bc39a4..422088ac6 100644 --- a/crates/prek/tests/languages/main.rs +++ b/crates/prek/tests/languages/main.rs @@ -19,6 +19,8 @@ mod python; mod ruby; mod rust; mod script; +mod shell; mod swift; +mod system; mod unimplemented; mod unsupported; diff --git a/crates/prek/tests/languages/script.rs b/crates/prek/tests/languages/script.rs index ae802ac41..3e5971f54 100644 --- a/crates/prek/tests/languages/script.rs +++ b/crates/prek/tests/languages/script.rs @@ -171,6 +171,47 @@ mod unix { Ok(()) } + + #[test] + fn script_shell_runs_entry_as_shell_source() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: shell-script + name: shell-script + language: script + files: ^a\.txt$ + entry: | + printf 'args:' + for value in "$@"; do + printf ' <%s>' "$value" + done + printf '\n' + shell: sh + args: [configured] + verbose: true + "#}); + context.work_dir().child("a.txt").write_str("a")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + shell-script.............................................................Passed + - hook id: shell-script + - duration: [TIME] + + args: + + ----- stderr ----- + "); + + Ok(()) + } } /// Test that a script with a shebang line works correctly on Windows. diff --git a/crates/prek/tests/languages/shell.rs b/crates/prek/tests/languages/shell.rs new file mode 100644 index 000000000..46f5aaf9a --- /dev/null +++ b/crates/prek/tests/languages/shell.rs @@ -0,0 +1,190 @@ +use assert_fs::fixture::{FileWriteStr, PathChild}; + +use crate::common::{TestContext, cmd_snapshot}; + +#[cfg(unix)] +#[test] +fn bash_shell_adapter_runs_entry() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: bash-shell + name: bash-shell + language: system + files: ^input\.txt$ + shell: bash + entry: | + items=("$@") + printf 'bash:%s:%s\n' "${items[0]}" "${items[1]}" + args: [configured] + verbose: true + "#}); + context.work_dir().child("input.txt").write_str("input")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + bash-shell...............................................................Passed + - hook id: bash-shell + - duration: [TIME] + + bash:configured:input.txt + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn pwsh_shell_adapter_runs_entry() -> anyhow::Result<()> { + if which::which("pwsh").is_err() { + return Ok(()); + } + + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pwsh-shell + name: pwsh-shell + language: system + files: ^input\.txt$ + shell: pwsh + entry: | + Write-Output "pwsh:$($args[0]):$($args[1])" + args: [configured] + verbose: true + "#}); + context.work_dir().child("input.txt").write_str("input")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + pwsh-shell...............................................................Passed + - hook id: pwsh-shell + - duration: [TIME] + + pwsh:configured:input.txt + + ----- stderr ----- + "); + + Ok(()) +} + +#[cfg(windows)] +#[test] +fn powershell_shell_adapter_runs_entry() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: powershell-shell + name: powershell-shell + language: system + files: ^input\.txt$ + shell: powershell + entry: | + Write-Output "powershell:$($args[0]):$($args[1])" + args: [configured] + verbose: true + "#}); + context.work_dir().child("input.txt").write_str("input")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + powershell-shell.........................................................Passed + - hook id: powershell-shell + - duration: [TIME] + + powershell:configured:input.txt + + ----- stderr ----- + "); + + Ok(()) +} + +#[cfg(windows)] +#[test] +fn cmd_shell_adapter_runs_entry() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: cmd-shell + name: cmd-shell + language: system + files: ^input\.txt$ + shell: cmd + entry: | + @echo off + echo cmd:%1:%2 + args: [configured] + verbose: true + "}); + context.work_dir().child("input.txt").write_str("input")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + cmd-shell................................................................Passed + - hook id: cmd-shell + - duration: [TIME] + + cmd:configured:input.txt + + ----- stderr ----- + "); + + Ok(()) +} + +#[test] +fn shell_rejected_for_pygrep() { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: check-todo + name: check-todo + language: pygrep + entry: TODO + shell: sh + always_run: true + pass_filenames: false + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Failed to init hooks + caused by: Invalid hook `check-todo` + caused by: Hook specified `shell` but the language `pygrep` does not support shell execution: `entry` is the regex pattern + "); +} diff --git a/crates/prek/tests/languages/system.rs b/crates/prek/tests/languages/system.rs new file mode 100644 index 000000000..1a740b987 --- /dev/null +++ b/crates/prek/tests/languages/system.rs @@ -0,0 +1,116 @@ +#[cfg(unix)] +use crate::common::{TestContext, cmd_snapshot}; +#[cfg(unix)] +use assert_fs::fixture::{FileWriteStr, PathChild}; + +#[cfg(unix)] +#[test] +fn multiline_entry_without_shell_uses_argv_semantics() { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: no-shell + name: no-shell + language: system + entry: | + echo first + echo second + pass_filenames: false + verbose: true + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + no-shell.................................................................Passed + - hook id: no-shell + - duration: [TIME] + + first echo second + + ----- stderr ----- + "); +} + +#[cfg(unix)] +#[test] +fn shell_runs_multiline_entry_as_one_script() { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: shell-script + name: shell-script + language: system + entry: | + echo first + echo second + shell: sh + pass_filenames: false + verbose: true + "}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + shell-script.............................................................Passed + - hook id: shell-script + - duration: [TIME] + + first + second + + ----- stderr ----- + "); +} + +#[cfg(unix)] +#[test] +fn shell_entry_receives_hook_args_before_filenames() -> anyhow::Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: shell-args + name: shell-args + language: system + files: ^a\.txt$ + entry: | + printf 'args:' + for value in "$@"; do + printf ' <%s>' "$value" + done + printf '\n' + shell: sh + args: [configured] + verbose: true + "#}); + context.work_dir().child("a.txt").write_str("a")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + shell-args...............................................................Passed + - hook id: shell-args + - duration: [TIME] + + args: + + ----- stderr ----- + "); + + Ok(()) +} diff --git a/docs/authoring-hooks.md b/docs/authoring-hooks.md index 8e050062a..539a6b08f 100644 --- a/docs/authoring-hooks.md +++ b/docs/authoring-hooks.md @@ -20,6 +20,7 @@ each manifest hook: | `id` | Yes | No | string | Stable identifier used in end-user configs. | | `name` | Yes | No | string | Human-friendly label shown in output. | | `entry` | Yes | No | string | Command to execute. | +| `shell` | No | Yes | string enum | Run `entry` through a predefined shell adapter (`sh`, `bash`, `pwsh`, `powershell`, or `cmd`). | | `language` | Yes | No | string | Execution environment, for example `python`, `node`, or `system`. | | `alias` | No | No | string | Alternate identifier accepted by `prek run`. | | `files` | No | No | regex string | Include only matching files. | @@ -50,9 +51,19 @@ manifest semantics. For the upstream reference, see: `prek`-only fields are accepted by `prek`, but upstream `pre-commit` will not recognize them. - End-user configuration may also set [`env`](configuration.md#prek-only-env). - When both the manifest and end-user config define `env`, the maps are merged - and end-user values override duplicate keys. + End-user configuration may also set [`env`](configuration.md#prek-only-env) + and [`shell`](configuration.md#shell). When both the manifest and end-user + config define `env`, the maps are merged and end-user values override + duplicate keys. + + `pass_filenames: n` with a positive integer is also a `prek` extension. + Upstream `pre-commit` only accepts a boolean value. + + When `shell` is set, `entry` is treated as shell source. Hook `args` and + filenames are passed as script arguments, so POSIX shell entries should read + them with `"$@"`. `shell` is supported only for language backends that use + the shell-aware entry resolver; see [`shell`](configuration.md#shell) for + the supported languages and exact shell adapter commands. !!! note "Manifest fields only" diff --git a/docs/configuration.md b/docs/configuration.md index dc4782a1e..f355d47cc 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -55,6 +55,7 @@ They work in both YAML and TOML, but they only matter for compatibility if you s - [`repo: builtin`](#prek-only-repo-builtin) - Hook-level: - [`env`](#prek-only-env) + - [`shell`](#shell) - [`priority`](#prek-only-priority) - [`minimum_prek_version`](#prek-only-minimum-prek-version-hook) @@ -863,6 +864,81 @@ The command line to execute for the hook. If `pass_filenames: true`, `prek` appends matching filenames to this command when running. +#### `shell` + + + +!!! note "prek-only" + + `shell` is a `prek` extension and may not be recognized by upstream `pre-commit`. + +Run `entry` through a predefined shell adapter. + +- Type: one of `sh`, `bash`, `pwsh`, `powershell`, `cmd` +- Default: `null` (run `entry` directly without a shell) + +When `shell` is omitted, `prek` preserves the default no-shell behavior: it parses `entry` into argv, invokes the command directly, and appends `args` and matching filenames as process arguments. + +When `shell` is set, `entry` is treated as source for that shell. `prek` writes the source to a temporary script file, runs it with the selected shell adapter, and passes hook `args` followed by matching filenames as script arguments. + +| `shell` | Adapter command | Script arguments | +| -- | -- | -- | +| `bash` | `bash --noprofile --norc -eo pipefail