From 4e33bb5aaa3c21de888820c02b42130584325104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nerijus=20Bend=C5=BEi=C5=ABnas?= Date: Fri, 24 Apr 2026 00:19:59 +0300 Subject: [PATCH] feat: add --range flag for checking multiple commits in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without this, checking a range of commits in CI required a shell loop with manual exit code aggregation. --range origin/main..HEAD runs all checks across the range and exits non-zero if any commit fails. Signed-off-by: Nerijus Bendžiūnas --- README.md | 20 +++--- src/git_commit_guard/__init__.py | 69 +++++++++++++++---- tests/test_git_commit_guard.py | 111 +++++++++++++++++++++++++++++++ 3 files changed, 178 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 903bcb3..2b4fbc4 100644 --- a/README.md +++ b/README.md @@ -146,16 +146,18 @@ provided. ### Checking a range of commits +Use `--range` to check all commits in a revision range. All commits are +checked and a single non-zero exit code is returned if any fail: + ```bash -# all non-merge commits between tags -git rev-list --no-merges v1.0..v2.0 | while read -r rev; do - commit-guard "$rev" || git log -1 --oneline "$rev" -done - -# only subject checks on a PR range -git rev-list --no-merges origin/main..HEAD | while read -r rev; do - commit-guard "$rev" --enable subject,imperative -done +# check all commits in a PR +commit-guard --range origin/main..HEAD + +# check between two tags +commit-guard --range v1.0..v2.0 + +# only subject checks on a range +commit-guard --range origin/main..HEAD --enable subject,imperative ``` ### pre-commit diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index c7872bd..4ea993c 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -224,6 +224,19 @@ def _get_message(rev): sys.exit(f"git error: {stderr}") +def _get_range_revs(rev_range): + try: + output = subprocess.check_output( # noqa: S603 + ["git", "log", "--format=%H", rev_range], # noqa: S607 + text=True, + stderr=subprocess.PIPE, + timeout=GIT_TIMEOUT, + ).strip() + except subprocess.CalledProcessError as e: + sys.exit(f"git error: {e.stderr.strip()}") + return output.split("\n") if output else [] + + @dataclass class Args: rev: str | None @@ -233,6 +246,7 @@ class Args: require_scope: bool allowed_types: frozenset max_subject_length: int + rev_range: str | None def _resolve_enabled(args, config, parser): @@ -330,6 +344,12 @@ def _parse_args(): metavar="N", help=f"maximum subject line length (default: {MAX_SUBJECT_LEN})", ) + parser.add_argument( + "--range", + dest="rev_range", + metavar="REF..REF", + help="check all commits in the given revision range", + ) args = parser.parse_args() config = _load_config() enabled = _resolve_enabled(args, config, parser) @@ -337,7 +357,12 @@ def _parse_args(): allowed_types = _resolve_types(args, config) max_subject_length = _resolve_max_subject_length(args, config) - if args.message_file: + if args.rev_range: + if args.rev is not None or args.message_file: + parser.error("--range cannot be combined with rev or --message-file") + rev = None + message = "" + elif args.message_file: rev = None message = _strip_comments(args.message_file.read_text().strip()) elif args.rev: @@ -358,6 +383,7 @@ def _parse_args(): require_scope=require_scope, allowed_types=allowed_types, max_subject_length=max_subject_length, + rev_range=args.rev_range, ) @@ -371,15 +397,8 @@ def _report(result): return 0 if result.ok else 1 -def main(): - args = _parse_args() - lines = args.message.split("\n") - - if Check.IMPERATIVE in args.enabled: - _ensure_nltk_data() - - result = Result() - +def _run_checks(args, rev, message, result): + lines = message.split("\n") desc = None if Check.SUBJECT in args.enabled: desc = check_subject( @@ -399,8 +418,32 @@ def main(): if Check.BODY in args.enabled: check_body(lines, result) if Check.SIGNED_OFF in args.enabled: - check_signed_off(args.message, result) - if Check.SIGNATURE in args.enabled and args.rev: - check_signature(args.rev, result) + check_signed_off(message, result) + if Check.SIGNATURE in args.enabled and rev: + check_signature(rev, result) + + +def main(): + args = _parse_args() + if Check.IMPERATIVE in args.enabled: + _ensure_nltk_data() + + if args.rev_range: + revs = _get_range_revs(args.rev_range) + if not revs: + sys.stderr.write("no commits in range\n") + return 0 + failed = False + for rev in revs: + message = _strip_comments(_get_message(rev)) + sys.stderr.write(f"{rev[:7]} {message.split('\n')[0]}\n") + result = Result() + _run_checks(args, rev, message, result) + if _report(result) != 0: + failed = True + return 1 if failed else 0 + + result = Result() + _run_checks(args, args.rev, args.message, result) return _report(result) diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index 7842c39..d432e99 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -11,6 +11,7 @@ _download_if_missing, _ensure_nltk_data, _get_message, + _get_range_revs, _load_config, _parse_checks, _parse_config_checks, @@ -831,3 +832,113 @@ def test_types_cli_overrides_config(self, tmp_path): ), ): assert main() == 0 + + def test_range_all_pass(self): + with ( + patch( + "sys.argv", + ["cg", "--range", "origin/main..HEAD", "--disable", "signature"], + ), + patch( + "git_commit_guard._get_range_revs", + return_value=["abc1234", "def5678"], + ), + patch("git_commit_guard._get_message", return_value=_VALID_MSG), + ): + assert main() == 0 + + def test_range_one_fails(self): + messages = {"abc1234": _VALID_MSG, "def5678": "not a valid commit message"} + with ( + patch( + "sys.argv", + [ + "cg", + "--range", + "origin/main..HEAD", + "--disable", + "signature,body,signed-off,imperative", + ], + ), + patch( + "git_commit_guard._get_range_revs", + return_value=["abc1234", "def5678"], + ), + patch( + "git_commit_guard._get_message", + side_effect=lambda rev: messages[rev], + ), + ): + assert main() == 1 + + def test_range_all_fail_returns_one(self): + with ( + patch( + "sys.argv", + [ + "cg", + "--range", + "origin/main..HEAD", + "--disable", + "signature,body,signed-off,imperative", + ], + ), + patch( + "git_commit_guard._get_range_revs", + return_value=["abc1234"], + ), + patch( + "git_commit_guard._get_message", + return_value="not a valid commit message", + ), + ): + assert main() == 1 + + def test_range_empty_returns_zero(self, capsys): + with ( + patch("sys.argv", ["cg", "--range", "origin/main..HEAD"]), + patch("git_commit_guard._get_range_revs", return_value=[]), + ): + assert main() == 0 + assert "no commits in range" in capsys.readouterr().err + + def test_range_conflicts_with_rev(self): + with ( + patch("sys.argv", ["cg", "abc123", "--range", "origin/main..HEAD"]), + pytest.raises(SystemExit), + ): + main() + + def test_range_conflicts_with_message_file(self, tmp_path): + f = tmp_path / "msg" + f.write_text(_VALID_MSG) + with ( + patch( + "sys.argv", + ["cg", "--message-file", str(f), "--range", "origin/main..HEAD"], + ), + pytest.raises(SystemExit), + ): + main() + + +class TestGetRangeRevs: + def test_returns_shas(self): + with patch( + "git_commit_guard.subprocess.check_output", + return_value="abc1234\ndef5678", + ): + assert _get_range_revs("origin/main..HEAD") == ["abc1234", "def5678"] + + def test_empty_range_returns_empty_list(self): + with patch("git_commit_guard.subprocess.check_output", return_value=""): + assert _get_range_revs("origin/main..HEAD") == [] + + def test_invalid_range_exits(self): + err = subprocess.CalledProcessError(128, "git") + err.stderr = "fatal: bad revision 'bogus'" + with ( + patch("git_commit_guard.subprocess.check_output", side_effect=err), + pytest.raises(SystemExit, match="git error"), + ): + _get_range_revs("bogus")