diff --git a/crates/prek-consts/src/env_vars.rs b/crates/prek-consts/src/env_vars.rs index 8903bf32f..fbe1720f6 100644 --- a/crates/prek-consts/src/env_vars.rs +++ b/crates/prek-consts/src/env_vars.rs @@ -31,6 +31,7 @@ impl EnvVars { pub const SSL_CERT_DIR: &'static str = "SSL_CERT_DIR"; pub const PREK_CONTAINER_RUNTIME: &'static str = "PREK_CONTAINER_RUNTIME"; pub const PREK_QUIET: &'static str = "PREK_QUIET"; + pub const PREK_REPORT_LEVEL: &'static str = "PREK_REPORT_LEVEL"; pub const PREK_LOG_TRUNCATE_LIMIT: &'static str = "PREK_LOG_TRUNCATE_LIMIT"; // PREK internal environment variables diff --git a/crates/prek/src/cli/hook_impl.rs b/crates/prek/src/cli/hook_impl.rs index c27bb3f27..82d394d52 100644 --- a/crates/prek/src/cli/hook_impl.rs +++ b/crates/prek/src/cli/hook_impl.rs @@ -12,7 +12,7 @@ use tokio::io::{AsyncReadExt, AsyncWriteExt}; use prek_consts::env_vars::EnvVars; -use crate::cli::{self, ExitStatus, RunArgs, flag}; +use crate::cli::{self, ExitStatus, ReportLevel, RunArgs, flag}; use crate::config::HookType; use crate::fs::CWD; use crate::git::GIT_ROOT; @@ -135,6 +135,7 @@ pub(crate) async fn hook_impl( false, false, run_args.extra, + ReportLevel::default(), false, printer, ) diff --git a/crates/prek/src/cli/mod.rs b/crates/prek/src/cli/mod.rs index 97984a8c3..5ac68e736 100644 --- a/crates/prek/src/cli/mod.rs +++ b/crates/prek/src/cli/mod.rs @@ -537,10 +537,43 @@ pub(crate) struct RunArgs { #[arg(long)] pub(crate) dry_run: bool, + /// Control which hook statuses are shown in output. + /// + /// Levels from least to most verbose: silent, fail, skipped-no-files, + /// skipped, passed, all. Each level includes all statuses from lower levels. + #[arg( + long, + value_enum, + env = EnvVars::PREK_REPORT_LEVEL, + default_value_t = ReportLevel::Passed, + )] + pub(crate) report_level: ReportLevel, + #[command(flatten)] pub(crate) extra: RunExtraArgs, } +/// Controls which hook status lines are displayed during a run. +/// +/// Levels are ordered from least to most verbose. Each level includes +/// all statuses from lower levels plus its own. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] +pub(crate) enum ReportLevel { + /// Show no per-hook status lines. + Silent, + /// Show only failed hooks. + Fail, + /// Show failed hooks and hooks skipped because no files matched or language is unimplemented. + SkippedNoFiles, + /// Show failed, no-files, and hooks excluded by `--skip`/`SKIP`/`PREK_SKIP`. + Skipped, + /// Show failed, skipped, and passed hooks (including dry-run). + #[default] + Passed, + /// Show every hook status. + All, +} + #[derive(Debug, Clone, Default, Args)] pub(crate) struct TryRepoArgs { /// Repository to source hooks from. diff --git a/crates/prek/src/cli/run/mod.rs b/crates/prek/src/cli/run/mod.rs index 0fe753380..f29659b71 100644 --- a/crates/prek/src/cli/run/mod.rs +++ b/crates/prek/src/cli/run/mod.rs @@ -1,6 +1,6 @@ pub(crate) use filter::{CollectOptions, FileFilter, collect_files}; pub(crate) use run::{install_hooks, run}; -pub(crate) use selector::{SelectorSource, Selectors}; +pub(crate) use selector::{HookPartition, SelectorSource, Selectors}; mod filter; mod keeper; diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index 5f0e40bed..f6f9e96c1 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -19,8 +19,8 @@ use unicode_width::UnicodeWidthStr; use crate::cli::reporter::{HookInitReporter, HookInstallReporter, HookRunReporter}; use crate::cli::run::keeper::WorkTreeKeeper; -use crate::cli::run::{CollectOptions, FileFilter, Selectors, collect_files}; -use crate::cli::{ExitStatus, RunExtraArgs}; +use crate::cli::run::{CollectOptions, FileFilter, HookPartition, Selectors, collect_files}; +use crate::cli::{ExitStatus, ReportLevel, RunExtraArgs}; use crate::config::{Language, PassFilenames, Stage}; use crate::fs::CWD; use crate::git::GIT_ROOT; @@ -49,6 +49,7 @@ pub(crate) async fn run( dry_run: bool, refresh: bool, extra_args: RunExtraArgs, + report_level: ReportLevel, verbose: bool, printer: Printer, ) -> Result { @@ -93,11 +94,16 @@ pub(crate) async fn run( .init_hooks(store, Some(&reporter)) .await .context("Failed to init hooks")?; - let selected_hooks: Vec<_> = hooks - .into_iter() - .filter(|h| selectors.matches_hook(h)) - .map(Arc::new) - .collect(); + + let mut selected_hooks = Vec::new(); + let mut skipped_by_selector: Vec> = Vec::new(); + for hook in hooks { + match selectors.hook_partition(&hook) { + HookPartition::Selected => selected_hooks.push(Arc::new(hook)), + HookPartition::SkippedBySkipSelector => skipped_by_selector.push(Arc::new(hook)), + HookPartition::NotSelected => {} + } + } selectors.report_unused(); @@ -147,6 +153,11 @@ pub(crate) async fn run( (hooks, hook_stage) }; + let skipped_by_selector: Vec<_> = skipped_by_selector + .into_iter() + .filter(|h| h.stages.contains(hook_stage)) + .collect(); + if filtered_hooks.is_empty() { debug!( stage = %hook_stage, @@ -203,11 +214,13 @@ pub(crate) async fn run( run_hooks( &workspace, &installed_hooks, + &skipped_by_selector, filenames, store, show_diff_on_failure, fail_fast, dry_run, + report_level, verbose, printer, ) @@ -498,6 +511,7 @@ impl StatusPrinter { const DRY_RUN: &'static str = "Dry Run"; const NO_FILES: &'static str = "(no files to check)"; const UNIMPLEMENTED: &'static str = "(unimplemented yet)"; + const EXCLUDED: &'static str = "(excluded by skip)"; fn for_hooks(hooks: &[InstalledHook], printer: Printer) -> Self { let name_len = hooks @@ -513,6 +527,24 @@ impl StatusPrinter { Self { printer, columns } } + fn for_hooks_with_skipped( + hooks: &[InstalledHook], + skipped: &[Arc], + printer: Printer, + ) -> Self { + let name_len = hooks + .iter() + .map(|hook| hook.name.width()) + .chain(skipped.iter().map(|hook| hook.name.width())) + .max() + .unwrap_or(0); + let columns = std::cmp::max( + 79, + name_len + 3 + Self::NO_FILES.len() + Self::SKIPPED.len(), + ); + Self { printer, columns } + } + fn printer(&self) -> Printer { self.printer } @@ -538,6 +570,11 @@ impl StatusPrinter { Self::SKIPPED.black().on_yellow().to_string(), Self::SKIPPED.width(), ), + RunStatus::Skipped => ( + Self::EXCLUDED, + Self::SKIPPED.black().on_yellow().to_string(), + Self::SKIPPED.width(), + ), RunStatus::DryRun => ( "", Self::DRY_RUN.on_yellow().to_string(), @@ -575,17 +612,19 @@ impl StatusPrinter { async fn run_hooks( workspace: &Workspace, hooks: &[InstalledHook], + skipped_by_selector: &[Arc], filenames: Vec, store: &Store, show_diff_on_failure: bool, fail_fast: Option, dry_run: bool, + report_level: ReportLevel, verbose: bool, printer: Printer, ) -> Result { debug_assert!(!hooks.is_empty(), "No hooks to run"); - let status_printer = StatusPrinter::for_hooks(hooks, printer); + let status_printer = StatusPrinter::for_hooks_with_skipped(hooks, skipped_by_selector, printer); let reporter = HookRunReporter::new(printer, status_printer.bar_len()); let mut success = true; @@ -623,7 +662,7 @@ async fn run_hooks( // If two hooks have the same priority, preserve their original order from the config. hooks.sort_by(|a, b| a.priority.cmp(&b.priority).then(a.idx.cmp(&b.idx))); - if projects_len > 1 || !project.is_root() { + if (projects_len > 1 || !project.is_root()) && report_level != ReportLevel::Silent { reporter.suspend(|| { writeln!( status_printer.printer().stdout(), @@ -668,6 +707,7 @@ async fn run_hooks( &group_results, verbose, group_modified_files, + report_level, ) })?; @@ -682,6 +722,19 @@ async fn run_hooks( break 'outer; } } + + // Render selector-skipped hooks for this project, after executed hooks. + if report_level.should_show(RunStatus::Skipped) { + let mut project_skipped: Vec<_> = skipped_by_selector + .iter() + .filter(|h| h.project() == project) + .collect(); + project_skipped.sort_by_key(|h| h.idx); + + for hook in project_skipped { + reporter.suspend(|| status_printer.write(&hook.name, "", RunStatus::Skipped))?; + } + } } reporter.on_complete(); @@ -800,9 +853,14 @@ fn render_priority_group( group_results: &[RunResult], verbose: bool, group_modified_files: bool, + report_level: ReportLevel, ) -> Result<()> { // Only show a special group UI when the group failed due to file modifications. // Hooks in a priority group run in parallel, so we can't attribute modifications to a single hook. + // + // When `--report-level` hides successful hooks, we still show this header so a run that fails + // only because hooks modified files is diagnosable (see design doc silent-level notes on + // exit codes and diffs — this is the multi-hook analogue). let show_group_ui = group_modified_files && group_results.len() > 1; let single_hook_modified_files = group_results.len() == 1 && group_modified_files; let group_prefix = if show_group_ui { @@ -839,12 +897,31 @@ fn render_priority_group( result.status }; - status_printer.write(&result.hook.name, prefix, status)?; + let show_status_on_console = report_level.should_show(status); + if show_status_on_console { + status_printer.write(&result.hook.name, prefix, status)?; + } if matches!(status, RunStatus::NoFiles | RunStatus::Unimplemented) { continue; } + let output = result.output.trim_ascii(); + if !output.is_empty() { + if let Some(file) = result.hook.log_file.as_deref() { + let mut file = fs_err::OpenOptions::new() + .create(true) + .append(true) + .open(file)?; + file.write_all(output)?; + file.flush()?; + } + } + + if !show_status_on_console { + continue; + } + let mut stdout = match status { RunStatus::Failed => printer.stdout_important(), _ => printer.stdout(), @@ -878,36 +955,24 @@ fn render_priority_group( )?; } - let output = result.output.trim_ascii(); - if !output.is_empty() { - if let Some(file) = result.hook.log_file.as_deref() { - let mut file = fs_err::OpenOptions::new() - .create(true) - .append(true) - .open(file)?; - file.write_all(output)?; - file.flush()?; + if !output.is_empty() && result.hook.log_file.is_none() { + if show_group_ui { + writeln!(stdout, "{}", " │".dimmed())?; } else { - if show_group_ui { - writeln!(stdout, "{}", " │".dimmed())?; - } else { - writeln!(stdout)?; - } - let text = String::from_utf8_lossy(output); - for line in text.lines() { - if line.is_empty() { - if show_group_ui { - writeln!(stdout, "{}", " │".dimmed())?; - } else { - writeln!(stdout)?; - } + writeln!(stdout)?; + } + let text = String::from_utf8_lossy(output); + for line in text.lines() { + if line.is_empty() { + if show_group_ui { + writeln!(stdout, "{}", " │".dimmed())?; } else { - if show_group_ui { - writeln!(stdout, "{group_prefix}{line}")?; - } else { - writeln!(stdout, " {line}")?; - } + writeln!(stdout)?; } + } else if show_group_ui { + writeln!(stdout, "{group_prefix}{line}")?; + } else { + writeln!(stdout, " {line}")?; } } } @@ -958,13 +1023,14 @@ enum RunStatus { DryRun, NoFiles, Unimplemented, + Skipped, } impl RunStatus { fn as_bool(self) -> bool { matches!( self, - Self::Success | Self::NoFiles | Self::DryRun | Self::Unimplemented + Self::Success | Self::NoFiles | Self::DryRun | Self::Unimplemented | Self::Skipped ) } @@ -973,7 +1039,21 @@ impl RunStatus { } fn is_skipped(self) -> bool { - matches!(self, Self::DryRun | Self::NoFiles | Self::Unimplemented) + matches!( + self, + Self::DryRun | Self::NoFiles | Self::Unimplemented | Self::Skipped + ) + } +} + +impl ReportLevel { + fn should_show(self, status: RunStatus) -> bool { + match status { + RunStatus::Failed => self >= ReportLevel::Fail, + RunStatus::NoFiles | RunStatus::Unimplemented => self >= ReportLevel::SkippedNoFiles, + RunStatus::Skipped => self >= ReportLevel::Skipped, + RunStatus::Success | RunStatus::DryRun => self >= ReportLevel::Passed, + } } } diff --git a/crates/prek/src/cli/run/selector.rs b/crates/prek/src/cli/run/selector.rs index a383787db..15c876f3a 100644 --- a/crates/prek/src/cli/run/selector.rs +++ b/crates/prek/src/cli/run/selector.rs @@ -131,6 +131,18 @@ impl Selector { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum HookPartition { + /// Hook runs (not excluded by skip selectors, matches include rules). + Selected, + /// Hook matches include rules but is excluded by `--skip` / `SKIP` / `PREK_SKIP`. + /// + /// These hooks are reported as selector-skipped at sufficiently high report levels. + SkippedBySkipSelector, + /// Hook is not in the run set (for example, not matched by positional hook selectors). + NotSelected, +} + #[derive(Debug, Clone, Default)] pub(crate) struct Selectors { includes: Vec, @@ -212,24 +224,38 @@ impl Selectors { }) } - /// Check if a hook matches any of the selection criteria. - pub(crate) fn matches_hook(&self, hook: &Hook) -> bool { + fn passes_include_without_marking(includes: &[Selector], hook: &Hook) -> bool { + if includes.is_empty() { + true + } else { + includes.iter().any(|include| include.matches_hook(hook)) + } + } + + /// Classify a hook for execution and selector-skipped reporting. + /// + /// [`Self::matches_hook`] is equivalent to `matches!(partition, HookPartition::Selected)`. + pub(crate) fn hook_partition(&self, hook: &Hook) -> HookPartition { let mut usage = self.usage.lock().unwrap(); - // Always check every selector to track usage - let mut skipped = false; + let mut excluded_by_skip = false; for (idx, skip) in self.skips.iter().enumerate() { if skip.matches_hook(hook) { usage.use_skip(idx); - skipped = true; + excluded_by_skip = true; } } - if skipped { - return false; + + if excluded_by_skip { + return if Self::passes_include_without_marking(&self.includes, hook) { + HookPartition::SkippedBySkipSelector + } else { + HookPartition::NotSelected + }; } if self.includes.is_empty() { - return true; // No `includes` mean all hooks are included + return HookPartition::Selected; } let mut included = false; @@ -239,7 +265,17 @@ impl Selectors { included = true; } } - included + + if included { + HookPartition::Selected + } else { + HookPartition::NotSelected + } + } + + /// Check if a hook matches any of the selection criteria. + pub(crate) fn matches_hook(&self, hook: &Hook) -> bool { + matches!(self.hook_partition(hook), HookPartition::Selected) } pub(crate) fn matches_hook_id(&self, hook_id: &str) -> bool { diff --git a/crates/prek/src/cli/try_repo.rs b/crates/prek/src/cli/try_repo.rs index 395d6620b..e3863555b 100644 --- a/crates/prek/src/cli/try_repo.rs +++ b/crates/prek/src/cli/try_repo.rs @@ -223,6 +223,7 @@ pub(crate) async fn try_repo( run_args.dry_run, refresh, run_args.extra, + run_args.report_level, verbose, printer, ) diff --git a/crates/prek/src/main.rs b/crates/prek/src/main.rs index c763ffb0a..ef4d73952 100644 --- a/crates/prek/src/main.rs +++ b/crates/prek/src/main.rs @@ -293,6 +293,7 @@ async fn run(cli: Cli) -> Result { args.dry_run, cli.globals.refresh, args.extra, + args.report_level, cli.globals.verbose > 0, printer, ) diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index bb229a405..3bd3ebc45 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -61,14 +61,14 @@ fn run_basic() -> Result<()> { context.git_add("."); - cmd_snapshot!(context.filters(), context.run().arg("trailing-whitespace"), @r#" + cmd_snapshot!(context.filters(), context.run().arg("trailing-whitespace"), @" success: true exit_code: 0 ----- stdout ----- trim trailing whitespace.................................................Passed ----- stderr ----- - "#); + "); Ok(()) } @@ -375,17 +375,17 @@ fn multiple_hook_ids() { context.git_add("."); // Multiple repeated hook-id (should deduplicate) - cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("hook1").arg("hook1"), @r#" + cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("hook1").arg("hook1"), @" success: true exit_code: 0 ----- stdout ----- First Hook...............................................................Passed ----- stderr ----- - "#); + "); // Hook-id that matches multiple hooks (by alias) - cmd_snapshot!(context.filters(), context.run().arg("shared-name"), @r#" + cmd_snapshot!(context.filters(), context.run().arg("shared-name"), @" success: true exit_code: 0 ----- stdout ----- @@ -393,7 +393,7 @@ fn multiple_hook_ids() { Shared Hook B............................................................Passed ----- stderr ----- - "#); + "); // Hook-id matches nothing cmd_snapshot!(context.filters(), context.run().arg("nonexistent-hook"), @r" @@ -420,17 +420,17 @@ fn multiple_hook_ids() { "); // Hook-id matches one hook - cmd_snapshot!(context.filters(), context.run().arg("hook2"), @r#" + cmd_snapshot!(context.filters(), context.run().arg("hook2"), @" success: true exit_code: 0 ----- stdout ----- Second Hook..............................................................Passed ----- stderr ----- - "#); + "); // Multiple hook-ids with mixed results (some exist, some don't) - cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("nonexistent").arg("hook2"), @r" + cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("nonexistent").arg("hook2"), @" success: true exit_code: 0 ----- stdout ----- @@ -442,7 +442,7 @@ fn multiple_hook_ids() { "); // Multiple valid hook-ids - cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("hook2").arg("nonexistent-hook"), @r" + cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("hook2").arg("nonexistent-hook"), @" success: true exit_code: 0 ----- stdout ----- @@ -454,7 +454,7 @@ fn multiple_hook_ids() { "); // Multiple hook-ids with some duplicates and aliases - cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("shared-name").arg("hook1"), @r#" + cmd_snapshot!(context.filters(), context.run().arg("hook1").arg("shared-name").arg("hook1"), @" success: true exit_code: 0 ----- stdout ----- @@ -463,7 +463,7 @@ fn multiple_hook_ids() { Shared Hook B............................................................Passed ----- stderr ----- - "#); + "); } #[test] @@ -701,7 +701,7 @@ fn cjk_hook_name() { - repo: local hooks: - id: trailing-whitespace - name: 去除行尾空格 + name: ?????? language: system entry: python3 -V - id: end-of-file-fixer @@ -716,7 +716,7 @@ fn cjk_hook_name() { success: true exit_code: 0 ----- stdout ----- - 去除行尾空格.............................................................Passed + ??????...................................................................Passed fix end of files.........................................................Passed ----- stderr ----- @@ -748,7 +748,7 @@ fn skips() { "#}); context.git_add("."); - cmd_snapshot!(context.filters(), context.run().env("SKIP", "end-of-file-fixer"), @r" + cmd_snapshot!(context.filters(), context.run().env("SKIP", "end-of-file-fixer"), @" success: false exit_code: 1 ----- stdout ----- @@ -758,17 +758,20 @@ fn skips() { check json...............................................................Failed - hook id: check-json - exit code: 1 + fix end of files......................................(excluded by skip)Skipped ----- stderr ----- "); - cmd_snapshot!(context.filters(), context.run().env("SKIP", "trailing-whitespace,end-of-file-fixer"), @r" + cmd_snapshot!(context.filters(), context.run().env("SKIP", "trailing-whitespace,end-of-file-fixer"), @" success: false exit_code: 1 ----- stdout ----- check json...............................................................Failed - hook id: check-json - exit code: 1 + trailing-whitespace...................................(excluded by skip)Skipped + fix end of files......................................(excluded by skip)Skipped ----- stderr ----- "); @@ -906,7 +909,7 @@ fn fallback_to_manual_stage() { "); // Mixing `pre-push` and manual selectors still runs the manual hook via fallback. - cmd_snapshot!(context.filters(), context.run().arg("pre-push").arg("manual-only"), @r" + cmd_snapshot!(context.filters(), context.run().arg("pre-push").arg("manual-only"), @" success: true exit_code: 0 ----- stdout ----- @@ -1334,6 +1337,36 @@ fn log_file() { assert_eq!(log, "Fixing files"); } +/// `log_file` is written even when per-hook console output is suppressed. +#[test] +fn log_file_report_level_silent() { + 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("Fixing files"); exit(1)' + always_run: true + log_file: log.txt + "#}); + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--report-level").arg("silent"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "#); + + assert_eq!(context.read("log.txt"), "Fixing files"); +} + /// Pass pre-commit environment variables to the hook. #[test] fn pass_env_vars() { @@ -2399,6 +2432,7 @@ fn selectors_completion() -> Result<()> { --show-diff-on-failure When hooks fail, run `git diff` directly afterward --fail-fast Stop running hooks after the first failure --dry-run Do not run the hooks, but print the hooks that would have been run + --report-level Control which hook statuses are shown in output --config Path to alternate config file --cd Change to directory before running --color Whether to use color in output diff --git a/crates/prek/tests/skipped_hooks.rs b/crates/prek/tests/skipped_hooks.rs index 4cd7734ba..456628ef8 100644 --- a/crates/prek/tests/skipped_hooks.rs +++ b/crates/prek/tests/skipped_hooks.rs @@ -218,3 +218,323 @@ fn all_hooks_skipped_multiple_priority_groups() -> Result<()> { Ok(()) } + +/// `--report-level fail` hides passed and no-files hooks, shows only failures. +#[test] +fn report_level_fail_shows_only_failures() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: fail-hook + name: fail-hook + language: system + entry: python3 -c "import sys; sys.exit(1)" + files: \.txt$ + - id: no-files-hook + name: no-files-hook + language: system + entry: echo "checking" + files: \.py$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--report-level").arg("fail"), @" + success: false + exit_code: 1 + ----- stdout ----- + fail-hook................................................................Failed + - hook id: fail-hook + - exit code: 1 + + ----- stderr ----- + "); + + Ok(()) +} + +/// `--report-level silent` shows no per-hook status lines but still fails. +#[test] +fn report_level_silent_no_output_but_exit_code() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: fail-hook + name: fail-hook + language: system + entry: python3 -c "import sys; sys.exit(1)" + files: \.txt$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--report-level").arg("silent"), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "); + + Ok(()) +} + +/// `--report-level skipped-no-files` shows failed and no-files hooks, hides passed. +#[test] +fn report_level_skipped_no_files() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: no-files-hook + name: no-files-hook + language: system + entry: echo "checking" + files: \.py$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--report-level").arg("skipped-no-files"), @" + success: true + exit_code: 0 + ----- stdout ----- + no-files-hook........................................(no files to check)Skipped + + ----- stderr ----- + "); + + Ok(()) +} + +/// `--report-level skipped` shows hooks excluded by --skip. +#[test] +fn report_level_skipped_shows_excluded_hooks() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: skipped-hook + name: skipped-hook + language: system + entry: echo "should be skipped" + files: \.txt$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run() + .arg("--skip").arg("skipped-hook") + .arg("--report-level").arg("skipped"), @" + success: true + exit_code: 0 + ----- stdout ----- + skipped-hook..........................................(excluded by skip)Skipped + + ----- stderr ----- + "); + + Ok(()) +} + +/// `PREK_REPORT_LEVEL` env var works as fallback for `--report-level`. +#[test] +fn report_level_env_var() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: fail-hook + name: fail-hook + language: system + entry: python3 -c "import sys; sys.exit(1)" + files: \.txt$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().env("PREK_REPORT_LEVEL", "fail"), @" + success: false + exit_code: 1 + ----- stdout ----- + fail-hook................................................................Failed + - hook id: fail-hook + - exit code: 1 + + ----- stderr ----- + "); + + Ok(()) +} + +/// Positional hook selectors must not label other hooks as excluded by skip. +#[test] +fn positional_include_does_not_emit_skip_excluded_lines() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r" + repos: + - repo: local + hooks: + - id: hook-a + name: hook-a + language: system + entry: echo a + files: \.txt$ + - id: hook-b + name: hook-b + language: system + entry: echo b + files: \.txt$ + "}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("hook-a").arg("--report-level").arg("all"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + hook-a...................................................................Passed + + ----- stderr ----- + "#); + + Ok(()) +} + +/// Multi-hook parallel formatters: `--report-level fail` still surfaces file-modification failure. +#[test] +fn report_level_fail_shows_group_modified_header() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: fmt-a + name: fmt-a + language: system + entry: python3 -c "open('f.txt','a').write('a')" + files: \.txt$ + priority: 0 + - id: fmt-b + name: fmt-b + language: system + entry: python3 -c "open('f.txt','a').write('b')" + files: \.txt$ + priority: 0 + "#}); + + cwd.child("f.txt").write_str("x")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--report-level").arg("fail"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + Files were modified by following hooks...................................Failed + + ----- stderr ----- + "#); + + Ok(()) +} + +/// Default behavior (no --report-level flag) matches current behavior. +#[test] +fn report_level_default_matches_current_behavior() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: no-files-hook + name: no-files-hook + language: system + entry: echo "checking" + files: \.py$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @" + success: true + exit_code: 0 + ----- stdout ----- + pass-hook................................................................Passed + no-files-hook........................................(no files to check)Skipped + + ----- stderr ----- + "); + + Ok(()) +} diff --git a/docs/cli.md b/docs/cli.md index 7cb4473e3..eddb26ca5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -233,7 +233,17 @@ prek run [OPTIONS] [HOOK|PROJECT]...
--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

