diff --git a/README.md b/README.md index 816a6c4..f05e6e5 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,22 @@ misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead: commit-guard --range origin/main..HEAD --allow-empty ``` +### Quiet mode + +Use `--quiet` (or `-q`) to suppress output for commits that have nothing to +report — only commits with errors or warnings are printed. On a long range +this leaves exactly the offending commits in the output; a fully compliant +range prints nothing and exits 0: + +```bash +commit-guard --range origin/main..HEAD --quiet +``` + +Quiet mode applies to single-commit and range mode, in both text and +`--output jsonl` formats. Exit codes are unchanged, so it composes with CI +gating. `--output-file` is not affected — the file always receives the +complete record stream. + ### Machine-readable output Use `--output jsonl` to emit one JSON line per commit to stdout instead of the diff --git a/docs/index.html b/docs/index.html index 4df4546..4153732 100644 --- a/docs/index.html +++ b/docs/index.html @@ -551,6 +551,15 @@

Output #

readable logs and structured results for downstream steps:

commit-guard --range origin/main..HEAD --output-file results.jsonl
+

+ Use --quiet (or -q) to suppress output for + commits that have nothing to report — only commits with errors or + warnings are printed, in both text and JSONL formats. A fully + compliant range prints nothing and exits 0. Exit codes are unchanged, + and --output-file always receives the complete record + stream: +

+
commit-guard --range origin/main..HEAD --quiet
diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 3966ae8..8199f65 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -146,6 +146,10 @@ def info(self, msg, check=None): def ok(self): return not any(lvl == Level.ERROR for _, lvl, _ in self.errors) + @property + def noteworthy(self): + return any(lvl in (Level.ERROR, Level.WARN) for _, lvl, _ in self.errors) + def _ensure_nltk_data(): _download_if_missing("taggers/averaged_perceptron_tagger_eng") @@ -565,6 +569,7 @@ class Args: subject_pattern: re.Pattern | None output: OutputFormat output_file: Path | None + quiet: bool def _resolve_enabled(args, config, parser): @@ -662,7 +667,7 @@ def _parse_checks(parser, value): parser.error(str(e)) -def _parse_args(): # noqa: PLR0915 Too many statements (59 > 50) +def _parse_args(): # noqa: PLR0915 Too many statements (60 > 50) checks_list = ",".join(sorted(Check)) parser = ArgumentParser(description="conventional commit checker") parser.add_argument("rev", nargs="?", default=None) @@ -761,6 +766,13 @@ def _parse_args(): # noqa: PLR0915 Too many statements (59 > 50) metavar="FILE", help="write JSONL results to FILE (text still goes to stdout)", ) + parser.add_argument( + "-q", + "--quiet", + action="store_true", + default=False, + help="suppress output for commits without errors or warnings", + ) args = parser.parse_args() config = _load_config() enabled = _resolve_enabled(args, config, parser) @@ -823,6 +835,7 @@ def _parse_args(): # noqa: PLR0915 Too many statements (59 > 50) subject_pattern=subject_pattern, output=OutputFormat(args.output), output_file=args.output_file, + quiet=args.quiet, ) @@ -838,7 +851,9 @@ def _jsonl_record(result, sha, subject): } -def _report_jsonl(result, sha, subject): +def _report_jsonl(result, sha, subject, *, quiet=False): + if quiet and not result.noteworthy: + return 0 print(json.dumps(_jsonl_record(result, sha, subject))) return 0 if result.ok else 1 @@ -847,7 +862,9 @@ def _write_jsonl_record(result, sha, subject, file): file.write(json.dumps(_jsonl_record(result, sha, subject)) + "\n") -def _report_text(result): +def _report_text(result, *, quiet=False): + if quiet and not result.noteworthy: + return 0 color = sys.stdout.isatty() for check, level, msg in result.errors: prefix = f"[{check}] " if check else "" @@ -892,6 +909,14 @@ def _run_checks(args, rev, message, result): check_signature(rev, result) +def _report_commit(args, result, sha, subject): + if args.output == OutputFormat.JSONL: + return _report_jsonl(result, sha, subject, quiet=args.quiet) + if args.rev_range and (not args.quiet or result.noteworthy): + print(f"{sha[:7]} {subject}") + return _report_text(result, quiet=args.quiet) + + def main(): args = _parse_args() @@ -911,13 +936,8 @@ def main(): subject = message.split("\n")[0] result = Result() _run_checks(args, rev, message, result) - if args.output == OutputFormat.JSONL: - if _report_jsonl(result, rev, subject) != 0: - failed = True - else: - print(f"{rev[:7]} {subject}") - if _report_text(result) != 0: - failed = True + if _report_commit(args, result, rev, subject) != 0: + failed = True if out_file: _write_jsonl_record(result, rev, subject, out_file) return 1 if failed else 0 @@ -927,6 +947,4 @@ def main(): _run_checks(args, args.rev, args.message, result) if out_file: _write_jsonl_record(result, args.rev, subject, out_file) - if args.output == OutputFormat.JSONL: - return _report_jsonl(result, args.rev, subject) - return _report_text(result) + return _report_commit(args, result, args.rev, subject) diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index 4435964..bec990a 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -12,6 +12,7 @@ GIT_TIMEOUT, MAX_SUBJECT_LEN, TYPES, + OutputFormat, Result, _download_if_missing, _ensure_nltk_data, @@ -29,6 +30,7 @@ _parse_checks, _parse_config_checks, _parse_noreply_username, + _report_commit, _report_jsonl, _report_text, _resolve_max_subject_length, @@ -1493,6 +1495,33 @@ def test_ok_no_ansi_when_not_tty(self, capsys): _report_text(r) assert "\033[" not in capsys.readouterr().out + def test_quiet_suppresses_clean_result(self, capsys): + r = Result() + ret = _report_text(r, quiet=True) + assert ret == 0 + assert capsys.readouterr().out == "" + + def test_quiet_suppresses_info_only_result(self, capsys): + r = Result() + r.info("signature type: SSH", check="signature") + ret = _report_text(r, quiet=True) + assert ret == 0 + assert capsys.readouterr().out == "" + + def test_quiet_shows_warning(self, capsys): + r = Result() + r.warn("heads up") + ret = _report_text(r, quiet=True) + assert ret == 0 + assert "heads up" in capsys.readouterr().out + + def test_quiet_shows_error(self, capsys): + r = Result() + r.error("something broke") + ret = _report_text(r, quiet=True) + assert ret == 1 + assert "something broke" in capsys.readouterr().out + class TestReportJsonl: def test_ok_commit(self, capsys): @@ -1522,6 +1551,19 @@ def test_failed_commit(self, capsys): "message": "missing body", } + def test_quiet_suppresses_ok_commit(self, capsys): + r = Result() + ret = _report_jsonl(r, "abc1234567890", "fix: add thing", quiet=True) + assert ret == 0 + assert capsys.readouterr().out == "" + + def test_quiet_shows_failed_commit(self, capsys): + r = Result() + r.error("missing body", check="body") + ret = _report_jsonl(r, "abc1234567890", "fix: add thing", quiet=True) + assert ret == 1 + assert json.loads(capsys.readouterr().out)["ok"] is False + def test_null_sha(self, capsys): r = Result() ret = _report_jsonl(r, None, "fix: add thing") @@ -1545,6 +1587,40 @@ def test_output_is_single_line(self, capsys): assert out.count("\n") == 1 +class TestReportCommit: + def test_range_text_prints_sha_header(self, capsys): + args = Namespace(output=OutputFormat.TEXT, rev_range="a..b", quiet=False) + ret = _report_commit(args, Result(), "abc1234567890", "fix: add thing") + assert ret == 0 + out = capsys.readouterr().out + assert out.startswith("abc1234 fix: add thing") + assert "all checks passed" in out + + def test_single_commit_text_has_no_header(self, capsys): + args = Namespace(output=OutputFormat.TEXT, rev_range=None, quiet=False) + ret = _report_commit(args, Result(), None, "fix: add thing") + assert ret == 0 + out = capsys.readouterr().out + assert "fix: add thing" not in out + assert "all checks passed" in out + + def test_jsonl_dispatch(self, capsys): + args = Namespace(output=OutputFormat.JSONL, rev_range="a..b", quiet=False) + r = Result() + r.error("missing body", check="body") + ret = _report_commit(args, r, "abc1234567890", "fix: add thing") + assert ret == 1 + data = json.loads(capsys.readouterr().out) + assert data["sha"] == "abc1234567890" + assert data["ok"] is False + + def test_quiet_range_suppresses_header_for_clean_commit(self, capsys): + args = Namespace(output=OutputFormat.TEXT, rev_range="a..b", quiet=True) + ret = _report_commit(args, Result(), "abc1234567890", "fix: add thing") + assert ret == 0 + assert capsys.readouterr().out == "" + + _VALID_MSG = "fix: add thing\n\nbody text\n\nSigned-off-by: A User " @@ -2452,3 +2528,141 @@ def test_failed_commit_written_to_file(self, tmp_path): assert main() == 1 data = json.loads(out_file.read_text()) assert data["ok"] is False + + +class TestQuiet: + def test_range_text_shows_only_failing_commits(self, capsys): + messages = {"aaa1234": _VALID_MSG, "bbb5678": "bad message"} + with ( + patch( + "sys.argv", + [ + "cg", + "--range", + "HEAD~2..HEAD", + "--disable", + "signature,body,signed-off,imperative", + "--quiet", + ], + ), + patch( + "git_commit_guard._get_range_revs", + return_value=["aaa1234", "bbb5678"], + ), + patch( + "git_commit_guard._get_message", + side_effect=lambda rev: messages[rev], + ), + ): + assert main() == 1 + out = capsys.readouterr().out + assert "bbb5678" in out + assert "aaa1234" not in out + + def test_range_text_all_pass_prints_nothing(self, capsys): + with ( + patch( + "sys.argv", + ["cg", "--range", "HEAD~2..HEAD", "--disable", "signature", "-q"], + ), + patch( + "git_commit_guard._get_range_revs", + return_value=["aaa1234", "bbb5678"], + ), + patch("git_commit_guard._get_message", return_value=_VALID_MSG), + ): + assert main() == 0 + assert capsys.readouterr().out == "" + + def test_single_commit_pass_prints_nothing(self, capsys, tmp_path): + msg_file = tmp_path / "msg" + msg_file.write_text(_VALID_MSG) + with patch( + "sys.argv", + [ + "cg", + "--message-file", + str(msg_file), + "--disable", + "signature,imperative", + "--quiet", + ], + ): + assert main() == 0 + assert capsys.readouterr().out == "" + + def test_single_commit_failure_still_printed(self, capsys, tmp_path): + msg_file = tmp_path / "msg" + msg_file.write_text("fix: add thing") + with patch( + "sys.argv", + [ + "cg", + "--message-file", + str(msg_file), + "--disable", + "signature,imperative", + "--quiet", + ], + ): + assert main() == 1 + assert "body" in capsys.readouterr().out + + def test_range_jsonl_emits_only_failing_records(self, capsys): + messages = {"aaa1234": _VALID_MSG, "bbb5678": "bad message"} + with ( + patch( + "sys.argv", + [ + "cg", + "--range", + "HEAD~2..HEAD", + "--disable", + "signature,body,signed-off,imperative", + "--output", + "jsonl", + "--quiet", + ], + ), + patch( + "git_commit_guard._get_range_revs", + return_value=["aaa1234", "bbb5678"], + ), + patch( + "git_commit_guard._get_message", + side_effect=lambda rev: messages[rev], + ), + ): + assert main() == 1 + lines = capsys.readouterr().out.strip().splitlines() + assert len(lines) == 1 + data = json.loads(lines[0]) + assert data["sha"] == "bbb5678" + assert data["ok"] is False + + def test_output_file_still_receives_all_records(self, capsys, tmp_path): + out_file = tmp_path / "results.jsonl" + with ( + patch( + "sys.argv", + [ + "cg", + "--range", + "HEAD~2..HEAD", + "--disable", + "signature", + "--quiet", + "--output-file", + str(out_file), + ], + ), + patch( + "git_commit_guard._get_range_revs", + return_value=["aaa1234", "bbb5678"], + ), + patch("git_commit_guard._get_message", return_value=_VALID_MSG), + ): + assert main() == 0 + assert capsys.readouterr().out == "" + lines = out_file.read_text().strip().splitlines() + assert len(lines) == 2