diff --git a/README.md b/README.md index 2b4fbc4..1852667 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,19 @@ commit-guard --range v1.0..v2.0 commit-guard --range origin/main..HEAD --enable subject,imperative ``` +Merge commits are excluded by default. Use `--include-merges` to check them: + +```bash +commit-guard --range origin/main..HEAD --include-merges +``` + +An empty range (no commits) exits non-zero by default — this catches +misconfigured range specs in CI. Use `--allow-empty` to exit 0 instead: + +```bash +commit-guard --range origin/main..HEAD --allow-empty +``` + ### pre-commit Add to your `.pre-commit-config.yaml`: diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 4ea993c..4129a5f 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -224,10 +224,14 @@ def _get_message(rev): sys.exit(f"git error: {stderr}") -def _get_range_revs(rev_range): +def _get_range_revs(rev_range, *, include_merges=False): + cmd = ["git", "log", "--format=%H"] + if not include_merges: + cmd.append("--no-merges") + cmd.append(rev_range) try: output = subprocess.check_output( # noqa: S603 - ["git", "log", "--format=%H", rev_range], # noqa: S607 + cmd, text=True, stderr=subprocess.PIPE, timeout=GIT_TIMEOUT, @@ -247,6 +251,8 @@ class Args: allowed_types: frozenset max_subject_length: int rev_range: str | None + allow_empty: bool + include_merges: bool def _resolve_enabled(args, config, parser): @@ -350,6 +356,18 @@ def _parse_args(): metavar="REF..REF", help="check all commits in the given revision range", ) + parser.add_argument( + "--allow-empty", + action="store_true", + default=False, + help="exit 0 when --range yields no commits (default: exit 1)", + ) + parser.add_argument( + "--include-merges", + action="store_true", + default=False, + help="include merge commits when checking a range (default: excluded)", + ) args = parser.parse_args() config = _load_config() enabled = _resolve_enabled(args, config, parser) @@ -357,6 +375,11 @@ def _parse_args(): allowed_types = _resolve_types(args, config) max_subject_length = _resolve_max_subject_length(args, config) + if args.allow_empty and not args.rev_range: + parser.error("--allow-empty requires --range") + if args.include_merges and not args.rev_range: + parser.error("--include-merges requires --range") + 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") @@ -384,6 +407,8 @@ def _parse_args(): allowed_types=allowed_types, max_subject_length=max_subject_length, rev_range=args.rev_range, + allow_empty=args.allow_empty, + include_merges=args.include_merges, ) @@ -430,10 +455,10 @@ def main(): _ensure_nltk_data() if args.rev_range: - revs = _get_range_revs(args.rev_range) + revs = _get_range_revs(args.rev_range, include_merges=args.include_merges) if not revs: sys.stderr.write("no commits in range\n") - return 0 + return 0 if args.allow_empty else 1 failed = False for rev in revs: message = _strip_comments(_get_message(rev)) diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index d432e99..9b62e41 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -894,14 +894,58 @@ def test_range_all_fail_returns_one(self): ): assert main() == 1 - def test_range_empty_returns_zero(self, capsys): + def test_range_empty_returns_one(self, capsys): with ( patch("sys.argv", ["cg", "--range", "origin/main..HEAD"]), patch("git_commit_guard._get_range_revs", return_value=[]), + ): + assert main() == 1 + assert "no commits in range" in capsys.readouterr().err + + def test_range_empty_with_allow_empty_returns_zero(self, capsys): + with ( + patch("sys.argv", ["cg", "--range", "origin/main..HEAD", "--allow-empty"]), + patch("git_commit_guard._get_range_revs", return_value=[]), ): assert main() == 0 assert "no commits in range" in capsys.readouterr().err + def test_allow_empty_without_range_exits(self): + with ( + patch("sys.argv", ["cg", "--allow-empty"]), + pytest.raises(SystemExit), + ): + main() + + def test_include_merges_without_range_exits(self): + with ( + patch("sys.argv", ["cg", "--include-merges"]), + pytest.raises(SystemExit), + ): + main() + + def test_range_include_merges_flag(self): + with ( + patch( + "sys.argv", + [ + "cg", + "--range", + "origin/main..HEAD", + "--include-merges", + "--disable", + "signature", + ], + ), + patch( + "git_commit_guard._get_range_revs", + return_value=["abc1234"], + ) as mock, + patch("git_commit_guard._get_message", return_value=_VALID_MSG), + ): + main() + mock.assert_called_once_with("origin/main..HEAD", include_merges=True) + def test_range_conflicts_with_rev(self): with ( patch("sys.argv", ["cg", "abc123", "--range", "origin/main..HEAD"]), @@ -930,6 +974,16 @@ def test_returns_shas(self): ): assert _get_range_revs("origin/main..HEAD") == ["abc1234", "def5678"] + def test_excludes_merges_by_default(self): + with patch("git_commit_guard.subprocess.check_output", return_value="") as mock: + _get_range_revs("origin/main..HEAD") + assert "--no-merges" in mock.call_args[0][0] + + def test_includes_merges_when_requested(self): + with patch("git_commit_guard.subprocess.check_output", return_value="") as mock: + _get_range_revs("origin/main..HEAD", include_merges=True) + assert "--no-merges" not in mock.call_args[0][0] + 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") == []