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 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-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..eace421fc 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,18 +79,43 @@ 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(); + 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. + // 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 + .all_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/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/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/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/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 bed8832d0..5b3d2e211 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,345 @@ 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(()) +} + +/// 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(()) +} + +/// 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<()> { @@ -2468,6 +2808,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..3d4c4d772 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 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

@@ -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 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/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. 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"