Explicit shell execution for hook entries¶
This document outlines the design for running hook entry commands through an explicit shell adapter.
Motivation¶
prek currently runs hook entries as subprocesses without a shell. The entry string is parsed into argv, then args and matching filenames are appended to that argv. This is predictable and avoids ambient-shell differences, but it is surprising for users who write hook entries as shell snippets:
The command above looks like a shell command, but without shell execution the operators are passed as argv tokens to the process. Multiline entries have a similar problem: formatting an entry as a YAML block scalar should not, by itself, change how the command is executed.
The goal is to add shell execution as an explicit opt-in while preserving the current no-shell default.
Configuration¶
Hook Configuration: shell¶
A new optional field shell is added to hook options.
repos:
- repo: local
hooks:
- id: test-all
name: Run pytest across Python versions
language: system
entry: |
uv run --python=3.10 --isolated pytest
uv run --python=3.11 --isolated pytest
uv run --python=3.12 --isolated pytest
uv run --python=3.13 --isolated pytest
shell: bash
- Type: enum
- Default:
null - Supported values:
sh,bash,pwsh,powershell,cmd
When shell is omitted or null, prek preserves the current behavior: parse entry into argv and invoke the command directly without a shell.
When shell is set, entry is treated as source for that shell, not as an argv command line.
Execution Model¶
The implementation should model entry and shell together as a hook entry abstraction. That abstraction is responsible for converting user configuration into a concrete process invocation.
It should not own unrelated execution concerns such as batching, reporter progress, pty output, hook environment variables, working directory, or language-specific environment setup.
No Shell¶
For the default no-shell path:
- Split
entrywith the existing argv parser. - Resolve the executable and shebang as today.
- Append hook
args. - Append matching filenames when
pass_filenamesallows them.
This is the compatibility path and must remain unchanged.
Shell¶
For shell execution:
- Treat
entryas shell source. - Write the source to a temporary script file.
- Build a shell-specific command invocation for that script file.
- Pass hook
argsand matching filenames as script arguments.
Using a temporary script file avoids quoting and escaping pitfalls from placing arbitrary multiline source behind -c or equivalent command-string flags.
Shell Adapters¶
shell is a predefined adapter, not a free-form executable string. This keeps schema validation, documentation, and cross-platform behavior precise.
POSIX Shells¶
For shell: bash, use a non-interactive bash adapter:
For shell: sh, use:
Inside the script, hook args and filenames are available through "$@".
PowerShell¶
For shell: pwsh, use PowerShell Core:
For shell: powershell, use Windows PowerShell:
On Windows, both adapters also pass -ExecutionPolicy Bypass. Both adapters use a .ps1 temporary script. Hook args and filenames are available through $args.
Windows cmd¶
For shell: cmd, use:
The adapter uses a .cmd temporary script. Hook args and filenames are available through %*.
Language Interaction¶
Supported language backends already share the same shape:
- Resolve the hook entry.
- Create a process.
- Apply language-specific PATH or environment setup.
- Append
argsand filenames.
The new hook entry abstraction should provide a single way to build the concrete argv for a batch, so managed and unmanaged language backends can opt into shell execution consistently.
language: script needs one special rule: when shell is unset, the first entry token remains a repository-relative script path, matching existing behavior. When shell is set, entry is shell source and no repository-relative script-path rewriting occurs.
Container-oriented languages may still need backend-specific handling because they construct a command for a container runtime rather than directly executing the hook command on the host.
Unsupported languages
Backends that still treat entry as language-specific data or parse it outside the shell-aware resolver should reject shell during validation instead of silently ignoring it.
| Language | Why shell is unsupported |
|---|---|
docker, docker_image | entry participates in container image or entrypoint selection instead of direct host process execution. |
dart | Dart package config injection requires entry to resolve to a direct dart command. |
fail | entry is the failure message body. |
julia, rust | entry participates in install/runtime package resolution and is split before execution. |
pygrep | entry is the regex pattern. |
r | entry must use the R backend's Rscript -e <expr> or Rscript <file> forms. |
Predefined repo: meta and repo: builtin hooks should reject shell as well, because their entries are owned by prek.
Design Considerations¶
Explicitness¶
prek should not infer shell execution from multiline entry values. Formatting an entry as a YAML block scalar should not change its execution semantics.
No Ambient Shell Default¶
prek should not use the user's ambient $SHELL as a default. Login shells, GUI Git clients, CI images, and shell startup files differ too much for hook execution to be reproducible.
Enum Instead of String¶
Using a Shell enum is intentionally less flexible than accepting arbitrary strings such as shell: "bash -e". The tradeoff is worthwhile for the initial feature:
- JSON Schema can validate values and provide completions.
- Runtime behavior is documented per adapter.
- Windows behavior is explicit instead of guessed.
- There is no second shell-like parsing layer for the
shelloption itself.
If custom shells become necessary later, the enum can be extended with a structured form such as:
That extension can be added without breaking the simple enum values.
Fail-Fast Defaults¶
The predefined adapters may include fail-fast flags, such as bash -e or sh -e, because shell: bash means "use prek's bash adapter" rather than "run the literal executable bash with no policy." These templates must be documented so users can reason about differences from manually running a shell.
Compatibility¶
The default value is null, so existing hooks keep their current no-shell behavior. Hooks that need shell features must opt in with shell.
This avoids the main compatibility risk of newline-based inline script detection: changing YAML formatting or file existence should not change the meaning of a hook entry.