From 3cd5ada26067974ac9612b9cfe4169d05205164c Mon Sep 17 00:00:00 2001 From: Na'aman Hirschfeld Date: Tue, 26 May 2026 20:32:19 +0200 Subject: [PATCH 1/4] feat(run): add --no-stash flag + PREK_NO_STASH env var to disable worktree keeper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The custom WorkingTreeKeeper (crates/prek/src/cli/run/keeper.rs) captures unstaged diff to a patch file, cleans the worktree before hooks, then re-applies. When the post-hook patch fails to apply — e.g. because a downstream hook re-stages 1000+ files mid-chain so the patch anchors move — the recovery does another `git checkout -- ` to discard hook changes and retries the patch. On large staged sets (alef-regen scale) this combination has manifested as the staged set vanishing during `git commit`. There was previously no way to opt out: stash was unconditional whenever the run was scoped (not --all-files / --files / --directory). Add a `--no-stash` CLI flag and `PREK_NO_STASH` env var that short-circuit `should_stash`, leaving the worktree untouched and bypassing the keeper entirely. Useful for commit chains where hooks re-stage files and conflict with the stash restore. --- crates/prek-consts/src/env_vars.rs | 1 + crates/prek/src/cli/hook_impl.rs | 1 + crates/prek/src/cli/mod.rs | 9 ++ crates/prek/src/cli/run/run.rs | 9 +- crates/prek/src/cli/try_repo.rs | 1 + crates/prek/src/main.rs | 1 + crates/prek/tests/run.rs | 122 ++++++++++++++++++++++++ docs/reference/cli.md | 4 + docs/reference/environment-variables.md | 16 ++++ 9 files changed, 163 insertions(+), 1 deletion(-) diff --git a/crates/prek-consts/src/env_vars.rs b/crates/prek-consts/src/env_vars.rs index fb7441907..e50b571de 100644 --- a/crates/prek-consts/src/env_vars.rs +++ b/crates/prek-consts/src/env_vars.rs @@ -25,6 +25,7 @@ impl EnvVars { pub const PREK_NO_CONCURRENCY: &'static str = "PREK_NO_CONCURRENCY"; pub const PREK_MAX_CONCURRENCY: &'static str = "PREK_MAX_CONCURRENCY"; pub const PREK_NO_FAST_PATH: &'static str = "PREK_NO_FAST_PATH"; + pub const PREK_NO_STASH: &'static str = "PREK_NO_STASH"; pub const PREK_UV_SOURCE: &'static str = "PREK_UV_SOURCE"; pub const PREK_NATIVE_TLS: &'static str = "PREK_NATIVE_TLS"; pub const SSL_CERT_FILE: &'static str = "SSL_CERT_FILE"; diff --git a/crates/prek/src/cli/hook_impl.rs b/crates/prek/src/cli/hook_impl.rs index 01975f5ba..8e07cb43b 100644 --- a/crates/prek/src/cli/hook_impl.rs +++ b/crates/prek/src/cli/hook_impl.rs @@ -136,6 +136,7 @@ pub(crate) async fn hook_impl( flag(run_args.fail_fast, run_args.no_fail_fast), false, false, + false, run_args.extra, false, printer, diff --git a/crates/prek/src/cli/mod.rs b/crates/prek/src/cli/mod.rs index 4513c630b..444f6e48f 100644 --- a/crates/prek/src/cli/mod.rs +++ b/crates/prek/src/cli/mod.rs @@ -567,6 +567,15 @@ pub(crate) struct RunArgs { #[arg(long, hide = true, overrides_with = "fail_fast")] pub(crate) no_fail_fast: bool, + /// Do not clean unstaged changes via the working-tree keeper before running hooks. + /// + /// Equivalent to setting `PREK_NO_STASH=1` or `no_stash: true` in the project + /// configuration file. Useful when several agents or tools are editing the same + /// repository concurrently, where the keeper's recovery path can clobber + /// uncommitted work in flight from other processes. + #[arg(long)] + pub(crate) no_stash: bool, + /// Do not run the hooks, but print the hooks that would have been run. #[arg(long)] pub(crate) dry_run: bool, diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index e2a231da5..837745f95 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -56,6 +56,7 @@ pub(crate) async fn run( show_diff_on_failure: bool, fail_fast: Option, dry_run: bool, + no_stash: bool, refresh: bool, extra_args: RunExtraArgs, verbose: bool, @@ -78,7 +79,13 @@ pub(crate) async fn run( // Ensure we are in a git repository. LazyLock::force(&GIT_ROOT).as_ref()?; - let should_stash = !all_files && files.is_empty() && directories.is_empty(); + // `--no-stash` flag or `PREK_NO_STASH=` env var disables the + // working-tree keeper entirely. Useful when downstream hooks re-stage files + // and conflict with prek's stash restore on large diffs. + let no_stash = + no_stash || EnvVars::var_as_bool(EnvVars::PREK_NO_STASH).unwrap_or(false); + let should_stash = + !all_files && files.is_empty() && directories.is_empty() && !no_stash; // Check if we have unresolved merge conflict files and fail fast. if should_stash && git::has_unmerged_paths().await? { diff --git a/crates/prek/src/cli/try_repo.rs b/crates/prek/src/cli/try_repo.rs index 06a238b07..34b8c81ce 100644 --- a/crates/prek/src/cli/try_repo.rs +++ b/crates/prek/src/cli/try_repo.rs @@ -221,6 +221,7 @@ pub(crate) async fn try_repo( run_args.show_diff_on_failure, flag(run_args.fail_fast, run_args.no_fail_fast), run_args.dry_run, + run_args.no_stash, refresh, run_args.extra, verbose, diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index 104ff8112..f98616c84 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -298,6 +298,7 @@ async fn run(cli: Cli) -> Result { args.show_diff_on_failure, flag(args.fail_fast, args.no_fail_fast), args.dry_run, + args.no_stash, cli.globals.refresh, args.extra, cli.globals.verbose > 0, diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index bed8832d0..0cfa137f3 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -1347,6 +1347,7 @@ fn global_path_options_expand_tilde() -> Result<()> { show_diff_on_failure: false, fail_fast: false, no_fail_fast: false, + no_stash: false, dry_run: false, extra: RunExtraArgs { remote_branch: None, @@ -1491,6 +1492,126 @@ fn staged_files_only() -> Result<()> { Ok(()) } +/// With `--no-stash`, the worktree keeper is bypassed entirely: unstaged +/// changes remain in place while hooks run, no patch file is created, and +/// no stash/restore stderr messages are emitted. +#[test] +fn no_stash_flag_skips_worktree_keeper() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: trailing-whitespace + name: trailing-whitespace + language: system + entry: python3 -c 'print(open("file.txt", "rt").read())' + verbose: true + types: [text] + "#}); + + context + .work_dir() + .child("file.txt") + .write_str("Hello, world!")?; + context.git_add("."); + + // Add an unstaged modification. Without `--no-stash` the keeper would + // stash this and the hook would see the staged "Hello, world!" content. + // With `--no-stash`, the hook sees the on-disk "Hello world again!". + context + .work_dir() + .child("file.txt") + .write_str("Hello world again!")?; + + cmd_snapshot!(context.filters(), context.run().arg("--no-stash"), @r" + success: true + exit_code: 0 + ----- stdout ----- + trailing-whitespace......................................................Passed + - hook id: trailing-whitespace + - duration: [TIME] + + Hello world again! + + ----- stderr ----- + "); + + // The unstaged content must still be present after the run. + let content = context.read("file.txt"); + assert_snapshot!(content, @"Hello world again!"); + + // No patch file should have been created in the prek patches dir. + let patches_dir = context.home_dir().child("patches"); + if patches_dir.exists() { + let entries: Vec<_> = fs_err::read_dir(patches_dir.path())?.collect(); + assert!( + entries.is_empty(), + "--no-stash must not create patch files, found: {entries:?}" + ); + } + + Ok(()) +} + +/// `PREK_NO_STASH=1` env var should produce the same effect as `--no-stash`. +#[test] +fn no_stash_env_var_skips_worktree_keeper() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: trailing-whitespace + name: trailing-whitespace + language: system + entry: python3 -c 'print(open("file.txt", "rt").read())' + verbose: true + types: [text] + "#}); + + context + .work_dir() + .child("file.txt") + .write_str("Hello, world!")?; + context.git_add("."); + + context + .work_dir() + .child("file.txt") + .write_str("Hello world again!")?; + + cmd_snapshot!(context.filters(), context.run().env(EnvVars::PREK_NO_STASH, "1"), @r" + success: true + exit_code: 0 + ----- stdout ----- + trailing-whitespace......................................................Passed + - hook id: trailing-whitespace + - duration: [TIME] + + Hello world again! + + ----- stderr ----- + "); + + let content = context.read("file.txt"); + assert_snapshot!(content, @"Hello world again!"); + + // No patch file should have been created in the prek patches dir. + let patches_dir = context.home_dir().child("patches"); + if patches_dir.exists() { + let entries: Vec<_> = fs_err::read_dir(patches_dir.path())?.collect(); + assert!( + entries.is_empty(), + "PREK_NO_STASH=1 must not create patch files, found: {entries:?}" + ); + } + + Ok(()) +} + #[cfg(unix)] #[test] fn restore_on_interrupt() -> Result<()> { @@ -2468,6 +2589,7 @@ fn selectors_completion() -> Result<()> { --stage The stage during which the hook is fired --show-diff-on-failure When hooks fail, run `git diff` directly afterward --fail-fast Stop running hooks after the first failure + --no-stash Do not clean unstaged changes via the working-tree keeper before running hooks --dry-run Do not run the hooks, but print the hooks that would have been run --config Path to alternate config file --cd Change to directory before running diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 71bf289d2..adfb98739 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -232,6 +232,8 @@ prek run [OPTIONS] [HOOK|PROJECT]...
--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.

+
--no-stash

Do not clean unstaged changes via the working-tree keeper before running hooks.

+

Equivalent to setting PREK_NO_STASH=1. Useful when hook chains re-stage files and conflict with prek's stash restore on large diffs.

--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

@@ -784,6 +786,8 @@ prek try-repo [OPTIONS] [HOOK|PROJECT]...
--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.

+
--no-stash

Do not clean unstaged changes via the working-tree keeper before running hooks.

+

Equivalent to setting PREK_NO_STASH=1. Useful when hook chains re-stage files and conflict with prek's stash restore on large diffs.

--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

diff --git a/docs/reference/environment-variables.md b/docs/reference/environment-variables.md index a02e1394d..9ac15898f 100644 --- a/docs/reference/environment-variables.md +++ b/docs/reference/environment-variables.md @@ -45,6 +45,22 @@ If you encounter "Too many open files" errors, lowering this value or raising th Disable Rust-native built-in hooks; always use the original hook implementation. See [Built-in Fast Hooks](../builtin.md) for details. +### `PREK_NO_STASH` + +Disable the working-tree keeper that stashes unstaged changes before running hooks +and restores them afterwards. +Equivalent to passing `--no-stash` on the command line or setting `no_stash: true` +in the project configuration file. + +The autostash behavior is undesirable when working with several agents or tools at +once on the same code — the keeper's recovery path runs `git checkout -- `, +which can clobber uncommitted work in flight from concurrent processes. +With this set, prek leaves the working tree untouched while hooks run. + +Precedence (highest wins): the `--no-stash` flag, then `PREK_NO_STASH`, then the +`no_stash` config key. An explicit `PREK_NO_STASH=0` overrides a `no_stash: true` +config entry. + ### `PREK_UV_SOURCE` Control how uv (Python package installer) is installed. From f8000dd3a58c0935610f9d2f8c499863ec7e1a65 Mon Sep 17 00:00:00 2001 From: Na'aman Hirschfeld Date: Tue, 26 May 2026 20:43:59 +0200 Subject: [PATCH 2/4] feat(config): support top-level no_stash key in .pre-commit-config.yaml / prek.toml Extend the `--no-stash` opt-out added in 452b714d so it can also be set as a top-level key in the project's config file, letting users disable the working-tree keeper per-repo without remembering an env var or flag every invocation. Useful when a repo's hook chain consistently conflicts with prek's patch-based stash/restore (e.g. shared-pre-commit-hooks autofix with --stage-all on alef-regen-scale staged sets). Resolution precedence (highest wins): `--no-stash` CLI flag > `PREK_NO_STASH` env var > top-level `no_stash: true` in config > default-false. The config field is wired through `Config::no_stash: Option` mirroring `fail_fast`, and the JSON schema + snapshot fixtures are regenerated accordingly. --- CHANGELOG.md | 10 ++ crates/prek/src/cli/run/run.rs | 35 +++-- crates/prek/src/config.rs | 6 + crates/prek/src/hook.rs | 1 + ...prek__config__tests__language_version.snap | 1 + .../prek__config__tests__meta_hooks-5.snap | 1 + ...ests__numeric_rev_is_parsed_as_string.snap | 1 + .../prek__config__tests__parse_hooks-3.snap | 1 + .../prek__config__tests__parse_repos-3.snap | 1 + .../prek__config__tests__parse_repos-4.snap | 1 + .../prek__config__tests__parse_repos-6.snap | 1 + .../prek__config__tests__parse_repos.snap | 1 + ...g__tests__read_config_with_merge_keys.snap | 1 + ...s__read_config_with_nested_merge_keys.snap | 1 + ...prek__config__tests__read_toml_config.snap | 1 + ...prek__config__tests__read_yaml_config.snap | 1 + crates/prek/tests/run.rs | 126 ++++++++++++++++++ docs/reference/cli.md | 4 +- docs/reference/configuration.md | 33 +++++ prek.schema.json | 4 + 20 files changed, 219 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cb24c9b5c..478678be4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## Unreleased + +### Enhancements + +- Add opt-out for the working-tree keeper via `--no-stash`, `PREK_NO_STASH`, and a + `no_stash` top-level config key. The autostash behavior is undesirable when + working with several agents or tools at once on the same code — the keeper's + recovery path runs `git checkout -- `, which can clobber uncommitted work + in flight from concurrent processes. + ## 0.4.3 Released on 2026-05-27. diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index 837745f95..ca973cfd5 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -79,24 +79,39 @@ pub(crate) async fn run( // Ensure we are in a git repository. LazyLock::force(&GIT_ROOT).as_ref()?; - // `--no-stash` flag or `PREK_NO_STASH=` env var disables the + let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?; + let selectors = Selectors::load(&includes, &skips, &workspace_root)?; + let mut workspace = + Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?; + + // `--no-stash` flag, `PREK_NO_STASH=` env var, or the top-level + // `no_stash: true` key in the root project's config disables the // working-tree keeper entirely. Useful when downstream hooks re-stage files // and conflict with prek's stash restore on large diffs. - let no_stash = - no_stash || EnvVars::var_as_bool(EnvVars::PREK_NO_STASH).unwrap_or(false); - let should_stash = - !all_files && files.is_empty() && directories.is_empty() && !no_stash; + // Resolution precedence (highest wins): CLI flag > env var > config > default-false. + let config_no_stash = workspace + .projects() + .iter() + .find(|p| p.is_root()) + .and_then(|p| p.config().no_stash) + .unwrap_or(false); + // CLI flag wins outright. Otherwise an explicit env var value (including + // `PREK_NO_STASH=0`) overrides the config; absent any env var we fall back + // to the config value (default false). + let no_stash = if no_stash { + true + } else if let Some(env_value) = EnvVars::var_as_bool(EnvVars::PREK_NO_STASH) { + env_value + } else { + config_no_stash + }; + let should_stash = !all_files && files.is_empty() && directories.is_empty() && !no_stash; // Check if we have unresolved merge conflict files and fail fast. if should_stash && git::has_unmerged_paths().await? { anyhow::bail!("You have unmerged paths. Resolve them before running prek"); } - let workspace_root = Workspace::find_root(config.as_deref(), &CWD)?; - let selectors = Selectors::load(&includes, &skips, &workspace_root)?; - let mut workspace = - Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?; - if should_stash { workspace.check_configs_staged().await?; } diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index 81397f104..97003a443 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -1136,6 +1136,12 @@ pub(crate) struct Config { /// Set to true to have prek stop running hooks after the first failure. /// Default is false. pub fail_fast: Option, + /// Set to true to skip prek's working-tree keeper (stash/restore of unstaged + /// changes before running hooks). Equivalent to passing `--no-stash` or + /// setting `PREK_NO_STASH=1`. Useful when hook chains aggressively re-stage + /// files and conflict with prek's patch-based restore on large diffs. + /// Default is false. + pub no_stash: Option, /// The minimum version of prek required to run this configuration. #[serde(deserialize_with = "deserialize_and_validate_minimum_version", default)] pub minimum_prek_version: Option, diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index 372790eb3..d6c36a963 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -940,6 +940,7 @@ mod tests { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 a03475643..c30ef7c81 100644 --- a/crates/prek/src/snapshots/prek__config__tests__language_version.snap +++ b/crates/prek/src/snapshots/prek__config__tests__language_version.snap @@ -117,6 +117,7 @@ Ok( files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 afc581817..f8719641b 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 @@ -128,6 +128,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 1ed0992c6..f428fb316 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 @@ -51,6 +51,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 47bbcb92a..43557a293 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 @@ -50,6 +50,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 de253da67..dc554b22d 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 @@ -56,6 +56,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 93c42ae52..eeb36e624 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 @@ -51,6 +51,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 93c42ae52..eeb36e624 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 @@ -51,6 +51,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 fa4e159cd..53121295f 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap @@ -50,6 +50,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 1164f40d3..f06896ff9 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 @@ -90,6 +90,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 62730b61d..0052eb923 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 @@ -56,6 +56,7 @@ Config { files: None, exclude: None, fail_fast: None, + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: { 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 1c8ae25a3..fece47266 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 @@ -126,6 +126,7 @@ Config { fail_fast: Some( true, ), + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, 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 736d7ca06..ed2b4b704 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 @@ -311,6 +311,7 @@ Config { fail_fast: Some( true, ), + no_stash: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index 0cfa137f3..ce6edf364 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -1612,6 +1612,132 @@ fn no_stash_env_var_skips_worktree_keeper() -> Result<()> { Ok(()) } +/// Top-level `no_stash: true` in `.pre-commit-config.yaml` should produce the +/// same effect as the `--no-stash` flag or `PREK_NO_STASH=1` env var. +#[test] +fn no_stash_config_key_skips_worktree_keeper() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + no_stash: true + repos: + - repo: local + hooks: + - id: trailing-whitespace + name: trailing-whitespace + language: system + entry: python3 -c 'print(open("file.txt", "rt").read())' + verbose: true + types: [text] + "#}); + + context + .work_dir() + .child("file.txt") + .write_str("Hello, world!")?; + context.git_add("."); + + // Add an unstaged modification. With config `no_stash: true` the keeper is + // skipped and the hook should observe the on-disk "Hello world again!" + // rather than the stashed-staged "Hello, world!". + context + .work_dir() + .child("file.txt") + .write_str("Hello world again!")?; + + cmd_snapshot!(context.filters(), context.run(), @r" + success: true + exit_code: 0 + ----- stdout ----- + trailing-whitespace......................................................Passed + - hook id: trailing-whitespace + - duration: [TIME] + + Hello world again! + + ----- stderr ----- + "); + + let content = context.read("file.txt"); + assert_snapshot!(content, @"Hello world again!"); + + // No patch file should have been created in the prek patches dir. + let patches_dir = context.home_dir().child("patches"); + if patches_dir.exists() { + let entries: Vec<_> = fs_err::read_dir(patches_dir.path())?.collect(); + assert!( + entries.is_empty(), + "no_stash: true must not create patch files, found: {entries:?}" + ); + } + + Ok(()) +} + +/// Documented precedence: an explicit `PREK_NO_STASH=0` overrides +/// `no_stash: true` in the project configuration. With the env var set false, +/// the working-tree keeper must engage even when the config opts out. +#[test] +fn no_stash_env_false_overrides_config_true() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + context.write_pre_commit_config(indoc::indoc! {r#" + no_stash: true + repos: + - repo: local + hooks: + - id: trailing-whitespace + name: trailing-whitespace + language: system + entry: python3 -c 'print(open("file.txt", "rt").read())' + verbose: true + types: [text] + "#}); + + context + .work_dir() + .child("file.txt") + .write_str("Hello, world!")?; + context.git_add("."); + + // Non-staged change that the keeper must stash/restore when engaged. + context + .work_dir() + .child("file.txt") + .write_str("Hello world again!")?; + + let filters: Vec<_> = context + .filters() + .into_iter() + .chain([(r"/\d+-\d+.patch", "/[TIME]-[PID].patch")]) + .collect(); + + // `PREK_NO_STASH=0` must override config `no_stash: true`, re-engaging the + // keeper. The hook therefore sees the staged "Hello, world!" content + // rather than the on-disk "Hello world again!", and stash/restore stderr + // lines are emitted. + cmd_snapshot!(filters, context.run().env(EnvVars::PREK_NO_STASH, "0"), @r" + success: true + exit_code: 0 + ----- stdout ----- + trailing-whitespace......................................................Passed + - hook id: trailing-whitespace + - duration: [TIME] + + Hello, world! + + ----- stderr ----- + Unstaged changes detected, stashing unstaged changes to `[HOME]/patches/[TIME]-[PID].patch` + Restored working tree changes from `[HOME]/patches/[TIME]-[PID].patch` + "); + + // The unstaged content must be restored after the run. + let content = context.read("file.txt"); + assert_snapshot!(content, @"Hello world again!"); + + Ok(()) +} + #[cfg(unix)] #[test] fn restore_on_interrupt() -> Result<()> { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index adfb98739..3d4c4d772 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -233,7 +233,7 @@ prek run [OPTIONS] [HOOK|PROJECT]...
--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--no-stash

Do not clean unstaged changes via the working-tree keeper before running hooks.

-

Equivalent to setting PREK_NO_STASH=1. Useful when hook chains re-stage files and conflict with prek's stash restore on large diffs.

+

Equivalent to setting PREK_NO_STASH=1 or no_stash: true in the project configuration file. Useful when several agents or tools are editing the same repository concurrently, where the keeper's recovery path can clobber uncommitted work in flight from other processes.

--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

@@ -787,7 +787,7 @@ prek try-repo [OPTIONS] [HOOK|PROJECT]...
--no-progress

Hide all progress outputs.

For example, spinners or progress bars.

--no-stash

Do not clean unstaged changes via the working-tree keeper before running hooks.

-

Equivalent to setting PREK_NO_STASH=1. Useful when hook chains re-stage files and conflict with prek's stash restore on large diffs.

+

Equivalent to setting PREK_NO_STASH=1 or no_stash: true in the project configuration file. Useful when several agents or tools are editing the same repository concurrently, where the keeper's recovery path can clobber uncommitted work in flight from other processes.

--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

diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index 542b84028..fb13fdee2 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -219,6 +219,39 @@ Stop the run after the first failing hook. This is a global default; individual hooks can also set `fail_fast`. +### `no_stash` + +Disable the working-tree keeper that stashes unstaged changes before running hooks +and restores them afterwards. With this set, prek leaves the working tree untouched +while hooks run. + +- Type: boolean +- Default: `false` + +The autostash behavior is undesirable when working with several agents or tools at +once on the same code — the keeper's recovery path runs `git checkout -- `, +which can clobber uncommitted work in flight from concurrent processes. +Setting `no_stash: true` opts the entire repository out of that behavior. + +Equivalent to passing `--no-stash` on the command line or setting the +`PREK_NO_STASH` environment variable. Precedence (highest wins): the CLI flag, +then the environment variable, then this config key. An explicit +`PREK_NO_STASH=0` overrides a `no_stash: true` config entry. + +Example: + +=== "prek.toml" + + ```toml + no_stash = true + ``` + +=== ".pre-commit-config.yaml" + + ```yaml + no_stash: true + ``` + ### `default_language_version` Map a language name to the default [`language_version`](#language_version) used by hooks of that language. diff --git a/prek.schema.json b/prek.schema.json index cd545a84a..5a6bb58ae 100644 --- a/prek.schema.json +++ b/prek.schema.json @@ -118,6 +118,10 @@ "description": "Set to true to have prek stop running hooks after the first failure.\nDefault is false.", "type": "boolean" }, + "no_stash": { + "description": "Set to true to skip prek's working-tree keeper (stash/restore of unstaged\nchanges before running hooks). Equivalent to passing `--no-stash` or\nsetting `PREK_NO_STASH=1`. Useful when hook chains aggressively re-stage\nfiles and conflict with prek's patch-based restore on large diffs.\nDefault is false.", + "type": "boolean" + }, "minimum_prek_version": { "description": "The minimum version of prek required to run this configuration.", "type": "string" From b8a0b12abddac8561923e2183a4fcc5609e0c953 Mon Sep 17 00:00:00 2001 From: Na'aman Hirschfeld Date: Wed, 27 May 2026 08:35:21 +0200 Subject: [PATCH 3/4] chore: align shared pre-commit hooks --- .pre-commit-config.yaml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 59c141b5c..9ae0dab28 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -15,7 +15,7 @@ repos: - id: end-of-file-fixer - repo: https://github.com/crate-ci/typos - rev: v1.46.2 + rev: v1.46.3 hooks: - id: typos @@ -40,6 +40,11 @@ repos: - id: check-renovate additional_dependencies: ['json5'] + - repo: https://github.com/kreuzberg-dev/pre-commit-hooks + rev: v1.2.3 + hooks: + - id: actionlint + - repo: local hooks: - id: taplo-fmt From ba62c224ca641ad9f05cf1347e2d2ea0f8902997 Mon Sep 17 00:00:00 2001 From: Na'aman Hirschfeld Date: Wed, 27 May 2026 10:09:52 +0200 Subject: [PATCH 4/4] fix(run): honour root no_stash when a nested project is selected When prek run is scoped to a nested project via a path selector (e.g. `prek run subproj/`), `Workspace::discover` filters its `projects` list to only the selected project. The root project is absent from that set, so looking for `is_root()` inside `workspace.projects()` fell through to the `unwrap_or(false)` default, silently re-engaging the working-tree keeper despite a repository-wide `no_stash: true` in the root config. Fix: read `no_stash` from `workspace.all_projects()`, which is always the complete, unfiltered project list regardless of CLI selectors. Adds a regression test that creates a workspace with root `no_stash: true`, a nested project without the key, and asserts the keeper is not engaged when the run is scoped to only the nested project. --- crates/prek/src/cli/run/run.rs | 6 ++- crates/prek/tests/run.rs | 93 ++++++++++++++++++++++++++++++++++ 2 files changed, 98 insertions(+), 1 deletion(-) diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index ca973cfd5..eace421fc 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -89,8 +89,12 @@ pub(crate) async fn run( // working-tree keeper entirely. Useful when downstream hooks re-stage files // and conflict with prek's stash restore on large diffs. // Resolution precedence (highest wins): CLI flag > env var > config > default-false. + // Use `all_projects()` (the unfiltered set) instead of `projects()` so that + // a root-level `no_stash: true` is honoured even when the CLI selector + // narrows the run to a nested project and the root project is absent from + // the filtered set. let config_no_stash = workspace - .projects() + .all_projects() .iter() .find(|p| p.is_root()) .and_then(|p| p.config().no_stash) diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index ce6edf364..5b3d2e211 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -1738,6 +1738,99 @@ fn no_stash_env_false_overrides_config_true() -> Result<()> { Ok(()) } +/// Root `no_stash: true` must be honoured when the CLI selector narrows the +/// run to a nested project, i.e. when the root project is absent from the +/// selector-filtered set returned by `workspace.projects()`. +/// +/// Regression test for: "Honor root `no_stash` when filtering projects". +#[test] +fn no_stash_root_config_honoured_when_nested_project_selected() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + // Root config opts out of stashing. + context.write_pre_commit_config(indoc::indoc! {r#" + no_stash: true + repos: + - repo: local + hooks: + - id: check-root + name: check-root + language: system + entry: python3 -c 'print("root")' + verbose: true + types: [text] + "#}); + + // Nested project does NOT have `no_stash` — the root opt-out must still + // propagate when the run is scoped to `subproj/` only. + context + .work_dir() + .child("subproj") + .create_dir_all() + .expect("Failed to create subproj dir"); + context + .work_dir() + .child("subproj/.pre-commit-config.yaml") + .write_str(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: trailing-whitespace + name: trailing-whitespace + language: system + entry: python3 -c 'print(open("file.txt", "rt").read())' + verbose: true + types: [text] + "#}) + .expect("Failed to write subproj config"); + + context + .work_dir() + .child("subproj/file.txt") + .write_str("Hello, world!")?; + context.git_add("."); + + // Unstaged modification — with the keeper engaged this would be stashed. + // With root `no_stash: true` honoured it must remain visible to the hook. + context + .work_dir() + .child("subproj/file.txt") + .write_str("Hello world again!")?; + + // Select only the nested project; the root project is NOT in the filtered + // set. The keeper must still be disabled because the root config says so. + cmd_snapshot!(context.filters(), context.run().arg("subproj/"), @r" + success: true + exit_code: 0 + ----- stdout ----- + ✓ subproj + trailing-whitespace....................................................Passed + - hook id: trailing-whitespace + - duration: [TIME] + + Hello world again! + + ----- stderr ----- + "); + + // The unstaged content must still be present after the run. + let content = context.read("subproj/file.txt"); + assert_snapshot!(content, @"Hello world again!"); + + // No patch file should have been created. + let patches_dir = context.home_dir().child("patches"); + if patches_dir.exists() { + let entries: Vec<_> = fs_err::read_dir(patches_dir.path())?.collect(); + assert!( + entries.is_empty(), + "root no_stash: true must suppress patch files even with nested selector, found: {entries:?}" + ); + } + + Ok(()) +} + #[cfg(unix)] #[test] fn restore_on_interrupt() -> Result<()> {