-
--show-diff-on-failure

When hooks fail, run git diff directly afterward

+
--report-level report-level

Control which hook statuses are shown in output.

+

Levels from least to most verbose: silent, fail, skipped-no-files, skipped, passed, all. Each level includes all statuses from lower levels.

+

May also be set with the PREK_REPORT_LEVEL environment variable.

[default: passed]

Possible values:

+
    +
  • silent: Show no per-hook status lines
  • +
  • fail: Show only failed hooks
  • +
  • skipped-no-files: Show failed hooks and hooks skipped because no files matched or language is unimplemented
  • +
  • skipped: Show failed, no-files, and hooks excluded by --skip/SKIP/PREK_SKIP
  • +
  • passed: Show failed, skipped, and passed hooks (including dry-run)
  • +
  • all: Show every hook status
  • +
--show-diff-on-failure

When hooks fail, run git diff directly afterward

--skip hook|project

Skip the specified hooks or projects.

Supports flexible selector syntax:

    @@ -771,7 +781,17 @@ prek try-repo [OPTIONS] [HOOK|PROJECT]...
--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

-
--rev, --ref rev

Manually select a rev to run against, otherwise the HEAD revision will be used

+
--report-level report-level

Control which hook statuses are shown in output.

+

Levels from least to most verbose: silent, fail, skipped-no-files, skipped, passed, all. Each level includes all statuses from lower levels.

