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