From c5e7b20c56a7b507dafa90853288b97fca443a9a Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Fri, 10 Apr 2026 17:04:01 -0700 Subject: [PATCH 01/13] docs: add --report-level design proposal and implementation plan Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-04-10-report-level-design.md | 127 +++ .../proposals/2026-04-10-report-level-plan.md | 989 ++++++++++++++++++ 2 files changed, 1116 insertions(+) create mode 100644 docs/proposals/2026-04-10-report-level-design.md create mode 100644 docs/proposals/2026-04-10-report-level-plan.md 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..78f0c1063 --- /dev/null +++ b/docs/proposals/2026-04-10-report-level-design.md @@ -0,0 +1,127 @@ +# 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..454539d69 --- /dev/null +++ b/docs/proposals/2026-04-10-report-level-plan.md @@ -0,0 +1,989 @@ +# `--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 From e9b747cd1f55e1550bb207178f63e4efe4bb6924 Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Fri, 10 Apr 2026 17:04:07 -0700 Subject: [PATCH 02/13] feat: add PREK_REPORT_LEVEL env var constant Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/prek-consts/src/env_vars.rs | 1 + 1 file changed, 1 insertion(+) 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 From 5c444953d83198c1911614053311be5ad92dafa4 Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Fri, 10 Apr 2026 17:04:18 -0700 Subject: [PATCH 03/13] feat: define ReportLevel enum and --report-level CLI flag Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/prek/src/cli/mod.rs | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/crates/prek/src/cli/mod.rs b/crates/prek/src/cli/mod.rs index 97984a8c3..c462ab7a1 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. From 3110649b8d1e8ea165b3a3aad2dde8bdf8cf8490 Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Fri, 10 Apr 2026 17:04:22 -0700 Subject: [PATCH 04/13] feat: add RunStatus::Skipped variant and ReportLevel::should_show Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/prek/src/cli/run/run.rs | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index 5f0e40bed..52f8d3d37 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -20,7 +20,7 @@ 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::{ExitStatus, ReportLevel, RunExtraArgs}; use crate::config::{Language, PassFilenames, Stage}; use crate::fs::CWD; use crate::git::GIT_ROOT; @@ -498,6 +498,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 @@ -538,6 +539,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(), @@ -958,13 +964,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 +980,18 @@ 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, + } } } From 298670059c93339898a42e6a07afd9ee7debebdb Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Fri, 10 Apr 2026 17:07:32 -0700 Subject: [PATCH 05/13] feat: thread report_level through run call chain Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/prek/src/cli/hook_impl.rs | 3 ++- crates/prek/src/cli/run/run.rs | 3 +++ crates/prek/src/cli/try_repo.rs | 3 ++- crates/prek/src/main.rs | 1 + 4 files changed, 8 insertions(+), 2 deletions(-) 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/run/run.rs b/crates/prek/src/cli/run/run.rs index 52f8d3d37..c6ddc3144 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -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 { @@ -208,6 +209,7 @@ pub(crate) async fn run( show_diff_on_failure, fail_fast, dry_run, + report_level, verbose, printer, ) @@ -586,6 +588,7 @@ async fn run_hooks( show_diff_on_failure: bool, fail_fast: Option, dry_run: bool, + _report_level: ReportLevel, verbose: bool, printer: Printer, ) -> Result { diff --git a/crates/prek/src/cli/try_repo.rs b/crates/prek/src/cli/try_repo.rs index 395d6620b..49dc01d3a 100644 --- a/crates/prek/src/cli/try_repo.rs +++ b/crates/prek/src/cli/try_repo.rs @@ -9,7 +9,7 @@ use tempfile::TempDir; use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Value}; use crate::cli::run::Selectors; -use crate::cli::{ExitStatus, flag}; +use crate::cli::{ExitStatus, ReportLevel, flag}; use crate::config; use crate::git; use crate::git::GIT_ROOT; @@ -223,6 +223,7 @@ pub(crate) async fn try_repo( run_args.dry_run, refresh, run_args.extra, + ReportLevel::default(), 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, ) From 38b2cbf3171f573e757f898f124901e0ace3f53e Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Fri, 10 Apr 2026 17:09:47 -0700 Subject: [PATCH 06/13] feat: filter hook status rendering by report level Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/prek/src/cli/run/run.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index c6ddc3144..28563ba90 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -588,7 +588,7 @@ async fn run_hooks( show_diff_on_failure: bool, fail_fast: Option, dry_run: bool, - _report_level: ReportLevel, + report_level: ReportLevel, verbose: bool, printer: Printer, ) -> Result { @@ -632,7 +632,9 @@ 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(), @@ -677,6 +679,7 @@ async fn run_hooks( &group_results, verbose, group_modified_files, + report_level, ) })?; @@ -809,10 +812,16 @@ 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. - let show_group_ui = group_modified_files && group_results.len() > 1; + 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()) @@ -848,6 +857,11 @@ fn render_priority_group( 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) { From 27019e19cf956003ef71f59e2c82a192b7acb5c1 Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Fri, 10 Apr 2026 17:11:53 -0700 Subject: [PATCH 07/13] feat: track and render selector-skipped hooks Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/prek/src/cli/run/run.rs | 57 ++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 6 deletions(-) diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index 28563ba90..1697ad372 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -94,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 { + if selectors.matches_hook(&hook) { + selected_hooks.push(Arc::new(hook)); + } else { + skipped_by_selector.push(Arc::new(hook)); + } + } selectors.report_unused(); @@ -148,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, @@ -204,6 +214,7 @@ pub(crate) async fn run( run_hooks( &workspace, &installed_hooks, + &skipped_by_selector, filenames, store, show_diff_on_failure, @@ -516,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 } @@ -583,6 +612,7 @@ impl StatusPrinter { async fn run_hooks( workspace: &Workspace, hooks: &[InstalledHook], + skipped_by_selector: &[Arc], filenames: Vec, store: &Store, show_diff_on_failure: bool, @@ -594,7 +624,7 @@ async fn run_hooks( ) -> 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; @@ -694,6 +724,21 @@ 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(); From 6256f45a8cdfeff762fa5da226935567cceb6272 Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Sun, 12 Apr 2026 15:41:06 -0700 Subject: [PATCH 08/13] test: add integration tests for --report-level flag Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/prek/tests/run.rs | 52 +++++-- crates/prek/tests/skipped_hooks.rs | 240 +++++++++++++++++++++++++++++ 2 files changed, 277 insertions(+), 15 deletions(-) diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index bb229a405..958816d02 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -61,14 +61,16 @@ 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 + fix end of files......................................(excluded by skip)Skipped + check json............................................(excluded by skip)Skipped ----- stderr ----- - "#); + "); Ok(()) } @@ -375,25 +377,30 @@ 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 + Second Hook...........................................(excluded by skip)Skipped + Shared Hook A.........................................(excluded by skip)Skipped + Shared Hook B.........................................(excluded by skip)Skipped ----- 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 ----- Shared Hook A............................................................Passed Shared Hook B............................................................Passed + First Hook............................................(excluded by skip)Skipped + Second Hook...........................................(excluded by skip)Skipped ----- stderr ----- - "#); + "); // Hook-id matches nothing cmd_snapshot!(context.filters(), context.run().arg("nonexistent-hook"), @r" @@ -420,50 +427,58 @@ 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 + First Hook............................................(excluded by skip)Skipped + Shared Hook A.........................................(excluded by skip)Skipped + Shared Hook B.........................................(excluded by skip)Skipped ----- 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 ----- First Hook...............................................................Passed Second Hook..............................................................Passed + Shared Hook A.........................................(excluded by skip)Skipped + Shared Hook B.........................................(excluded by skip)Skipped ----- stderr ----- warning: selector `nonexistent` did not match any hooks "); // 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 ----- First Hook...............................................................Passed Second Hook..............................................................Passed + Shared Hook A.........................................(excluded by skip)Skipped + Shared Hook B.........................................(excluded by skip)Skipped ----- stderr ----- warning: selector `nonexistent-hook` did not match any hooks "); // 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 ----- First Hook...............................................................Passed Shared Hook A............................................................Passed Shared Hook B............................................................Passed + Second Hook...........................................(excluded by skip)Skipped ----- stderr ----- - "#); + "); } #[test] @@ -748,7 +763,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 +773,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 ----- "); @@ -901,16 +919,19 @@ fn fallback_to_manual_stage() { ----- stdout ----- manual-only..............................................................Passed another-manual...........................................................Passed + default-stage.........................................(excluded by skip)Skipped ----- stderr ----- "); // 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 ----- manual-only..............................................................Passed + another-manual........................................(excluded by skip)Skipped + default-stage.........................................(excluded by skip)Skipped ----- stderr ----- "); @@ -2399,6 +2420,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..33025ce68 100644 --- a/crates/prek/tests/skipped_hooks.rs +++ b/crates/prek/tests/skipped_hooks.rs @@ -218,3 +218,243 @@ 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(()) +} + +/// 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(()) +} From 6a5a666e5adf141ee8ee58ebca6c5fa5cbdb72c1 Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Sun, 12 Apr 2026 15:58:24 -0700 Subject: [PATCH 09/13] fix: update snapshots and docs for --report-level flag Fix clippy doc-markdown lint, update meta_hooks test snapshots to reflect selector-skipped hooks, and regenerate CLI reference. Co-Authored-By: Claude Opus 4.6 (1M context) --- crates/prek/src/cli/mod.rs | 2 +- crates/prek/tests/meta_hooks.rs | 3 +++ docs/cli.md | 24 ++++++++++++++++++++++-- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/prek/src/cli/mod.rs b/crates/prek/src/cli/mod.rs index c462ab7a1..5ac68e736 100644 --- a/crates/prek/src/cli/mod.rs +++ b/crates/prek/src/cli/mod.rs @@ -565,7 +565,7 @@ pub(crate) enum ReportLevel { 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. + /// Show failed, no-files, and hooks excluded by `--skip`/`SKIP`/`PREK_SKIP`. Skipped, /// Show failed, skipped, and passed hooks (including dry-run). #[default] diff --git a/crates/prek/tests/meta_hooks.rs b/crates/prek/tests/meta_hooks.rs index db284bc14..ff013fb93 100644 --- a/crates/prek/tests/meta_hooks.rs +++ b/crates/prek/tests/meta_hooks.rs @@ -145,6 +145,8 @@ fn check_useless_excludes_remote() -> anyhow::Result<()> { - exit code: 1 The exclude pattern `regex: ^useless/$` for `echo` does not match any files + black.................................................(excluded by skip)Skipped + echo..................................................(excluded by skip)Skipped ----- stderr ----- "); @@ -265,6 +267,7 @@ fn check_useless_excludes_workspace_paths_are_project_relative() -> anyhow::Resu ----- stdout ----- Running hooks for `app`: Check useless excludes...................................................Passed + ok....................................................(excluded by skip)Skipped ----- stderr ----- "); 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:

From fa20795a3a761123b9302964e9ea6006f76a46ce Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Sun, 12 Apr 2026 19:07:08 -0700 Subject: [PATCH 10/13] fix(ci): rustfmt run.rs and clippy doc for report-level tests Made-with: Cursor --- crates/prek/src/cli/run/run.rs | 16 +++++++--------- crates/prek/tests/skipped_hooks.rs | 2 +- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/crates/prek/src/cli/run/run.rs b/crates/prek/src/cli/run/run.rs index 1697ad372..e50112dd6 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -662,9 +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()) - && report_level != ReportLevel::Silent - { + if (projects_len > 1 || !project.is_root()) && report_level != ReportLevel::Silent { reporter.suspend(|| { writeln!( status_printer.printer().stdout(), @@ -734,9 +732,7 @@ async fn run_hooks( project_skipped.sort_by_key(|h| h.idx); for hook in project_skipped { - reporter.suspend(|| { - status_printer.write(&hook.name, "", RunStatus::Skipped) - })?; + reporter.suspend(|| status_printer.write(&hook.name, "", RunStatus::Skipped))?; } } } @@ -865,8 +861,7 @@ fn render_priority_group( .iter() .any(|r| report_level.should_show(r.status)); - let show_group_ui = - group_modified_files && group_results.len() > 1 && any_visible; + 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()) @@ -1042,7 +1037,10 @@ impl RunStatus { } fn is_skipped(self) -> bool { - matches!(self, Self::DryRun | Self::NoFiles | Self::Unimplemented | Self::Skipped) + matches!( + self, + Self::DryRun | Self::NoFiles | Self::Unimplemented | Self::Skipped + ) } } diff --git a/crates/prek/tests/skipped_hooks.rs b/crates/prek/tests/skipped_hooks.rs index 33025ce68..35186e648 100644 --- a/crates/prek/tests/skipped_hooks.rs +++ b/crates/prek/tests/skipped_hooks.rs @@ -378,7 +378,7 @@ fn report_level_skipped_shows_excluded_hooks() -> Result<()> { Ok(()) } -/// PREK_REPORT_LEVEL env var works as fallback for --report-level. +/// `PREK_REPORT_LEVEL` env var works as fallback for `--report-level`. #[test] fn report_level_env_var() -> Result<()> { let context = TestContext::new(); From 6f2cdf4b592f64ebe27f74de7295981d2a6b66aa Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Mon, 13 Apr 2026 09:08:00 -0700 Subject: [PATCH 11/13] docs: mdformat report-level proposal files for CI lint Made-with: Cursor --- .../2026-04-10-report-level-design.md | 26 ++++++++++--------- .../proposals/2026-04-10-report-level-plan.md | 10 +++++++ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/docs/proposals/2026-04-10-report-level-design.md b/docs/proposals/2026-04-10-report-level-design.md index 78f0c1063..e3591ab79 100644 --- a/docs/proposals/2026-04-10-report-level-design.md +++ b/docs/proposals/2026-04-10-report-level-design.md @@ -13,7 +13,7 @@ Add a `--report-level ` flag to `prek run` that controls which hook statu 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 | @@ -26,7 +26,7 @@ Each level is a threshold — a status is displayed if the report level is high ### Status-to-level mapping | `RunStatus` | Minimum level to display | -|---|---| +| -- | -- | | `Failed` | `fail` | | `NoFiles` | `skipped-no-files` | | `Unimplemented` | `skipped-no-files` | @@ -61,6 +61,7 @@ A new method `ReportLevel::should_show(status: RunStatus) -> bool` gates each `s #### `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 @@ -86,6 +87,7 @@ The suffix `(excluded by skip)` distinguishes from `(no files to check)` skips. **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` @@ -98,16 +100,16 @@ The existing `-q`/`--quiet` flag controls the `Printer` level (`Quiet`, `Silent` ## 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()` +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 diff --git a/docs/proposals/2026-04-10-report-level-plan.md b/docs/proposals/2026-04-10-report-level-plan.md index 454539d69..b24d3f139 100644 --- a/docs/proposals/2026-04-10-report-level-plan.md +++ b/docs/proposals/2026-04-10-report-level-plan.md @@ -15,6 +15,7 @@ ### 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** @@ -42,6 +43,7 @@ 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** @@ -106,6 +108,7 @@ 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`** @@ -205,7 +208,9 @@ 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** @@ -325,6 +330,7 @@ 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()`** @@ -526,6 +532,7 @@ 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** @@ -667,6 +674,7 @@ 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`** @@ -980,10 +988,12 @@ Expected: `--report-level` appears in the help output with the six level options - [ ] **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 From 1a23929e0d992a7b7431d0f95ee31a556ac958ec Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Mon, 13 Apr 2026 09:39:41 -0700 Subject: [PATCH 12/13] fix(report-level): correct skip partition, group UI, log_file, try-repo Only treat hooks matched by skip selectors as selector-skipped for display. Show multi-hook file-modification header when successes are hidden. Always append hook output to log_file when set; forward report_level from try-repo. Update snapshots and add regression tests. Made-with: Cursor --- crates/prek/src/cli/run/mod.rs | 2 +- crates/prek/src/cli/run/run.rs | 84 +++++++++++++++-------------- crates/prek/src/cli/run/selector.rs | 54 +++++++++++++++---- crates/prek/src/cli/try_repo.rs | 4 +- crates/prek/tests/common/mod.rs | 4 ++ crates/prek/tests/meta_hooks.rs | 3 -- crates/prek/tests/run.rs | 70 ++++++++++++++---------- crates/prek/tests/skipped_hooks.rs | 80 +++++++++++++++++++++++++++ 8 files changed, 216 insertions(+), 85 deletions(-) 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 e50112dd6..f6f9e96c1 100644 --- a/crates/prek/src/cli/run/run.rs +++ b/crates/prek/src/cli/run/run.rs @@ -19,7 +19,7 @@ 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::run::{CollectOptions, FileFilter, HookPartition, Selectors, collect_files}; use crate::cli::{ExitStatus, ReportLevel, RunExtraArgs}; use crate::config::{Language, PassFilenames, Stage}; use crate::fs::CWD; @@ -98,10 +98,10 @@ pub(crate) async fn run( 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)); + match selectors.hook_partition(&hook) { + HookPartition::Selected => selected_hooks.push(Arc::new(hook)), + HookPartition::SkippedBySkipSelector => skipped_by_selector.push(Arc::new(hook)), + HookPartition::NotSelected => {} } } @@ -857,11 +857,11 @@ fn render_priority_group( ) -> 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. - 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; + // + // 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 { format!("{}", " │ ".dimmed()) @@ -897,14 +897,28 @@ fn render_priority_group( result.status }; - // Skip rendering if below the report level threshold. - if !report_level.should_show(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; } - status_printer.write(&result.hook.name, prefix, status)?; + 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 matches!(status, RunStatus::NoFiles | RunStatus::Unimplemented) { + if !show_status_on_console { continue; } @@ -941,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}")?; } } } 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 49dc01d3a..e3863555b 100644 --- a/crates/prek/src/cli/try_repo.rs +++ b/crates/prek/src/cli/try_repo.rs @@ -9,7 +9,7 @@ use tempfile::TempDir; use toml_edit::{Array, ArrayOfTables, DocumentMut, InlineTable, Item, Value}; use crate::cli::run::Selectors; -use crate::cli::{ExitStatus, ReportLevel, flag}; +use crate::cli::{ExitStatus, flag}; use crate::config; use crate::git; use crate::git::GIT_ROOT; @@ -223,7 +223,7 @@ pub(crate) async fn try_repo( run_args.dry_run, refresh, run_args.extra, - ReportLevel::default(), + run_args.report_level, verbose, printer, ) diff --git a/crates/prek/tests/common/mod.rs b/crates/prek/tests/common/mod.rs index 8350a1e3e..17ef2c5a3 100644 --- a/crates/prek/tests/common/mod.rs +++ b/crates/prek/tests/common/mod.rs @@ -417,6 +417,10 @@ pub const INSTA_FILTERS: &[(&str, &str)] = &[ (r"\b(\d+\.)?\d+(ms|s)\b", "[TIME]"), // Strip non-deterministic lock contention warnings from parallel test execution (r"(?m)^warning: Waiting to acquire lock.*\n", ""), + // Normalize box-drawing characters (group modified-files UI) for portable snapshots + ("┌", "?"), + ("└", "?"), + ("│", "?"), ]; #[allow(unused_macros)] diff --git a/crates/prek/tests/meta_hooks.rs b/crates/prek/tests/meta_hooks.rs index ff013fb93..db284bc14 100644 --- a/crates/prek/tests/meta_hooks.rs +++ b/crates/prek/tests/meta_hooks.rs @@ -145,8 +145,6 @@ fn check_useless_excludes_remote() -> anyhow::Result<()> { - exit code: 1 The exclude pattern `regex: ^useless/$` for `echo` does not match any files - black.................................................(excluded by skip)Skipped - echo..................................................(excluded by skip)Skipped ----- stderr ----- "); @@ -267,7 +265,6 @@ fn check_useless_excludes_workspace_paths_are_project_relative() -> anyhow::Resu ----- stdout ----- Running hooks for `app`: Check useless excludes...................................................Passed - ok....................................................(excluded by skip)Skipped ----- stderr ----- "); diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index 958816d02..c68f48b8e 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -66,8 +66,6 @@ fn run_basic() -> Result<()> { exit_code: 0 ----- stdout ----- trim trailing whitespace.................................................Passed - fix end of files......................................(excluded by skip)Skipped - check json............................................(excluded by skip)Skipped ----- stderr ----- "); @@ -382,9 +380,6 @@ fn multiple_hook_ids() { exit_code: 0 ----- stdout ----- First Hook...............................................................Passed - Second Hook...........................................(excluded by skip)Skipped - Shared Hook A.........................................(excluded by skip)Skipped - Shared Hook B.........................................(excluded by skip)Skipped ----- stderr ----- "); @@ -396,8 +391,6 @@ fn multiple_hook_ids() { ----- stdout ----- Shared Hook A............................................................Passed Shared Hook B............................................................Passed - First Hook............................................(excluded by skip)Skipped - Second Hook...........................................(excluded by skip)Skipped ----- stderr ----- "); @@ -432,9 +425,6 @@ fn multiple_hook_ids() { exit_code: 0 ----- stdout ----- Second Hook..............................................................Passed - First Hook............................................(excluded by skip)Skipped - Shared Hook A.........................................(excluded by skip)Skipped - Shared Hook B.........................................(excluded by skip)Skipped ----- stderr ----- "); @@ -446,8 +436,6 @@ fn multiple_hook_ids() { ----- stdout ----- First Hook...............................................................Passed Second Hook..............................................................Passed - Shared Hook A.........................................(excluded by skip)Skipped - Shared Hook B.........................................(excluded by skip)Skipped ----- stderr ----- warning: selector `nonexistent` did not match any hooks @@ -460,8 +448,6 @@ fn multiple_hook_ids() { ----- stdout ----- First Hook...............................................................Passed Second Hook..............................................................Passed - Shared Hook A.........................................(excluded by skip)Skipped - Shared Hook B.........................................(excluded by skip)Skipped ----- stderr ----- warning: selector `nonexistent-hook` did not match any hooks @@ -475,7 +461,6 @@ fn multiple_hook_ids() { First Hook...............................................................Passed Shared Hook A............................................................Passed Shared Hook B............................................................Passed - Second Hook...........................................(excluded by skip)Skipped ----- stderr ----- "); @@ -617,15 +602,15 @@ fn priority_group_modified_files_is_group_failure_and_output_is_indented() -> Re exit_code: 1 ----- stdout ----- Files were modified by following hooks...................................Failed - ┌ Modifies File........................................................Passed - │ - hook id: modify - │ - duration: [TIME] - │ Prints Output........................................................Passed - │ - hook id: loud - │ - duration: [TIME] - │ - │ hello from loud - └ No Output............................................................Passed + ? Modifies File........................................................Passed + ? - hook id: modify + ? - duration: [TIME] + ? Prints Output........................................................Passed + ? - hook id: loud + ? - duration: [TIME] + ? + ? hello from loud + ? No Output............................................................Passed Later Hook...............................................................Passed - hook id: later - duration: [TIME] @@ -716,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 @@ -731,7 +716,7 @@ fn cjk_hook_name() { success: true exit_code: 0 ----- stdout ----- - 去除行尾空格.............................................................Passed + ??????...................................................................Passed fix end of files.........................................................Passed ----- stderr ----- @@ -919,7 +904,6 @@ fn fallback_to_manual_stage() { ----- stdout ----- manual-only..............................................................Passed another-manual...........................................................Passed - default-stage.........................................(excluded by skip)Skipped ----- stderr ----- "); @@ -930,8 +914,6 @@ fn fallback_to_manual_stage() { exit_code: 0 ----- stdout ----- manual-only..............................................................Passed - another-manual........................................(excluded by skip)Skipped - default-stage.........................................(excluded by skip)Skipped ----- stderr ----- "); @@ -1355,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() { diff --git a/crates/prek/tests/skipped_hooks.rs b/crates/prek/tests/skipped_hooks.rs index 35186e648..456628ef8 100644 --- a/crates/prek/tests/skipped_hooks.rs +++ b/crates/prek/tests/skipped_hooks.rs @@ -419,6 +419,86 @@ fn report_level_env_var() -> Result<()> { 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<()> { From 8a5083b04083b3469aade33c9fb98325f8bcc177 Mon Sep 17 00:00:00 2001 From: Jason D'Amour Date: Tue, 14 Apr 2026 10:48:42 -0700 Subject: [PATCH 13/13] fix(tests): do not strip U+2502 from snapshots used by gitleaks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removing global insta filters for box-drawing chars; they mangled docker_image gitleaks output. Expect UTF-8 ┌│└ in the priority-group modified-files snapshot instead. Made-with: Cursor --- crates/prek/tests/common/mod.rs | 4 ---- crates/prek/tests/run.rs | 18 +++++++++--------- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/crates/prek/tests/common/mod.rs b/crates/prek/tests/common/mod.rs index 17ef2c5a3..8350a1e3e 100644 --- a/crates/prek/tests/common/mod.rs +++ b/crates/prek/tests/common/mod.rs @@ -417,10 +417,6 @@ pub const INSTA_FILTERS: &[(&str, &str)] = &[ (r"\b(\d+\.)?\d+(ms|s)\b", "[TIME]"), // Strip non-deterministic lock contention warnings from parallel test execution (r"(?m)^warning: Waiting to acquire lock.*\n", ""), - // Normalize box-drawing characters (group modified-files UI) for portable snapshots - ("┌", "?"), - ("└", "?"), - ("│", "?"), ]; #[allow(unused_macros)] diff --git a/crates/prek/tests/run.rs b/crates/prek/tests/run.rs index c68f48b8e..3bd3ebc45 100644 --- a/crates/prek/tests/run.rs +++ b/crates/prek/tests/run.rs @@ -602,15 +602,15 @@ fn priority_group_modified_files_is_group_failure_and_output_is_indented() -> Re exit_code: 1 ----- stdout ----- Files were modified by following hooks...................................Failed - ? Modifies File........................................................Passed - ? - hook id: modify - ? - duration: [TIME] - ? Prints Output........................................................Passed - ? - hook id: loud - ? - duration: [TIME] - ? - ? hello from loud - ? No Output............................................................Passed + ┌ Modifies File........................................................Passed + │ - hook id: modify + │ - duration: [TIME] + │ Prints Output........................................................Passed + │ - hook id: loud + │ - duration: [TIME] + │ + │ hello from loud + └ No Output............................................................Passed Later Hook...............................................................Passed - hook id: later - duration: [TIME]