+

May also be set with the PREK_REPORT_LEVEL environment variable.

[default: passed]

Possible values:

+
    +
  • silent: Show no per-hook status lines
  • +
  • fail: Show only failed hooks
  • +
  • skipped-no-files: Show failed hooks and hooks skipped because no files matched or language is unimplemented
  • +
  • skipped: Show failed, no-files, and hooks excluded by --skip/SKIP/PREK_SKIP
  • +
  • passed: Show failed, skipped, and passed hooks (including dry-run)
  • +
  • all: Show every hook status
  • +
--rev, --ref rev

Manually select a rev to run against, otherwise the HEAD revision will be used

--show-diff-on-failure

When hooks fail, run git diff directly afterward

--skip hook|project

Skip the specified hooks or projects.

Supports flexible selector syntax:

diff --git a/docs/proposals/2026-04-10-report-level-design.md b/docs/proposals/2026-04-10-report-level-design.md new file mode 100644 index 000000000..e3591ab79 --- /dev/null +++ b/docs/proposals/2026-04-10-report-level-design.md @@ -0,0 +1,129 @@ +# Design: `--report-level` flag for `prek run` + +**Issue:** [#1777 — Proposal: Hook status level report](https://github.com/j178/prek/issues/1777) + +**Date:** 2026-04-10 + +## Summary + +Add a `--report-level ` flag to `prek run` that controls which hook statuses are displayed in output. This unifies three related requests (#1240, #1468, #1537) into a single output-filtering model. Hook execution, exit codes, and failure semantics are unchanged — this is purely a display filter. + +## Report levels + +Six levels, ordered from least to most verbose: + +| Level | Shows | +| -- | -- | +| `silent` | No per-hook status lines | +| `fail` | Failed hooks only | +| `skipped-no-files` | Failed + hooks skipped because no files matched + unimplemented language hooks | +| `skipped` | All of the above + hooks excluded by `--skip`/`SKIP`/`PREK_SKIP` | +| `passed` | All of the above + passed hooks + dry-run hooks | +| `all` | Every hook status, including any future report-only states | + +Each level is a threshold — a status is displayed if the report level is high enough to include it. + +### Status-to-level mapping + +| `RunStatus` | Minimum level to display | +| -- | -- | +| `Failed` | `fail` | +| `NoFiles` | `skipped-no-files` | +| `Unimplemented` | `skipped-no-files` | +| `Skipped` (new variant) | `skipped` | +| `Success` | `passed` | +| `DryRun` | `passed` | + +**Default level:** `passed`, as recommended in the issue. This matches current behavior since all currently-displayed statuses (`Failed`, `NoFiles`, `Unimplemented`, `Success`, `DryRun`) are at or below `passed`. The only new visibility comes when a user explicitly sets `skipped` or higher — then selector-excluded hooks appear. + +## Architecture: Two-pass approach + +The implementation separates "what to run" from "what to show." Execution logic is untouched; the report level only affects rendering. + +### Pass 1: Execution (unchanged, plus tracking) + +The existing selector filtering at the top of `run()` is changed from a filter to a partition: + +``` +hooks.partition(|h| selectors.matches_hook(h)) -> (selected_hooks, skipped_hooks) +``` + +- `selected_hooks` enter the existing execution pipeline exactly as today +- `skipped_hooks` are never executed, never affect exit codes, never affect `fail_fast` +- `skipped_hooks` are passed into `run_hooks()` purely for display + +### Pass 2: Rendering (filtered by report level) + +`report_level` is threaded from CLI args through `run()` -> `run_hooks()` -> `render_priority_group()`. + +A new method `ReportLevel::should_show(status: RunStatus) -> bool` gates each `status_printer.write()` call. If a status line is hidden, its associated verbose output (hook id, duration, exit code, output) is also hidden. + +#### `silent` level specifics + +When report level is `silent`: + +- No per-hook status lines are printed +- Project headers (`Running hooks for X:`) are also suppressed +- Exit codes, the "files were modified" diff, and the "unimplemented languages" warning still appear + +#### Group UI handling + +The `group_modified_files` box UI (`┌│└` decorations) only renders if at least one hook in the group is visible at the current report level. + +### Selector-skipped hook display + +Selector-skipped hooks are rendered **after** all executed hooks within their project section, not interleaved by priority/index. This is a deliberate simplification — perfect interleaving would add complexity for minimal user benefit since skipped hooks have no output or timing. The project grouping provides enough context. + +Display format for selector-skipped hooks: + +``` +my-hook..........................................(excluded by skip)Skipped +``` + +The suffix `(excluded by skip)` distinguishes from `(no files to check)` skips. The status badge uses yellow background (matching `Unimplemented` style) to distinguish from cyan `NoFiles` skips. + +## CLI definition + +**Flag:** `--report-level ` + +Defined in `RunArgs` struct with: + +- `value_enum` for clap parsing of the six level names +- `env = EnvVars::PREK_REPORT_LEVEL` for environment variable fallback +- `default_value_t = ReportLevel::Passed` + +**Environment variable:** `PREK_REPORT_LEVEL` — added to `EnvVars` in `prek-consts`. + +### Interaction with `--quiet` + +The existing `-q`/`--quiet` flag controls the `Printer` level (`Quiet`, `Silent`) which suppresses all output broadly. `--report-level` is a finer-grained control that filters specifically which hook status lines appear. They compose independently: `-q` reduces all output at the printer level, while `--report-level` filters which hook statuses are emitted in the first place. No special interaction logic is needed — they operate at different layers. + +## Files to modify + +1. `**crates/prek-consts/src/env_vars.rs`\*\* — add `PREK_REPORT_LEVEL` constant +2. `**crates/prek/src/cli/mod.rs**` — add `report_level` field to `RunArgs`, define `ReportLevel` enum +3. `**crates/prek/src/cli/run/run.rs**` — main changes: + - Add `RunStatus::Skipped` variant + - Partition hooks into selected/skipped instead of filtering + - Thread `report_level` through `run_hooks()` and `render_priority_group()` + - Add `should_show()` gate before `status_printer.write()` calls + - Render selector-skipped hooks after executed hooks per project + - Suppress project headers and group UI when appropriate for the level +4. `**crates/prek/src/main.rs**` — pass `report_level` from args to `cli::run()` + +## Testing + +Tests are added to the existing `crates/prek/tests/skipped_hooks.rs`. + +**Test cases:** + +1. `--report-level fail` — mix of passing/failing hooks, only failed hooks in output +2. `--report-level silent` — no per-hook lines, exit code still reflects failures +3. `--report-level skipped-no-files` — failed + no-files hooks appear, passed hooks hidden +4. `--report-level skipped` — use `--skip` to exclude a hook, verify it appears with `(excluded by skip)` suffix +5. `--report-level passed` (default) — same output as current behavior +6. `--report-level all` — everything shows including selector-skipped hooks +7. `PREK_REPORT_LEVEL` env var — verify env var works as fallback +8. Default behavior — omitting the flag produces `passed`-level output + +Existing test snapshots should not change since the default level matches current behavior. diff --git a/docs/proposals/2026-04-10-report-level-plan.md b/docs/proposals/2026-04-10-report-level-plan.md new file mode 100644 index 000000000..b24d3f139 --- /dev/null +++ b/docs/proposals/2026-04-10-report-level-plan.md @@ -0,0 +1,999 @@ +# `--report-level` Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a `--report-level ` flag to `prek run` that filters which hook status lines are displayed, without changing execution semantics. + +**Architecture:** Two-pass approach — execution is unchanged, report level gates rendering. Selector-skipped hooks are tracked separately and rendered after executed hooks per project. A `ReportLevel` enum with 6 ordered variants implements threshold-based filtering via a `should_show(RunStatus) -> bool` method. + +**Tech Stack:** Rust, clap (CLI parsing with `ValueEnum` derive), existing snapshot test infrastructure (`insta`, `assert_cmd`) + +**Spec:** `docs/proposals/2026-04-10-report-level-design.md` + +--- + +### Task 1: Add `PREK_REPORT_LEVEL` environment variable constant + +**Files:** + +- Modify: `crates/prek-consts/src/env_vars.rs:20-34` + +- [ ] **Step 1: Add the constant** + +In `crates/prek-consts/src/env_vars.rs`, add after the `PREK_QUIET` line (line 33): + +```rust + pub const PREK_REPORT_LEVEL: &'static str = "PREK_REPORT_LEVEL"; +``` + +- [ ] **Step 2: Verify it compiles** + +Run: `cargo check -p prek-consts` +Expected: compiles with no errors + +- [ ] **Step 3: Commit** + +```bash +git add crates/prek-consts/src/env_vars.rs +git commit -m "feat: add PREK_REPORT_LEVEL env var constant" +``` + +--- + +### Task 2: Define `ReportLevel` enum and add CLI flag + +**Files:** + +- Modify: `crates/prek/src/cli/mod.rs:436-542` + +- [ ] **Step 1: Define the `ReportLevel` enum** + +Add the enum definition after the existing `RunArgs` struct (after line 542) in `crates/prek/src/cli/mod.rs`. Follow the pattern used by `ColorChoice` (line 90) and `ListOutputFormat` (line 557): + +```rust +/// Controls which hook status lines are displayed during a run. +/// +/// Levels are ordered from least to most verbose. Each level includes +/// all statuses from lower levels plus its own. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)] +pub(crate) enum ReportLevel { + /// Show no per-hook status lines. + Silent, + /// Show only failed hooks. + Fail, + /// Show failed hooks and hooks skipped because no files matched or language is unimplemented. + SkippedNoFiles, + /// Show failed, no-files, and hooks excluded by --skip/SKIP/PREK_SKIP. + Skipped, + /// Show failed, skipped, and passed hooks (including dry-run). + #[default] + Passed, + /// Show every hook status. + All, +} +``` + +- [ ] **Step 2: Add the `report_level` field to `RunArgs`** + +In `crates/prek/src/cli/mod.rs`, add a new field to the `RunArgs` struct (after the `dry_run` field, around line 538): + +```rust + /// Control which hook statuses are shown in output. + /// + /// Levels from least to most verbose: silent, fail, skipped-no-files, + /// skipped, passed, all. Each level includes all statuses from lower levels. + #[arg( + long, + value_enum, + env = EnvVars::PREK_REPORT_LEVEL, + default_value_t = ReportLevel::Passed, + )] + pub(crate) report_level: ReportLevel, +``` + +- [ ] **Step 3: Verify it compiles** + +Run: `cargo check -p prek` +Expected: compiles with no errors (the field is defined but not yet used) + +- [ ] **Step 4: Commit** + +```bash +git add crates/prek/src/cli/mod.rs +git commit -m "feat: define ReportLevel enum and --report-level CLI flag" +``` + +--- + +### Task 3: Add `RunStatus::Skipped` variant and `ReportLevel::should_show` + +**Files:** + +- Modify: `crates/prek/src/cli/run/run.rs:954-978` + +- [ ] **Step 1: Add `Skipped` variant to `RunStatus`** + +In `crates/prek/src/cli/run/run.rs`, add the `Skipped` variant to the `RunStatus` enum (line 954-961): + +```rust +#[derive(Copy, Clone, Eq, PartialEq)] +enum RunStatus { + Success, + Failed, + DryRun, + NoFiles, + Unimplemented, + Skipped, +} +``` + +- [ ] **Step 2: Update `RunStatus` methods to handle `Skipped`** + +Update the three methods on `RunStatus` (lines 963-978): + +```rust +impl RunStatus { + fn as_bool(self) -> bool { + matches!( + self, + Self::Success | Self::NoFiles | Self::DryRun | Self::Unimplemented | Self::Skipped + ) + } + + fn is_unimplemented(self) -> bool { + matches!(self, Self::Unimplemented) + } + + fn is_skipped(self) -> bool { + matches!(self, Self::DryRun | Self::NoFiles | Self::Unimplemented | Self::Skipped) + } +} +``` + +- [ ] **Step 3: Add `should_show` method to `ReportLevel`** + +Import `ReportLevel` at the top of `crates/prek/src/cli/run/run.rs` and add this `impl` block. Place it right after the `ReportLevel` import or near the `RunStatus` impl: + +```rust +use crate::cli::ReportLevel; +``` + +Then add the impl (near the `RunStatus` impl, around line 978): + +```rust +impl ReportLevel { + fn should_show(self, status: RunStatus) -> bool { + match status { + RunStatus::Failed => self >= ReportLevel::Fail, + RunStatus::NoFiles | RunStatus::Unimplemented => self >= ReportLevel::SkippedNoFiles, + RunStatus::Skipped => self >= ReportLevel::Skipped, + RunStatus::Success | RunStatus::DryRun => self >= ReportLevel::Passed, + } + } +} +``` + +- [ ] **Step 4: Add `Skipped` rendering to `StatusPrinter::write`** + +In `StatusPrinter::write` (line 524-570), add a constant and a match arm. Add the constant with the others (around line 499): + +```rust + const EXCLUDED: &'static str = "(excluded by skip)"; +``` + +Add the match arm in the `write` method's match block (after the `Unimplemented` arm, around line 540): + +```rust + RunStatus::Skipped => ( + Self::EXCLUDED, + Self::SKIPPED.black().on_yellow().to_string(), + Self::SKIPPED.width(), + ), +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `cargo check -p prek` +Expected: compiles (warnings about unused `ReportLevel::should_show` are fine — it's used in the next task) + +- [ ] **Step 6: Commit** + +```bash +git add crates/prek/src/cli/run/run.rs +git commit -m "feat: add RunStatus::Skipped variant and ReportLevel::should_show" +``` + +--- + +### Task 4: Thread `report_level` through the call chain + +**Files:** + +- Modify: `crates/prek/src/main.rs:276-298` + +- Modify: `crates/prek/src/cli/run/run.rs:34-54,203-213,575-584` + +- [ ] **Step 1: Pass `report_level` from main.rs** + +In `crates/prek/src/main.rs`, add `args.report_level` to the `cli::run()` call (around line 279-298). Add it after `args.extra` (line 295): + +```rust + Command::Run(args) => { + show_settings!(args); + + cli::run( + &store, + cli.globals.config, + args.includes, + args.skips, + args.stage, + args.from_ref, + args.to_ref, + args.all_files, + args.files, + args.directory, + args.last_commit, + args.show_diff_on_failure, + flag(args.fail_fast, args.no_fail_fast), + args.dry_run, + cli.globals.refresh, + args.extra, + args.report_level, + cli.globals.verbose > 0, + printer, + ) + .await + } +``` + +- [ ] **Step 2: Add `report_level` parameter to `cli::run()`** + +In `crates/prek/src/cli/run/run.rs`, update the `run()` function signature (lines 34-54). Add `report_level: ReportLevel` after `extra_args: RunExtraArgs`: + +```rust +pub(crate) async fn run( + store: &Store, + config: Option, + includes: Vec, + skips: Vec, + hook_stage: Option, + from_ref: Option, + to_ref: Option, + all_files: bool, + files: Vec, + directories: Vec, + last_commit: bool, + show_diff_on_failure: bool, + fail_fast: Option, + dry_run: bool, + refresh: bool, + extra_args: RunExtraArgs, + report_level: ReportLevel, + verbose: bool, + printer: Printer, +) -> Result { +``` + +- [ ] **Step 3: Pass `report_level` to `run_hooks()`** + +Update the `run_hooks()` call site (around line 203-213): + +```rust + run_hooks( + &workspace, + &installed_hooks, + filenames, + store, + show_diff_on_failure, + fail_fast, + dry_run, + report_level, + verbose, + printer, + ) + .await +``` + +- [ ] **Step 4: Add `report_level` parameter to `run_hooks()`** + +Update the `run_hooks()` function signature (lines 575-584): + +```rust +async fn run_hooks( + workspace: &Workspace, + hooks: &[InstalledHook], + filenames: Vec, + store: &Store, + show_diff_on_failure: bool, + fail_fast: Option, + dry_run: bool, + report_level: ReportLevel, + verbose: bool, + printer: Printer, +) -> Result { +``` + +- [ ] **Step 5: Verify it compiles** + +Run: `cargo check -p prek` +Expected: compiles with no errors + +- [ ] **Step 6: Commit** + +```bash +git add crates/prek/src/main.rs crates/prek/src/cli/run/run.rs +git commit -m "feat: thread report_level through run call chain" +``` + +--- + +### Task 5: Filter rendering by report level + +**Files:** + +- Modify: `crates/prek/src/cli/run/run.rs:626-672,797-917` + +- [ ] **Step 1: Pass `report_level` to `render_priority_group()`** + +Update the call site in `run_hooks()` (around line 664-672): + +```rust + reporter.suspend(|| { + render_priority_group( + printer, + &status_printer, + &group_results, + verbose, + group_modified_files, + report_level, + ) + })?; +``` + +- [ ] **Step 2: Update `render_priority_group` signature and add filtering** + +Update `render_priority_group` (line 797) to accept `report_level` and gate status line output: + +```rust +fn render_priority_group( + printer: Printer, + status_printer: &StatusPrinter, + group_results: &[RunResult], + verbose: bool, + group_modified_files: bool, + report_level: ReportLevel, +) -> Result<()> { + // Only show a special group UI when the group failed due to file modifications + // AND at least one hook in the group is visible at the current report level. + let any_visible = group_results + .iter() + .any(|r| report_level.should_show(r.status)); + + let show_group_ui = + group_modified_files && group_results.len() > 1 && any_visible; + let single_hook_modified_files = group_results.len() == 1 && group_modified_files; + let group_prefix = if show_group_ui { + format!("{}", " │ ".dimmed()) + } else { + String::new() + }; + + if show_group_ui { + status_printer.write( + "Files were modified by following hooks", + "", + RunStatus::Failed, + )?; + } + + for (i, result) in group_results.iter().enumerate() { + let prefix = if show_group_ui { + if i == 0 { + " ┌ " + } else if i + 1 == group_results.len() { + " └ " + } else { + " │ " + } + } else { + "" + }; + + // If a single hook modified files, treat it as failed. + let status = if single_hook_modified_files && result.status == RunStatus::Success { + RunStatus::Failed + } else { + result.status + }; + + // Skip rendering if below the report level threshold. + if !report_level.should_show(status) { + continue; + } + + status_printer.write(&result.hook.name, prefix, status)?; + + if matches!(status, RunStatus::NoFiles | RunStatus::Unimplemented) { + continue; + } + + let mut stdout = match status { + RunStatus::Failed => printer.stdout_important(), + _ => printer.stdout(), + }; + + if verbose || result.hook.verbose || status == RunStatus::Failed { + writeln!( + stdout, + "{group_prefix}{}", + format!("- hook id: {}", result.hook.id).dimmed() + )?; + if verbose || result.hook.verbose { + writeln!( + stdout, + "{group_prefix}{}", + format!("- duration: {:.2?}s", result.duration.as_secs_f64()).dimmed() + )?; + } + if result.exit_status != 0 { + writeln!( + stdout, + "{group_prefix}{}", + format!("- exit code: {}", result.exit_status).dimmed() + )?; + } + if single_hook_modified_files { + writeln!( + stdout, + "{group_prefix}{}", + "- files were modified by this hook".dimmed() + )?; + } + + let output = result.output.trim_ascii(); + if !output.is_empty() { + if let Some(file) = result.hook.log_file.as_deref() { + let mut file = fs_err::OpenOptions::new() + .create(true) + .append(true) + .open(file)?; + file.write_all(output)?; + file.flush()?; + } else { + if show_group_ui { + writeln!(stdout, "{}", " │".dimmed())?; + } else { + writeln!(stdout)?; + } + let text = String::from_utf8_lossy(output); + for line in text.lines() { + if line.is_empty() { + if show_group_ui { + writeln!(stdout, "{}", " │".dimmed())?; + } else { + writeln!(stdout)?; + } + } else { + if show_group_ui { + writeln!(stdout, "{group_prefix}{line}")?; + } else { + writeln!(stdout, " {line}")?; + } + } + } + } + } + } + } + + Ok(()) +} +``` + +- [ ] **Step 3: Suppress project headers when `report_level` is `Silent`** + +In `run_hooks()`, wrap the project header output (around line 626-636) with a report level check: + +```rust + if (projects_len > 1 || !project.is_root()) + && report_level != ReportLevel::Silent + { + reporter.suspend(|| { + writeln!( + status_printer.printer().stdout(), + "{}{}", + if first { "" } else { "\n" }, + format!("Running hooks for `{}`:", project.to_string().cyan()).bold() + ) + })?; + first = false; + } +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo check -p prek` +Expected: compiles with no errors + +- [ ] **Step 5: Verify existing tests still pass** + +Run: `cargo nextest run -p prek --test skipped_hooks` +Expected: all existing tests pass (default `passed` level matches current behavior) + +- [ ] **Step 6: Commit** + +```bash +git add crates/prek/src/cli/run/run.rs +git commit -m "feat: filter hook status rendering by report level" +``` + +--- + +### Task 6: Track and render selector-skipped hooks + +**Files:** + +- Modify: `crates/prek/src/cli/run/run.rs:92-100,575-686` + +- [ ] **Step 1: Partition hooks into selected and skipped** + +In `crates/prek/src/cli/run/run.rs`, replace the filter at lines 92-100 with a partition: + +```rust + let hooks = workspace + .init_hooks(store, Some(&reporter)) + .await + .context("Failed to init hooks")?; + + let mut selected_hooks = Vec::new(); + let mut skipped_by_selector: Vec> = Vec::new(); + for hook in hooks { + if selectors.matches_hook(&hook) { + selected_hooks.push(Arc::new(hook)); + } else { + skipped_by_selector.push(Arc::new(hook)); + } + } +``` + +- [ ] **Step 2: Filter skipped hooks by the active stage** + +After the stage resolution logic (around line 148), filter `skipped_by_selector` to only include hooks for the active stage. This prevents showing skipped hooks from unrelated stages (e.g., `pre-push` hooks when running `pre-commit`): + +```rust + let skipped_by_selector: Vec<_> = skipped_by_selector + .into_iter() + .filter(|h| h.stages.contains(hook_stage)) + .collect(); +``` + +- [ ] **Step 3: Pass `skipped_by_selector` to `run_hooks()`** + +Update the `run_hooks()` call (around line 203) to include `skipped_by_selector`: + +```rust + run_hooks( + &workspace, + &installed_hooks, + &skipped_by_selector, + filenames, + store, + show_diff_on_failure, + fail_fast, + dry_run, + report_level, + verbose, + printer, + ) + .await +``` + +Update the `run_hooks()` function signature: + +```rust +async fn run_hooks( + workspace: &Workspace, + hooks: &[InstalledHook], + skipped_by_selector: &[Arc], + filenames: Vec, + store: &Store, + show_diff_on_failure: bool, + fail_fast: Option, + dry_run: bool, + report_level: ReportLevel, + verbose: bool, + printer: Printer, +) -> Result { +``` + +- [ ] **Step 4: Include skipped hook names in `StatusPrinter` column width** + +In `run_hooks()`, update the `StatusPrinter` construction (around line 588) to account for skipped hook names: + +```rust + let status_printer = StatusPrinter::for_hooks_with_skipped(hooks, skipped_by_selector, printer); +``` + +Add a new constructor method to `StatusPrinter` (near line 502): + +```rust + fn for_hooks_with_skipped( + hooks: &[InstalledHook], + skipped: &[Arc], + printer: Printer, + ) -> Self { + let name_len = hooks + .iter() + .map(|hook| hook.name.width()) + .chain(skipped.iter().map(|hook| hook.name.width())) + .max() + .unwrap_or(0); + let columns = std::cmp::max( + 79, + name_len + 3 + Self::NO_FILES.len() + Self::SKIPPED.len(), + ); + Self { printer, columns } + } +``` + +- [ ] **Step 5: Render selector-skipped hooks after executed hooks per project** + +In `run_hooks()`, after each project's priority group loop (after line 684, before the closing of the `'outer` loop), add rendering for selector-skipped hooks belonging to this project: + +```rust + // Render selector-skipped hooks for this project, after executed hooks. + if report_level.should_show(RunStatus::Skipped) { + let mut project_skipped: Vec<_> = skipped_by_selector + .iter() + .filter(|h| h.project() == project) + .collect(); + project_skipped.sort_by_key(|h| h.idx); + + for hook in project_skipped { + reporter.suspend(|| { + status_printer.write(&hook.name, "", RunStatus::Skipped) + })?; + } + } +``` + +- [ ] **Step 6: Verify it compiles** + +Run: `cargo check -p prek` +Expected: compiles with no errors + +- [ ] **Step 7: Commit** + +```bash +git add crates/prek/src/cli/run/run.rs +git commit -m "feat: track and render selector-skipped hooks" +``` + +--- + +### Task 7: Write integration tests + +**Files:** + +- Modify: `crates/prek/tests/skipped_hooks.rs` + +- [ ] **Step 1: Add test for `--report-level fail`** + +Add to `crates/prek/tests/skipped_hooks.rs`: + +```rust +/// `--report-level fail` hides passed and no-files hooks, shows only failures. +#[test] +fn report_level_fail_shows_only_failures() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: fail-hook + name: fail-hook + language: system + entry: exit 1 + files: \.txt$ + - id: no-files-hook + name: no-files-hook + language: system + entry: echo "checking" + files: \.py$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--report-level").arg("fail"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + fail-hook...............................................................Failed + - hook id: fail-hook + - exit code: 1 + + ----- stderr ----- + "#); + + Ok(()) +} +``` + +- [ ] **Step 2: Run the test to verify** + +Run: `cargo nextest run -p prek --test skipped_hooks report_level_fail_shows_only_failures` +Expected: PASS (or snapshot needs updating with `cargo insta review`) + +- [ ] **Step 3: Add test for `--report-level silent`** + +```rust +/// `--report-level silent` shows no per-hook status lines but still fails. +#[test] +fn report_level_silent_no_output_but_exit_code() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: fail-hook + name: fail-hook + language: system + entry: exit 1 + files: \.txt$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--report-level").arg("silent"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + "#); + + Ok(()) +} +``` + +- [ ] **Step 4: Add test for `--report-level skipped-no-files`** + +```rust +/// `--report-level skipped-no-files` shows failed and no-files hooks, hides passed. +#[test] +fn report_level_skipped_no_files() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: no-files-hook + name: no-files-hook + language: system + entry: echo "checking" + files: \.py$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().arg("--report-level").arg("skipped-no-files"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + no-files-hook....................................(no files to check)Skipped + + ----- stderr ----- + "#); + + Ok(()) +} +``` + +- [ ] **Step 5: Add test for `--report-level skipped` showing excluded hooks** + +```rust +/// `--report-level skipped` shows hooks excluded by --skip. +#[test] +fn report_level_skipped_shows_excluded_hooks() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: skipped-hook + name: skipped-hook + language: system + entry: echo "should be skipped" + files: \.txt$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run() + .arg("--skip").arg("skipped-hook") + .arg("--report-level").arg("skipped"), @r#" + success: true + exit_code: 0 + ----- stdout ----- + pass-hook...............................................................Passed + skipped-hook........................................(excluded by skip)Skipped + + ----- stderr ----- + "#); + + Ok(()) +} +``` + +- [ ] **Step 6: Add test for `PREK_REPORT_LEVEL` env var** + +```rust +/// PREK_REPORT_LEVEL env var works as fallback for --report-level. +#[test] +fn report_level_env_var() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: fail-hook + name: fail-hook + language: system + entry: exit 1 + files: \.txt$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run().env("PREK_REPORT_LEVEL", "fail"), @r#" + success: false + exit_code: 1 + ----- stdout ----- + fail-hook...............................................................Failed + - hook id: fail-hook + - exit code: 1 + + ----- stderr ----- + "#); + + Ok(()) +} +``` + +- [ ] **Step 7: Add test for default behavior (no flag)** + +```rust +/// Default behavior (no --report-level flag) matches current behavior. +#[test] +fn report_level_default_matches_current_behavior() -> Result<()> { + let context = TestContext::new(); + context.init_project(); + + let cwd = context.work_dir(); + + context.write_pre_commit_config(indoc::indoc! {r#" + repos: + - repo: local + hooks: + - id: pass-hook + name: pass-hook + language: system + entry: echo "ok" + files: \.txt$ + - id: no-files-hook + name: no-files-hook + language: system + entry: echo "checking" + files: \.py$ + "#}); + + cwd.child("file.txt").write_str("content")?; + context.git_add("."); + + cmd_snapshot!(context.filters(), context.run(), @r#" + success: true + exit_code: 0 + ----- stdout ----- + pass-hook...............................................................Passed + no-files-hook....................................(no files to check)Skipped + + ----- stderr ----- + "#); + + Ok(()) +} +``` + +- [ ] **Step 8: Run all new tests** + +Run: `cargo nextest run -p prek --test skipped_hooks` +Expected: all tests pass (update snapshots with `cargo insta review` if needed) + +- [ ] **Step 9: Run existing test suite to check for regressions** + +Run: `cargo nextest run -p prek --test run` +Expected: all existing tests still pass + +- [ ] **Step 10: Commit** + +```bash +git add crates/prek/tests/skipped_hooks.rs +git commit -m "test: add integration tests for --report-level flag" +``` + +--- + +### Task 8: Final verification + +- [ ] **Step 1: Run the full test suite** + +Run: `cargo nextest run -p prek` +Expected: all tests pass + +- [ ] **Step 2: Run clippy** + +Run: `cargo clippy -p prek -- -D warnings` +Expected: no warnings + +- [ ] **Step 3: Verify CLI help output** + +Run: `cargo run -p prek -- run --help` +Expected: `--report-level` appears in the help output with the six level options listed + +- [ ] **Step 4: Manual smoke test** + +Create a temporary test project and verify: + +```bash +# In a temp git repo with a .pre-commit-config.yaml: +cargo run -p prek -- run --report-level fail +cargo run -p prek -- run --report-level silent +cargo run -p prek -- run --report-level skipped --skip some-hook +``` + +Expected: output matches the design spec for each level