diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index 5fc8839c..1c7dd471 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -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 !language.supports_shell() { + 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", + ), + }); + } + } + Ok(()) } @@ -502,6 +534,13 @@ impl HookEntry { /// Split the entry into a list of commands. pub(crate) fn split(&self) -> Result, Error> { + if let Some(shell) = self.shell { + panic!( + "internal error: attempted to split shell entry `{}` using {shell:?}", + self.hook + ); + } + 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), @@ -519,6 +558,10 @@ impl HookEntry { pub(crate) fn raw(&self) -> &str { &self.entry } + + pub(crate) fn shell(&self) -> Option { + self.shell + } } impl Shell { @@ -997,7 +1040,7 @@ mod tests { use crate::languages::version::LanguageRequest; use crate::workspace::Project; - use super::{Hook, HookBuilder, Repo, bash_argv, cmd_argv, powershell_argv}; + use super::{Hook, HookBuilder, Repo}; #[tokio::test] async fn hook_builder_build_fills_and_merges_attributes() -> Result<()> { diff --git a/crates/prek/src/languages/mod.rs b/crates/prek/src/languages/mod.rs index da8a7bcc..34a99869 100644 --- a/crates/prek/src/languages/mod.rs +++ b/crates/prek/src/languages/mod.rs @@ -129,6 +129,24 @@ impl LanguageImpl for Unimplemented { // system: only system version, no env, no additional deps impl Language { + pub(crate) fn supports_shell(self) -> bool { + matches!( + 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 + ) + } + pub fn supported(lang: Language) -> bool { matches!( lang, diff --git a/crates/prek/src/languages/python/pep723.rs b/crates/prek/src/languages/python/pep723.rs index c704362a..591e52b3 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`", diff --git a/crates/prek/tests/languages/main.rs b/crates/prek/tests/languages/main.rs index 12e98827..422088ac 100644 --- a/crates/prek/tests/languages/main.rs +++ b/crates/prek/tests/languages/main.rs @@ -19,6 +19,7 @@ mod python; mod ruby; mod rust; mod script; +mod shell; mod swift; mod system; mod unimplemented; diff --git a/crates/prek/tests/languages/shell.rs b/crates/prek/tests/languages/shell.rs new file mode 100644 index 00000000..0541c17f --- /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 + "); +} diff --git a/docs/authoring-hooks.md b/docs/authoring-hooks.md index 22e6ef19..69bd73bd 100644 --- a/docs/authoring-hooks.md +++ b/docs/authoring-hooks.md @@ -58,8 +58,9 @@ manifest semantics. For the upstream reference, see: 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 `"$@"`. See [`shell`](configuration.md#shell) for the exact shell - adapter commands. + 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 a4f0c167..5ba8a465 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -871,11 +871,14 @@ Run `entry` through a predefined shell adapter. - Type: one of `sh`, `bash`, `pwsh`, `powershell`, `cmd` - Default: `null` (run `entry` directly without a shell) - `prek`-only +- Supported for `bun`, `deno`, `dotnet`, `golang`, `haskell`, `lua`, `node`, `python`, `ruby`, `script`, `swift`, and `system` hooks. 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` is rejected for language backends that do not run `entry` through the shell-aware entry resolver, including `docker`, `docker_image`, `fail`, `julia`, `pygrep`, and `rust`, and for `repo: meta` and `repo: builtin` hooks. + | `shell` | Adapter command | Script arguments | | -- | -- | -- | | `bash` | `bash --noprofile --norc -eo pipefail