Skip to main content

omni_dev/cli/git/
lint.rs

1//! Lint command — deterministic, no-AI validation of commit messages against
2//! guidelines. The AI sibling is [`super::check`]; the two share
3//! [`crate::data::check::CheckReport`] so they're interchangeable in
4//! scripts. Core rule logic lives in [`crate::git::lint`].
5
6use std::io::Read;
7use std::path::Path;
8
9use anyhow::{Context, Result};
10use clap::Parser;
11
12use crate::data::check::{CheckReport, CommitCheckResult, OutputFormat};
13
14/// Lint command options - deterministically validates commit messages
15/// against guidelines (no AI, no network).
16#[derive(Parser)]
17pub struct LintCommand {
18    /// Commit range to lint (e.g., HEAD~3..HEAD, abc123..def456).
19    /// Defaults to commits ahead of the default base branch
20    /// (origin/main, origin/master, main, or master).
21    #[arg(value_name = "COMMIT_RANGE")]
22    pub commit_range: Option<String>,
23
24    /// Path to custom context directory (defaults to .omni-dev/).
25    #[arg(long)]
26    pub context_dir: Option<std::path::PathBuf>,
27
28    /// Accepted for CLI parity with `check`; unused by `lint` (there is no
29    /// AI prose guidelines file to load — deterministic rules come from
30    /// `.omni-dev/commit-rules.yaml` and `.omni-dev/scopes.yaml` instead).
31    #[arg(long)]
32    pub guidelines: Option<std::path::PathBuf>,
33
34    /// Output format.
35    #[arg(short = 'o', long, value_enum, default_value_t = OutputFormat::Text)]
36    pub output: OutputFormat,
37
38    /// Exits with error code if any issues found (including warnings).
39    #[arg(long)]
40    pub strict: bool,
41
42    /// Only shows errors/warnings, suppresses info-level output.
43    #[arg(long)]
44    pub quiet: bool,
45
46    /// Shows the resolved rules/scopes configuration sources.
47    #[arg(long)]
48    pub verbose: bool,
49
50    /// Includes passing commits in output (hidden by default).
51    #[arg(long)]
52    pub show_passing: bool,
53
54    /// Reads a single commit message from standard input instead of a
55    /// commit range (for a `commit-msg` git hook call site).
56    #[arg(long)]
57    pub stdin: bool,
58}
59
60impl LintCommand {
61    /// Executes the lint command, validating commit messages against
62    /// deterministic rules.
63    pub async fn execute(self, repo: Option<&Path>) -> Result<()> {
64        let repo_root = match repo {
65            Some(p) => p.to_path_buf(),
66            None => std::env::current_dir().context("Failed to determine current directory")?,
67        };
68        let repo_root = repo_root.as_path();
69        let output_format = self.output;
70
71        let context_dir =
72            crate::claude::context::resolve_context_dir_at(self.context_dir.as_deref(), repo_root);
73        let valid_scopes = crate::claude::context::load_project_scopes(&context_dir, repo_root);
74        let rules = crate::claude::context::load_commit_rules(&context_dir);
75
76        if self.verbose && output_format == OutputFormat::Text {
77            self.show_config_status(repo_root, &context_dir, &valid_scopes, &rules);
78        }
79
80        let report = if self.stdin {
81            let mut message = String::new();
82            std::io::stdin()
83                .read_to_string(&mut message)
84                .context("Failed to read commit message from stdin")?;
85            lint_report_for_message(&message, &rules, &valid_scopes)
86        } else {
87            let range = self.resolve_range(repo_root)?;
88            lint_report_for_range(repo_root, &range, &rules, &valid_scopes)?
89        };
90
91        self.output_report(&report, output_format)?;
92
93        // Unlike `check`, an empty range is a clean exit (0), not an error —
94        // a deterministic gate with nothing to lint isn't a failure.
95        let exit_code = report.exit_code(self.strict);
96        if exit_code != 0 {
97            std::process::exit(exit_code);
98        }
99
100        Ok(())
101    }
102
103    fn resolve_range(&self, repo_root: &Path) -> Result<String> {
104        if let Some(range) = &self.commit_range {
105            return Ok(range.clone());
106        }
107        let repo = crate::git::GitRepository::open_at(repo_root)
108            .context("Failed to open git repository at the given path")?;
109        super::default_commit_range(&repo)
110    }
111
112    fn show_config_status(
113        &self,
114        _repo_root: &Path,
115        context_dir: &Path,
116        valid_scopes: &[crate::data::context::ScopeDefinition],
117        rules: &crate::data::context::CommitRules,
118    ) {
119        use crate::claude::context::{config_source_label, ConfigSourceLabel};
120
121        println!("📋 Lint configuration:");
122        println!("   📂 Config dir: {}", context_dir.display());
123
124        let scopes_source = if valid_scopes.is_empty() {
125            "⚪ None found (any scope accepted)".to_string()
126        } else {
127            match config_source_label(context_dir, "scopes.yaml") {
128                ConfigSourceLabel::NotFound => {
129                    format!(
130                        "✅ (ecosystem defaults only) ({} scopes)",
131                        valid_scopes.len()
132                    )
133                }
134                label => format!("✅ {label} ({} scopes)", valid_scopes.len()),
135            }
136        };
137        println!("   🎯 Valid scopes: {scopes_source}");
138
139        let rules_source = match config_source_label(context_dir, "commit-rules.yaml") {
140            ConfigSourceLabel::NotFound => "⚪ Using built-in defaults".to_string(),
141            label => format!("✅ {label}"),
142        };
143        println!("   📏 Commit rules: {rules_source}");
144        println!(
145            "      subject_max_len={}, require_scope={}, types={}",
146            rules.subject_max_len,
147            rules.require_scope,
148            rules.types.len()
149        );
150        println!();
151    }
152
153    /// Outputs the lint report in the specified format.
154    fn output_report(&self, report: &CheckReport, format: OutputFormat) -> Result<()> {
155        match format {
156            OutputFormat::Text => self.output_text_report(report),
157            OutputFormat::Json => {
158                let json = serde_json::to_string_pretty(report)
159                    .context("Failed to serialize report to JSON")?;
160                println!("{json}");
161                Ok(())
162            }
163            OutputFormat::Yaml => {
164                let yaml =
165                    crate::data::to_yaml(report).context("Failed to serialize report to YAML")?;
166                println!("{yaml}");
167                Ok(())
168            }
169        }
170    }
171
172    /// Outputs the text format report.
173    fn output_text_report(&self, report: &CheckReport) -> Result<()> {
174        use crate::data::check::IssueSeverity;
175
176        println!();
177
178        for result in &report.commits {
179            if result.passes && !self.show_passing {
180                continue;
181            }
182
183            if self.quiet && !has_errors_or_warnings(&result.issues) {
184                continue;
185            }
186
187            let icon = super::formatting::determine_commit_icon(result.passes, &result.issues);
188            let short_hash = super::formatting::truncate_hash(&result.hash);
189            println!("{icon} {short_hash} - \"{}\"", result.message);
190
191            for issue in &result.issues {
192                if self.quiet && issue.severity == IssueSeverity::Info {
193                    continue;
194                }
195                let severity_str = super::formatting::format_severity_label(issue.severity);
196                println!(
197                    "   {} [{}] {}",
198                    severity_str, issue.section, issue.explanation
199                );
200            }
201
202            println!();
203        }
204
205        println!(
206            "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n\
207             Summary: {} commits linted\n\
208             \x20 {} errors, {} warnings\n\
209             \x20 {} passed, {} with issues",
210            report.summary.total_commits,
211            report.summary.error_count,
212            report.summary.warning_count,
213            report.summary.passing_commits,
214            report.summary.failing_commits,
215        );
216
217        Ok(())
218    }
219}
220
221/// Returns whether any issues have Error or Warning severity.
222fn has_errors_or_warnings(issues: &[crate::data::check::CommitIssue]) -> bool {
223    use crate::data::check::IssueSeverity;
224    issues
225        .iter()
226        .any(|i| matches!(i.severity, IssueSeverity::Error | IssueSeverity::Warning))
227}
228
229/// Builds a single-commit [`CheckReport`] by linting `message` directly —
230/// no git, no repository. Used by `--stdin` and the MCP `message` input.
231fn lint_report_for_message(
232    message: &str,
233    rules: &crate::data::context::CommitRules,
234    valid_scopes: &[crate::data::context::ScopeDefinition],
235) -> CheckReport {
236    let issues = crate::git::lint_message(message, rules, valid_scopes);
237    let passes = crate::git::lint_passes(&issues);
238    let result = CommitCheckResult {
239        hash: "-".to_string(),
240        message: message.lines().next().unwrap_or("").to_string(),
241        issues,
242        suggestion: None,
243        passes,
244        summary: None,
245    };
246    CheckReport::new(vec![result])
247}
248
249/// Builds a [`CheckReport`] by linting every non-merge commit in `range`.
250/// Merge commits are already excluded by
251/// [`crate::git::GitRepository::get_commits_in_range`] — no additional
252/// filtering needed here. An empty range yields an empty (clean) report.
253fn lint_report_for_range(
254    repo_root: &Path,
255    range: &str,
256    rules: &crate::data::context::CommitRules,
257    valid_scopes: &[crate::data::context::ScopeDefinition],
258) -> Result<CheckReport> {
259    let repo = crate::git::GitRepository::open_at(repo_root)
260        .context("Failed to open git repository at the given path")?;
261    let commits = repo.get_commits_in_range(range)?;
262
263    let results = commits
264        .iter()
265        .map(|commit| {
266            let issues = crate::git::lint_message(&commit.original_message, rules, valid_scopes);
267            let passes = crate::git::lint_passes(&issues);
268            CommitCheckResult {
269                hash: commit.hash.clone(),
270                message: commit
271                    .original_message
272                    .lines()
273                    .next()
274                    .unwrap_or("")
275                    .to_string(),
276                issues,
277                suggestion: None,
278                passes,
279                summary: None,
280            }
281        })
282        .collect();
283
284    Ok(CheckReport::new(results))
285}
286
287/// Structured output from [`run_lint`] for programmatic consumers (MCP).
288#[derive(Debug, Clone)]
289pub struct LintOutcome {
290    /// YAML serialisation of the full [`CheckReport`].
291    pub report_yaml: String,
292    /// `true` when any commit has an error-severity issue.
293    pub has_errors: bool,
294    /// `true` when any commit has a warning-severity issue.
295    pub has_warnings: bool,
296    /// Total commits linted.
297    pub total_commits: usize,
298    /// Strict mode setting that produced `exit_code`.
299    pub strict: bool,
300    /// Exit code the CLI would use, honouring `strict`.
301    pub exit_code: i32,
302}
303
304/// What to lint.
305///
306/// Either a commit range (resolved the same way as the CLI's positional
307/// argument, defaulting to commits ahead of the base branch when `None`) or
308/// a single literal message (the `--stdin` equivalent).
309pub enum LintInput {
310    /// Lint every non-merge commit in this range.
311    Range(Option<String>),
312    /// Lint this message directly, bypassing git entirely.
313    Message(String),
314}
315
316/// Non-interactive core for `omni-dev git commit message lint`.
317///
318/// Shared by the CLI and the MCP `git_lint_commits` tool. No AI client
319/// involved — deterministic and synchronous under the hood, `async` only
320/// for call-site parity with [`super::run_check`].
321///
322/// `repo_path` selects the repository (`None` defaults to the current
323/// working directory); `context_dir` overrides the `.omni-dev/` resolution
324/// chain for both `scopes.yaml` and `commit-rules.yaml`.
325pub async fn run_lint(
326    input: LintInput,
327    repo_path: Option<&Path>,
328    context_dir: Option<&Path>,
329    strict: bool,
330) -> Result<LintOutcome> {
331    let repo_root = match repo_path {
332        Some(p) => p.to_path_buf(),
333        None => std::env::current_dir().context("Failed to determine current directory")?,
334    };
335    let repo_root = repo_root.as_path();
336
337    let ctx_dir = crate::claude::context::resolve_context_dir_at(context_dir, repo_root);
338    let valid_scopes = crate::claude::context::load_project_scopes(&ctx_dir, repo_root);
339    let rules = crate::claude::context::load_commit_rules(&ctx_dir);
340
341    let report = match input {
342        LintInput::Message(message) => lint_report_for_message(&message, &rules, &valid_scopes),
343        LintInput::Range(range) => {
344            let range = if let Some(r) = range {
345                r
346            } else {
347                let repo = crate::git::GitRepository::open_at(repo_root)
348                    .context("Failed to open git repository at the given path")?;
349                super::default_commit_range(&repo)?
350            };
351            lint_report_for_range(repo_root, &range, &rules, &valid_scopes)?
352        }
353    };
354
355    let report_yaml = crate::data::to_yaml(&report).context("Failed to serialise CheckReport")?;
356    let has_errors = report.has_errors();
357    let has_warnings = report.has_warnings();
358    let exit_code = report.exit_code(strict);
359    let total_commits = report.commits.len();
360
361    Ok(LintOutcome {
362        report_yaml,
363        has_errors,
364        has_warnings,
365        total_commits,
366        strict,
367        exit_code,
368    })
369}
370
371#[cfg(test)]
372#[allow(clippy::unwrap_used, clippy::expect_used)]
373mod tests {
374    use super::*;
375
376    fn init_test_repo() -> tempfile::TempDir {
377        let tmp_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tmp");
378        std::fs::create_dir_all(&tmp_root).unwrap();
379        let temp_dir = tempfile::tempdir_in(&tmp_root).unwrap();
380        for args in [
381            vec!["init"],
382            vec!["checkout", "-b", "main"],
383            vec!["commit", "--allow-empty", "-m", "feat(cli): first commit"],
384        ] {
385            let output = std::process::Command::new("git")
386                .current_dir(temp_dir.path())
387                .args([
388                    "-c",
389                    "user.email=test@example.com",
390                    "-c",
391                    "user.name=Test",
392                    "-c",
393                    "commit.gpgsign=false",
394                ])
395                .args(&args)
396                .output()
397                .unwrap();
398            assert!(output.status.success(), "git {args:?} failed");
399        }
400        temp_dir
401    }
402
403    fn commit(dir: &Path, message: &str) {
404        let output = std::process::Command::new("git")
405            .current_dir(dir)
406            .args([
407                "-c",
408                "user.email=test@example.com",
409                "-c",
410                "user.name=Test",
411                "-c",
412                "commit.gpgsign=false",
413                "commit",
414                "--allow-empty",
415                "-m",
416                message,
417            ])
418            .output()
419            .unwrap();
420        assert!(output.status.success(), "commit failed: {message}");
421    }
422
423    fn merge_dummy_branch(dir: &Path) {
424        let sh = |args: &[&str]| {
425            let output = std::process::Command::new("git")
426                .current_dir(dir)
427                .args([
428                    "-c",
429                    "user.email=test@example.com",
430                    "-c",
431                    "user.name=Test",
432                    "-c",
433                    "commit.gpgsign=false",
434                ])
435                .args(args)
436                .output()
437                .unwrap();
438            assert!(output.status.success(), "git {args:?} failed");
439        };
440        sh(&["checkout", "-b", "side"]);
441        sh(&["commit", "--allow-empty", "-m", "feat(cli): side change"]);
442        sh(&["checkout", "main"]);
443        sh(&["commit", "--allow-empty", "-m", "feat(cli): main change"]);
444        sh(&["merge", "side", "--no-ff", "-m", "Merge branch 'side'"]);
445    }
446
447    #[tokio::test]
448    async fn run_lint_message_flags_known_issues() {
449        let outcome = run_lint(
450            LintInput::Message("feature(bogus): Bad Message.".to_string()),
451            None,
452            None,
453            false,
454        )
455        .await
456        .unwrap();
457        assert!(outcome.has_errors);
458        assert_eq!(outcome.exit_code, 1);
459        assert_eq!(outcome.total_commits, 1);
460        assert!(outcome.report_yaml.contains("commits:"));
461    }
462
463    #[tokio::test]
464    async fn run_lint_message_clean_passes() {
465        let outcome = run_lint(
466            LintInput::Message("feat(cli): add thing".to_string()),
467            None,
468            None,
469            false,
470        )
471        .await
472        .unwrap();
473        assert!(!outcome.has_errors);
474        assert_eq!(outcome.exit_code, 0);
475    }
476
477    #[tokio::test]
478    async fn run_lint_range_merge_commit_excluded() {
479        let temp_dir = init_test_repo();
480        merge_dummy_branch(temp_dir.path());
481
482        let outcome = run_lint(
483            LintInput::Range(Some("HEAD~2..HEAD".to_string())),
484            Some(temp_dir.path()),
485            None,
486            false,
487        )
488        .await
489        .unwrap();
490
491        // HEAD~2..HEAD spans the merge commit plus "main change"; the merge
492        // commit itself must not appear.
493        assert!(!outcome.report_yaml.contains("Merge branch"));
494    }
495
496    #[tokio::test]
497    async fn run_lint_range_empty_is_clean_not_an_error() {
498        let temp_dir = init_test_repo();
499        let outcome = run_lint(
500            LintInput::Range(Some("HEAD..HEAD".to_string())),
501            Some(temp_dir.path()),
502            None,
503            false,
504        )
505        .await
506        .unwrap();
507        assert_eq!(outcome.total_commits, 0);
508        assert!(!outcome.has_errors);
509        assert_eq!(outcome.exit_code, 0);
510    }
511
512    #[tokio::test]
513    async fn run_lint_range_strict_promotes_warnings() {
514        let temp_dir = init_test_repo();
515        commit(
516            temp_dir.path(),
517            "feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
518        );
519        let outcome = run_lint(
520            LintInput::Range(Some("HEAD~1..HEAD".to_string())),
521            Some(temp_dir.path()),
522            None,
523            true,
524        )
525        .await
526        .unwrap();
527        assert!(!outcome.has_errors);
528        assert!(outcome.has_warnings);
529        assert_eq!(outcome.exit_code, 2);
530    }
531
532    #[tokio::test]
533    async fn run_lint_range_and_message_agree_on_same_content() {
534        let temp_dir = init_test_repo();
535        commit(temp_dir.path(), "feature(bogus): Bad Message.");
536
537        let range_outcome = run_lint(
538            LintInput::Range(Some("HEAD~1..HEAD".to_string())),
539            Some(temp_dir.path()),
540            None,
541            false,
542        )
543        .await
544        .unwrap();
545        let message_outcome = run_lint(
546            LintInput::Message("feature(bogus): Bad Message.".to_string()),
547            None,
548            None,
549            false,
550        )
551        .await
552        .unwrap();
553
554        assert_eq!(range_outcome.has_errors, message_outcome.has_errors);
555        assert_eq!(range_outcome.exit_code, message_outcome.exit_code);
556    }
557
558    #[test]
559    fn cli_execute_json_output_matches_check_report_shape() {
560        let temp_dir = init_test_repo();
561        commit(temp_dir.path(), "feat(cli): second commit");
562        let cmd = LintCommand {
563            commit_range: Some("HEAD~1..HEAD".to_string()),
564            context_dir: None,
565            guidelines: None,
566            output: OutputFormat::Json,
567            strict: false,
568            quiet: true,
569            verbose: false,
570            show_passing: true,
571            stdin: false,
572        };
573        let rt = tokio::runtime::Runtime::new().unwrap();
574        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
575        assert!(result.is_ok());
576    }
577
578    #[test]
579    fn cli_execute_yaml_output_matches_check_report_shape() {
580        let temp_dir = init_test_repo();
581        commit(temp_dir.path(), "feat(cli): second commit");
582        let cmd = LintCommand {
583            commit_range: Some("HEAD~1..HEAD".to_string()),
584            context_dir: None,
585            guidelines: None,
586            output: OutputFormat::Yaml,
587            strict: false,
588            quiet: true,
589            verbose: false,
590            show_passing: true,
591            stdin: false,
592        };
593        let rt = tokio::runtime::Runtime::new().unwrap();
594        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
595        assert!(result.is_ok());
596    }
597
598    /// `commit_range: None` drives `resolve_range` through its
599    /// `default_commit_range` fallback rather than the literal-range
600    /// shortcut every other test in this module uses.
601    #[test]
602    fn cli_execute_range_none_uses_default_commit_range() {
603        let temp_dir = init_test_repo();
604        let cmd = LintCommand {
605            commit_range: None,
606            context_dir: None,
607            guidelines: None,
608            output: OutputFormat::Json,
609            strict: false,
610            quiet: true,
611            verbose: false,
612            show_passing: true,
613            stdin: false,
614        };
615        let rt = tokio::runtime::Runtime::new().unwrap();
616        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
617        assert!(result.is_ok(), "expected clean exit, got: {result:?}");
618    }
619
620    /// `LintInput::Range(None)` is `run_lint`'s own default-range fallback —
621    /// a separate code path from `LintCommand::resolve_range` above, since
622    /// `run_lint` is also reachable from the MCP tool with no CLI in front
623    /// of it.
624    #[tokio::test]
625    async fn run_lint_range_none_uses_default_commit_range() {
626        let temp_dir = init_test_repo();
627        let outcome = run_lint(LintInput::Range(None), Some(temp_dir.path()), None, false)
628            .await
629            .unwrap();
630        assert_eq!(outcome.total_commits, 0);
631    }
632
633    #[test]
634    fn cli_execute_verbose_config_status_empty_scopes_rules_not_found() {
635        let temp_dir = init_test_repo();
636        // An explicit (nonexistent) context_dir bypasses walk-up discovery —
637        // without it, resolution would walk up from `temp_dir` (created
638        // under this crate's own `tmp/`) and find *this repo's* real
639        // `.omni-dev/scopes.yaml`, defeating the "nothing configured" case
640        // this test means to exercise.
641        let context_dir = temp_dir.path().join(".omni-dev");
642        let cmd = LintCommand {
643            commit_range: Some("HEAD..HEAD".to_string()),
644            context_dir: Some(context_dir),
645            guidelines: None,
646            output: OutputFormat::Text,
647            strict: false,
648            quiet: false,
649            verbose: true,
650            show_passing: false,
651            stdin: false,
652        };
653        let rt = tokio::runtime::Runtime::new().unwrap();
654        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
655        assert!(result.is_ok());
656    }
657
658    #[test]
659    fn cli_execute_verbose_config_status_scopes_and_rules_found() {
660        let temp_dir = init_test_repo();
661        let context_dir = temp_dir.path().join(".omni-dev");
662        std::fs::create_dir_all(&context_dir).unwrap();
663        std::fs::write(
664            context_dir.join("scopes.yaml"),
665            "scopes:\n  - name: custom\n    description: Custom scope\n    examples: []\n    file_patterns: []\n",
666        )
667        .unwrap();
668        std::fs::write(
669            context_dir.join("commit-rules.yaml"),
670            "subject_max_len: 72\ntypes:\n  - feat\nrequire_scope: false\nforbidden_footers: []\n",
671        )
672        .unwrap();
673
674        let cmd = LintCommand {
675            commit_range: Some("HEAD..HEAD".to_string()),
676            context_dir: Some(context_dir),
677            guidelines: None,
678            output: OutputFormat::Text,
679            strict: false,
680            quiet: false,
681            verbose: true,
682            show_passing: false,
683            stdin: false,
684        };
685        let rt = tokio::runtime::Runtime::new().unwrap();
686        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
687        assert!(result.is_ok());
688    }
689
690    #[test]
691    fn cli_execute_verbose_config_status_ecosystem_scopes_no_file() {
692        let temp_dir = init_test_repo();
693        std::fs::write(temp_dir.path().join("Cargo.toml"), "[package]\n").unwrap();
694        // See the comment in the "empty scopes" test above: an explicit
695        // (nonexistent) context_dir bypasses walk-up discovery, so the only
696        // non-empty scopes are the `Cargo.toml`-derived ecosystem defaults
697        // this test targets.
698        let context_dir = temp_dir.path().join(".omni-dev");
699
700        let cmd = LintCommand {
701            commit_range: Some("HEAD..HEAD".to_string()),
702            context_dir: Some(context_dir),
703            guidelines: None,
704            output: OutputFormat::Text,
705            strict: false,
706            quiet: false,
707            verbose: true,
708            show_passing: false,
709            stdin: false,
710        };
711        let rt = tokio::runtime::Runtime::new().unwrap();
712        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
713        assert!(result.is_ok());
714    }
715
716    /// `passes` (and so the `show_passing: false` default's skip-continue at
717    /// the top of the per-commit loop) reflects only `Error`-severity
718    /// issues, matching `exit_code`'s own error-only gate — a
719    /// `Warning`-only commit still counts as passing and is hidden here
720    /// just like a clean one. `output_text_report`'s per-issue print lines
721    /// are covered separately below (`quiet_mode_filters_...`), since a
722    /// commit that fails the `show_passing` check can't reach them without
723    /// an `Error`-severity issue, which would drive `execute()`'s
724    /// `std::process::exit` and abort this whole test binary.
725    #[test]
726    fn output_text_report_show_passing_false_hides_warning_only_commits() {
727        let temp_dir = init_test_repo();
728        commit(temp_dir.path(), "feat(cli): clean second commit");
729        commit(
730            temp_dir.path(),
731            "feat(cli): add thing\n\nCo-Authored-By: Bot <bot@example.com>",
732        );
733        let cmd = LintCommand {
734            commit_range: Some("HEAD~2..HEAD".to_string()),
735            context_dir: None,
736            guidelines: None,
737            output: OutputFormat::Text,
738            strict: false,
739            quiet: false,
740            verbose: false,
741            show_passing: false,
742            stdin: false,
743        };
744        let rt = tokio::runtime::Runtime::new().unwrap();
745        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
746        assert!(result.is_ok(), "expected clean exit, got: {result:?}");
747    }
748
749    /// `quiet` filters both a clean commit (`show_passing: true` keeps it
750    /// past the first check, but it has no errors/warnings to show) and,
751    /// within a surviving commit's own issue list, its `Info`-severity
752    /// entries — two distinct skip branches in `output_text_report`.
753    #[test]
754    fn output_text_report_quiet_mode_filters_clean_and_info_issues() {
755        let temp_dir = init_test_repo();
756        commit(temp_dir.path(), "feat(cli): clean thing");
757        commit(
758            temp_dir.path(),
759            "feat(cli): Add thing.\n\nCo-Authored-By: Bot <bot@example.com>",
760        );
761        let cmd = LintCommand {
762            commit_range: Some("HEAD~2..HEAD".to_string()),
763            context_dir: None,
764            guidelines: None,
765            output: OutputFormat::Text,
766            strict: false,
767            quiet: true,
768            verbose: false,
769            show_passing: true,
770            stdin: false,
771        };
772        let rt = tokio::runtime::Runtime::new().unwrap();
773        let result = rt.block_on(cmd.execute(Some(temp_dir.path())));
774        assert!(result.is_ok(), "expected clean exit, got: {result:?}");
775    }
776}