diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index b54afc9b6..360eccfd7 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -89,6 +89,10 @@ pub(crate) async fn run( let has_group_filters = group_filters.has_filters(); let workspace = Workspace::discover(store, workspace_root, config, Some(&selectors), refresh)?; + if report_unfrozen_revs(&workspace, printer)? { + return Ok(ExitStatus::Failure); + } + if should_stash { workspace.check_configs_staged().await?; } @@ -241,6 +245,54 @@ pub(crate) async fn run( .await } +fn report_unfrozen_revs(workspace: &Workspace, printer: Printer) -> Result { + let projects = workspace + .projects() + .iter() + .filter(|project| project.config().requires_frozen_revs()) + .filter_map(|project| { + let repos = project + .config() + .repos_with_unfrozen_revs() + .collect::>(); + (!repos.is_empty()).then_some((project, repos)) + }) + .collect::>(); + + if projects.is_empty() { + return Ok(false); + } + + writeln!( + printer.stderr(), + "{}: Config contains non-frozen remote hook revisions", + "error".red().bold() + )?; + for (project, repos) in projects { + writeln!( + printer.stderr(), + " in `{}`:", + project.config_file().display() + )?; + for repo in repos { + writeln!( + printer.stderr(), + " - {} uses rev `{}`", + repo.repo.cyan(), + repo.rev.yellow() + )?; + } + } + writeln!( + printer.stderr(), + "{}: run `{}` to replace tags with commit SHAs", + "hint".yellow().bold(), + "prek auto-update --freeze".cyan() + )?; + + Ok(true) +} + fn infer_stage_and_input_mode( explicit_stage: Option, has_group_filters: bool, diff --git a/crates/prek/src/cli/validate.rs b/crates/prek/src/cli/validate.rs index d5fece91b..662d8a505 100644 --- a/crates/prek/src/cli/validate.rs +++ b/crates/prek/src/cli/validate.rs @@ -7,10 +7,44 @@ use anyhow::Result; use owo_colors::OwoColorize; use crate::cli::ExitStatus; -use crate::config::{read_config, read_manifest}; +use crate::config::{Config, read_config, read_manifest}; use crate::printer::Printer; use crate::warn_user; +fn write_unfrozen_rev_error( + config: &Config, + config_path: &std::path::Path, + printer: Printer, +) -> Result { + let repos = config.repos_with_unfrozen_revs().collect::>(); + if repos.is_empty() { + return Ok(false); + } + + writeln!( + printer.stderr(), + "{}: Config `{}` contains non-frozen remote hook revisions", + "error".red().bold(), + config_path.display() + )?; + for repo in repos { + writeln!( + printer.stderr(), + " - {} uses rev `{}`", + repo.repo.cyan(), + repo.rev.yellow() + )?; + } + writeln!( + printer.stderr(), + "{}: run `{}` to replace tags with commit SHAs", + "hint".yellow().bold(), + "prek auto-update --freeze".cyan() + )?; + + Ok(true) +} + pub(crate) fn validate_configs(configs: Vec, printer: Printer) -> Result { let mut status = ExitStatus::Success; @@ -19,18 +53,27 @@ pub(crate) fn validate_configs(configs: Vec, printer: Printer) -> Resul return Ok(ExitStatus::Success); } - for config in configs { - if let Err(err) = read_config(&config) { - writeln!(printer.stderr(), "{}: {}", "error".red().bold(), err)?; - for source in iter::successors(err.source(), |&err| err.source()) { - writeln!( - printer.stderr(), - " {}: {}", - "caused by".red().bold(), - source - )?; + for config_path in configs { + match read_config(&config_path) { + Ok(config) => { + if config.requires_frozen_revs() + && write_unfrozen_rev_error(&config, &config_path, printer)? + { + status = ExitStatus::Failure; + } + } + Err(err) => { + writeln!(printer.stderr(), "{}: {}", "error".red().bold(), err)?; + for source in iter::successors(err.source(), |&err| err.source()) { + writeln!( + printer.stderr(), + " {}: {}", + "caused by".red().bold(), + source + )?; + } + status = ExitStatus::Failure; } - status = ExitStatus::Failure; } } diff --git a/crates/prek/src/config.rs b/crates/prek/src/config.rs index e73adbf91..6a68c323c 100644 --- a/crates/prek/src/config.rs +++ b/crates/prek/src/config.rs @@ -929,6 +929,11 @@ impl RemoteRepo { } } +/// Check if a string looks like a frozen git revision. +pub(crate) fn is_frozen_rev(s: &str) -> bool { + looks_like_sha(s) +} + impl Display for RemoteRepo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}@{}", self.repo, self.rev) @@ -1175,6 +1180,9 @@ 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 require remote repository revisions to be pinned to commit SHAs. + /// Default is false. + pub require_frozen_revs: 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, @@ -1189,6 +1197,19 @@ pub(crate) struct Config { _unused_keys: BTreeMap, } +impl Config { + pub(crate) fn requires_frozen_revs(&self) -> bool { + self.require_frozen_revs.unwrap_or(false) + } + + pub(crate) fn repos_with_unfrozen_revs(&self) -> impl Iterator { + self.repos.iter().filter_map(|repo| match repo { + Repo::Remote(repo) if !is_frozen_rev(&repo.rev) => Some(repo), + Repo::Remote(_) | Repo::Local(_) | Repo::Meta(_) | Repo::Builtin(_) => None, + }) + } +} + #[derive(Debug, thiserror::Error)] pub(crate) enum Error { #[error(transparent)] diff --git a/crates/prek/src/hook.rs b/crates/prek/src/hook.rs index 194cc42be..f0ae046d7 100644 --- a/crates/prek/src/hook.rs +++ b/crates/prek/src/hook.rs @@ -958,6 +958,7 @@ mod tests { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 4be41e369..36e37f1c5 100644 --- a/crates/prek/src/snapshots/prek__config__tests__language_version.snap +++ b/crates/prek/src/snapshots/prek__config__tests__language_version.snap @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 1959 expression: result --- Ok( @@ -120,6 +121,7 @@ Ok( files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 2d95ddc10..2e58d516c 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 1933 expression: result --- Config { @@ -131,6 +132,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 871b6c394..264d0442d 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 2365 expression: config --- Config { @@ -52,6 +53,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 3ecc35b8a..c04f2d019 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 1838 expression: result --- Config { @@ -51,6 +52,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 a0c217f5f..548fcf453 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 1684 expression: result --- Config { @@ -57,6 +58,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 e9751c80c..60628a546 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 1695 expression: result --- Config { @@ -52,6 +53,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 e9751c80c..03b91ee97 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 1725 expression: result --- Config { @@ -52,6 +53,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 95d10d8e7..6ccd3ddf6 100644 --- a/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap +++ b/crates/prek/src/snapshots/prek__config__tests__parse_repos.snap @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 1641 expression: result --- Config { @@ -51,6 +52,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 b342f402a..6dc2e9b45 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 2293 expression: config --- Config { @@ -92,6 +93,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 b535d713c..7393de600 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 2323 expression: config --- Config { @@ -57,6 +58,7 @@ Config { files: None, exclude: None, fail_fast: None, + require_frozen_revs: 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 d3b0b8a38..08daab031 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 2001 expression: config --- Config { @@ -129,6 +130,7 @@ Config { fail_fast: Some( true, ), + require_frozen_revs: 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 94e696973..c308ac533 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 @@ -1,5 +1,6 @@ --- source: crates/prek/src/config.rs +assertion_line: 1965 expression: config --- Config { @@ -318,6 +319,7 @@ Config { fail_fast: Some( true, ), + require_frozen_revs: None, minimum_prek_version: None, orphan: None, _unused_keys: {}, diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index f956d3e69..aef85e93a 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -115,6 +115,64 @@ fn run_does_not_rewrite_unchanged_config_tracking_file() -> Result<()> { Ok(()) } +#[test] +fn run_require_frozen_revs() { + let context = TestContext::new(); + context.init_project(); + + context.write_pre_commit_config(indoc::indoc! {r" + require_frozen_revs: true + repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + "}); + + cmd_snapshot!(context.filters(), context.run(), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: Config contains non-frozen remote hook revisions + in `[TEMP_DIR]/.pre-commit-config.yaml`: + - https://github.com/pre-commit/pre-commit-hooks uses rev `v5.0.0` + hint: run `prek auto-update --freeze` to replace tags with commit SHAs + "); +} + +#[test] +fn run_require_frozen_revs_allows_local_repos() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + context.write_pre_commit_config(indoc::indoc! {r" + require_frozen_revs: true + repos: + - repo: local + hooks: + - id: local-hook + name: local-hook + entry: echo ok + language: system + "}); + cwd.child("file.txt").write_str("Hello, world!\n")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--all-files"), @r" + success: true + exit_code: 0 + ----- stdout ----- + local-hook...............................................................Passed + + ----- stderr ----- + "); + + Ok(()) +} + #[test] fn run_glob_patterns_with_multiple_hooks() -> Result<()> { let context = TestContext::new(); diff --git a/crates/prek/tests/validate.rs b/crates/prek/tests/validate.rs index 91e6246cd..822e04662 100644 --- a/crates/prek/tests/validate.rs +++ b/crates/prek/tests/validate.rs @@ -65,6 +65,68 @@ fn validate_config() -> anyhow::Result<()> { Ok(()) } +#[test] +fn validate_config_require_frozen_revs() { + let context = TestContext::new(); + context.write_pre_commit_config(indoc::indoc! {r" + require_frozen_revs: true + repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 0123456789abcdef0123456789abcdef01234567 + hooks: + - id: ruff + - repo: local + hooks: + - id: local-hook + name: local-hook + entry: echo ok + language: system + "}); + + cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @r" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + error: Config `.pre-commit-config.yaml` contains non-frozen remote hook revisions + - https://github.com/pre-commit/pre-commit-hooks uses rev `v5.0.0` + hint: run `prek auto-update --freeze` to replace tags with commit SHAs + "); +} + +#[test] +fn validate_config_require_frozen_revs_allows_frozen_and_local_repos() { + let context = TestContext::new(); + context.write_pre_commit_config(indoc::indoc! {r" + require_frozen_revs: true + repos: + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: 0123456789abcdef0123456789abcdef01234567 + hooks: + - id: ruff + - repo: local + hooks: + - id: local-hook + name: local-hook + entry: echo ok + language: system + "}); + + cmd_snapshot!(context.filters(), context.validate_config().arg(PRE_COMMIT_CONFIG_YAML), @r" + success: true + exit_code: 0 + ----- stdout ----- + + ----- stderr ----- + success: All configs are valid + "); +} + #[test] fn invalid_config_error() { let context = TestContext::new(); diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md index ec8d6c7c2..bfdde08d3 100644 --- a/docs/reference/configuration.md +++ b/docs/reference/configuration.md @@ -52,6 +52,29 @@ Each entry is one of: See [Repo entries](#repo-entries). +### `require_frozen_revs` + +Require every remote repository [`rev`](#rev) in this config file to be pinned to a commit SHA. + +- Type: boolean +- Default: `false` + +When enabled, [`prek run`](cli.md#prek-run) and [`prek validate-config`](cli.md#prek-validate-config) fail before hook initialization if any remote hook repository uses a tag or branch name. Run [`prek auto-update --freeze`](cli.md#prek-auto-update--freeze) to replace tag names with commit SHAs. + +In workspace mode, this setting is scoped to the config file that defines it. It applies only to that project and is not inherited by nested projects. + +=== "prek.toml" + + ```toml + require_frozen_revs = true + ``` + +=== ".pre-commit-config.yaml" + + ```yaml + require_frozen_revs: true + ``` + ### `files` diff --git a/prek.schema.json b/prek.schema.json index be4a62c3d..913fc5ae9 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" }, + "require_frozen_revs": { + "description": "Set to true to require remote repository revisions to be pinned to commit SHAs.\nDefault is false.", + "type": "boolean" + }, "minimum_prek_version": { "description": "The minimum version of prek required to run this configuration.", "type": "string"