From bd890f10214d28de2cf9d9206611b256ddb85526 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 25 Jul 2026 00:15:16 +0300 Subject: [PATCH 01/12] chore: fix SonarQube code smells (#480) --- tests/config_test.py | 4 +- tests/main_test.py | 290 ++++++++++++++++++++++++------------------- 2 files changed, 164 insertions(+), 130 deletions(-) diff --git a/tests/config_test.py b/tests/config_test.py index 3deb8af9..bcbc434a 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -308,7 +308,9 @@ def test_load_config_invalid_toml(self): f.flush() try: - with pytest.raises(Exception): # Should raise a TOML parsing error + with pytest.raises( + Exception, match="[Ee]xpected" + ): # Should raise a TOML parsing error load_config(f.name) finally: os.unlink(f.name) diff --git a/tests/main_test.py b/tests/main_test.py index dec216f7..e0a2c6b8 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -16,38 +16,38 @@ class TestMain: @pytest.mark.benchmark - def test_help(self, capfd): - sys.argv = [CMD, "--help"] + def test_help(self, capfd, monkeypatch): + monkeypatch.setattr("sys.argv", [CMD, "--help"]) with pytest.raises(SystemExit): main() out, _ = capfd.readouterr() assert "usage:" in out @pytest.mark.benchmark - def test_version(self): + def test_version(self, monkeypatch): # argparse defines --version - sys.argv = [CMD, "--version"] + monkeypatch.setattr("sys.argv", [CMD, "--version"]) with pytest.raises(SystemExit): main() @pytest.mark.benchmark - def test_no_args_shows_help(self, capfd): + def test_no_args_shows_help(self, capfd, monkeypatch): """When no arguments are provided, should show help and exit 0.""" - sys.argv = [CMD] + monkeypatch.setattr("sys.argv", [CMD]) assert main() == 0 @pytest.mark.benchmark - def test_message_validation_with_valid_commit(self, mocker): + def test_message_validation_with_valid_commit(self, mocker, monkeypatch): """Test that a valid commit message passes validation.""" # Mock stdin to provide a valid commit message mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: add new feature\n") - sys.argv = [CMD, "-m"] + monkeypatch.setattr("sys.argv", [CMD, "-m"]) assert main() == 0 @pytest.mark.benchmark - def test_message_validation_with_invalid_commit(self, mocker): + def test_message_validation_with_invalid_commit(self, mocker, monkeypatch): """Test that an invalid commit message fails validation.""" # Mock stdin to provide an invalid commit message mocker.patch("sys.stdin.isatty", return_value=False) @@ -56,24 +56,24 @@ def test_message_validation_with_invalid_commit(self, mocker): # Mock git author to ensure it's not in any ignore list mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") - sys.argv = [CMD, "-m"] + monkeypatch.setattr("sys.argv", [CMD, "-m"]) assert main() == 1 @pytest.mark.benchmark - def test_message_validation_from_file(self): + def test_message_validation_from_file(self, monkeypatch): """Test validation of commit message from a file.""" with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("fix: resolve bug") f.flush() try: - sys.argv = [CMD, "-m", f.name] + monkeypatch.setattr("sys.argv", [CMD, "-m", f.name]) assert main() == 0 finally: os.unlink(f.name) @pytest.mark.benchmark - def test_branch_validation(self, mocker): + def test_branch_validation(self, mocker, monkeypatch): """Test branch name validation.""" # Mock git command to return a valid branch name mocker.patch( @@ -83,11 +83,11 @@ def test_branch_validation(self, mocker): )(), ) - sys.argv = [CMD, "-b"] + monkeypatch.setattr("sys.argv", [CMD, "-b"]) assert main() == 0 @pytest.mark.benchmark - def test_author_name_validation(self, mocker): + def test_author_name_validation(self, mocker, monkeypatch): """Test author name validation.""" # Mock git command to return a valid author name mocker.patch( @@ -97,11 +97,11 @@ def test_author_name_validation(self, mocker): )(), ) - sys.argv = [CMD, "-n"] + monkeypatch.setattr("sys.argv", [CMD, "-n"]) assert main() == 0 @pytest.mark.benchmark - def test_author_email_validation(self, mocker): + def test_author_email_validation(self, mocker, monkeypatch): """Test author email validation.""" # Mock git command to return a valid author email mocker.patch( @@ -111,17 +111,17 @@ def test_author_email_validation(self, mocker): )(), ) - sys.argv = [CMD, "-e"] + monkeypatch.setattr("sys.argv", [CMD, "-e"]) assert main() == 0 @pytest.mark.benchmark - def test_dry_run_always_passes(self, mocker): + def test_dry_run_always_passes(self, mocker, monkeypatch): """Test that dry run mode always returns 0.""" # Mock stdin to provide an invalid commit message mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="invalid commit message\n") - sys.argv = [CMD, "-m", "--dry-run"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--dry-run"]) assert main() == 0 @@ -153,54 +153,59 @@ class TestMainFunctionEdgeCases: """Test main function edge cases for better coverage.""" @pytest.mark.benchmark - def test_main_with_message_file_argument(self): + def test_main_with_message_file_argument(self, monkeypatch): """Test main function with --message pointing to a file.""" with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("feat: add new feature") f.flush() try: - sys.argv = ["commit-check", "--message", f.name] + monkeypatch.setattr("sys.argv", ["commit-check", "--message", f.name]) result = main() assert result == 0 finally: os.unlink(f.name) @pytest.mark.benchmark - def test_main_with_message_empty_string_and_stdin(self, mocker): + def test_main_with_message_empty_string_and_stdin(self, mocker, monkeypatch): """Test main function with --message (empty) and stdin input.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: Add new feature\n") - sys.argv = ["commit-check", "--message"] + monkeypatch.setattr("sys.argv", ["commit-check", "--message"]) result = main() assert result == 0 @pytest.mark.benchmark - def test_main_with_message_empty_string_no_stdin_with_git(self, mocker): + def test_main_with_message_empty_string_no_stdin_with_git( + self, mocker, monkeypatch + ): """Test main function with --message (empty), no stdin, git fallback.""" mocker.patch("sys.stdin.isatty", return_value=True) mocker.patch( "commit_check.engine.get_commit_info", return_value="feat: add feature" ) - sys.argv = ["commit-check", "--message"] + monkeypatch.setattr("sys.argv", ["commit-check", "--message"]) result = main() assert result == 0 # Removed problematic config and multi-check tests due to complex validation dependencies @pytest.mark.benchmark - def test_main_with_invalid_config_file(self, mocker): + def test_main_with_invalid_config_file(self, mocker, monkeypatch): """Test main function with invalid config file.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: Test feature\n") - sys.argv = [ - "commit-check", - "--config", - "/nonexistent/config.toml", - "--message", # empty -> read from stdin - ] + monkeypatch.setattr( + "sys.argv", + [ + "commit-check", + "--config", + "/nonexistent/config.toml", + "--message", # empty -> read from stdin + ], + ) # This should fail with proper error message when config file doesn't exist result = main() @@ -209,7 +214,7 @@ def test_main_with_invalid_config_file(self, mocker): # Removed problematic tests that had configuration dependency issues @pytest.mark.benchmark - def test_main_with_dry_run_all_checks(self, mocker): + def test_main_with_dry_run_all_checks(self, mocker, monkeypatch): """Test main function with dry run and all checks.""" # Mock git operations mocker.patch( @@ -219,25 +224,28 @@ def test_main_with_dry_run_all_checks(self, mocker): mocker.patch("commit_check.util.has_commits", return_value=True) mocker.patch("commit_check.util.get_commit_info", return_value="Invalid Name") - sys.argv = [ - "commit-check", - "--message", - "invalid commit message", - "--branch", - "--author-name", - "--author-email", - "--dry-run", - ] + monkeypatch.setattr( + "sys.argv", + [ + "commit-check", + "--message", + "invalid commit message", + "--branch", + "--author-name", + "--author-email", + "--dry-run", + ], + ) result = main() assert result == 0 # Dry run always returns 0 @pytest.mark.benchmark - def test_main_error_handling_subprocess_failure(self, mocker, capsys): + def test_main_error_handling_subprocess_failure(self, mocker, capsys, monkeypatch): """Test main function when subprocess operations fail.""" # Mock subprocess to fail mocker.patch("subprocess.run", side_effect=Exception("Git command failed")) - sys.argv = ["commit-check", "--branch"] + monkeypatch.setattr("sys.argv", ["commit-check", "--branch"]) # Should handle the error gracefully result = main() @@ -245,15 +253,18 @@ def test_main_error_handling_subprocess_failure(self, mocker, capsys): assert result in [0, 1] # Either passes or fails gracefully @pytest.mark.benchmark - def test_nonexistent_config_file_error(self, capsys): + def test_nonexistent_config_file_error(self, capsys, monkeypatch): """Test that specifying a non-existent config file returns error.""" - sys.argv = [ - "commit-check", - "--config", - "/nonexistent/config.toml", - "--message", - "feat: test", - ] + monkeypatch.setattr( + "sys.argv", + [ + "commit-check", + "--config", + "/nonexistent/config.toml", + "--message", + "feat: test", + ], + ) result = main() assert result == 1 @@ -269,28 +280,32 @@ class TestCLIArgumentIntegration: """Test CLI argument integration with the new config merger.""" @pytest.mark.benchmark - def test_cli_subject_imperative_true(self, mocker): + def test_cli_subject_imperative_true(self, mocker, monkeypatch): """Test --subject-imperative=true rejects non-imperative commit.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: Added feature\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") - sys.argv = ["commit-check", "--message", "--subject-imperative=true"] + monkeypatch.setattr( + "sys.argv", ["commit-check", "--message", "--subject-imperative=true"] + ) result = main() assert result == 1 # Should fail due to non-imperative mood @pytest.mark.benchmark - def test_cli_subject_imperative_false(self, mocker): + def test_cli_subject_imperative_false(self, mocker, monkeypatch): """Test --subject-imperative=false allows non-imperative commit.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: Added feature\n") - sys.argv = ["commit-check", "--message", "--subject-imperative=false"] + monkeypatch.setattr( + "sys.argv", ["commit-check", "--message", "--subject-imperative=false"] + ) result = main() assert result == 0 # Should pass @pytest.mark.benchmark - def test_cli_subject_max_length(self, mocker): + def test_cli_subject_max_length(self, mocker, monkeypatch): """Test --subject-max-length limits commit subject.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch( @@ -299,23 +314,27 @@ def test_cli_subject_max_length(self, mocker): ) mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") - sys.argv = ["commit-check", "--message", "--subject-max-length=30"] + monkeypatch.setattr( + "sys.argv", ["commit-check", "--message", "--subject-max-length=30"] + ) result = main() assert result == 1 # Should fail due to length @pytest.mark.benchmark - def test_cli_allow_commit_types(self, mocker): + def test_cli_allow_commit_types(self, mocker, monkeypatch): """Test --allow-commit-types restricts commit types.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="chore: do something\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") - sys.argv = ["commit-check", "--message", "--allow-commit-types=feat,fix"] + monkeypatch.setattr( + "sys.argv", ["commit-check", "--message", "--allow-commit-types=feat,fix"] + ) result = main() assert result == 1 # Should fail because 'chore' is not in allowed types @pytest.mark.benchmark - def test_cli_allow_merge_commits_false(self, mocker): + def test_cli_allow_merge_commits_false(self, mocker, monkeypatch): """Test --allow-merge-commits=false rejects merge commits.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch( @@ -323,23 +342,28 @@ def test_cli_allow_merge_commits_false(self, mocker): ) mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") - sys.argv = ["commit-check", "--message", "--allow-merge-commits=false"] + monkeypatch.setattr( + "sys.argv", ["commit-check", "--message", "--allow-merge-commits=false"] + ) result = main() assert result == 1 # Should fail @pytest.mark.benchmark - def test_cli_multiple_args_combined(self, mocker): + def test_cli_multiple_args_combined(self, mocker, monkeypatch): """Test multiple CLI arguments work together.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: Add feature\n") - sys.argv = [ - "commit-check", - "--message", - "--subject-imperative=true", - "--subject-max-length=100", - "--allow-commit-types=feat,fix,docs", - ] + monkeypatch.setattr( + "sys.argv", + [ + "commit-check", + "--message", + "--subject-imperative=true", + "--subject-max-length=100", + "--allow-commit-types=feat,fix,docs", + ], + ) result = main() assert result == 0 # Should pass all checks @@ -355,7 +379,7 @@ def test_env_subject_imperative(self, mocker, monkeypatch): mocker.patch("sys.stdin.read", return_value="feat: Added feature\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") - sys.argv = ["commit-check", "--message"] + monkeypatch.setattr("sys.argv", ["commit-check", "--message"]) result = main() assert result == 1 # Should fail due to non-imperative @@ -370,7 +394,7 @@ def test_env_subject_max_length(self, mocker, monkeypatch): ) mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") - sys.argv = ["commit-check", "--message"] + monkeypatch.setattr("sys.argv", ["commit-check", "--message"]) result = main() assert result == 1 # Should fail due to length @@ -382,7 +406,7 @@ def test_env_allow_commit_types(self, mocker, monkeypatch): mocker.patch("sys.stdin.read", return_value="chore: do something\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") - sys.argv = ["commit-check", "--message"] + monkeypatch.setattr("sys.argv", ["commit-check", "--message"]) result = main() assert result == 1 # Should fail @@ -399,7 +423,9 @@ def test_cli_overrides_env(self, mocker, monkeypatch): mocker.patch("sys.stdin.read", return_value="feat: Added feature\n") # Override with CLI to false - sys.argv = ["commit-check", "--message", "--subject-imperative=false"] + monkeypatch.setattr( + "sys.argv", ["commit-check", "--message", "--subject-imperative=false"] + ) result = main() assert result == 0 # CLI wins, should pass @@ -415,7 +441,7 @@ def test_env_overrides_default(self, mocker, monkeypatch): ) mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") - sys.argv = ["commit-check", "--message"] + monkeypatch.setattr("sys.argv", ["commit-check", "--message"]) result = main() assert result == 1 # Env var wins, should fail @@ -424,7 +450,7 @@ class TestPositionalArgumentFeature: """Test positional commit_msg_file argument for pre-commit compatibility.""" @pytest.mark.benchmark - def test_positional_arg_without_message_flag(self): + def test_positional_arg_without_message_flag(self, monkeypatch): """Test using just the positional argument without --message flag.""" with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("feat: add positional argument support") @@ -432,14 +458,14 @@ def test_positional_arg_without_message_flag(self): try: # Use positional argument only (no --message flag) - sys.argv = ["commit-check", f.name] + monkeypatch.setattr("sys.argv", ["commit-check", f.name]) result = main() assert result == 0 # Should pass validation finally: os.unlink(f.name) @pytest.mark.benchmark - def test_positional_arg_with_message_flag(self): + def test_positional_arg_with_message_flag(self, monkeypatch): """Test using positional argument with --message flag.""" with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: f.write("fix: resolve bug in validation") @@ -447,14 +473,14 @@ def test_positional_arg_with_message_flag(self): try: # Use both positional argument and --message flag - sys.argv = ["commit-check", "--message", f.name] + monkeypatch.setattr("sys.argv", ["commit-check", "--message", f.name]) result = main() assert result == 0 # Should pass validation finally: os.unlink(f.name) @pytest.mark.benchmark - def test_positional_arg_with_branch_flag(self, mocker): + def test_positional_arg_with_branch_flag(self, mocker, monkeypatch): """Test positional argument with other check flags (edge case).""" # Mock git command to return a valid branch name mocker.patch( @@ -470,7 +496,7 @@ def test_positional_arg_with_branch_flag(self, mocker): try: # Use positional argument with --branch flag - sys.argv = ["commit-check", "--branch", f.name] + monkeypatch.setattr("sys.argv", ["commit-check", "--branch", f.name]) result = main() # Should validate both commit message and branch name assert result == 0 # Should pass both validations @@ -478,7 +504,7 @@ def test_positional_arg_with_branch_flag(self, mocker): os.unlink(f.name) @pytest.mark.benchmark - def test_positional_arg_invalid_commit(self, mocker): + def test_positional_arg_invalid_commit(self, mocker, monkeypatch): """Test that positional argument correctly rejects invalid commits.""" # Mock git author to ensure it's not in any ignore list mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") @@ -489,14 +515,14 @@ def test_positional_arg_invalid_commit(self, mocker): try: # Use positional argument with invalid message - sys.argv = ["commit-check", f.name] + monkeypatch.setattr("sys.argv", ["commit-check", f.name]) result = main() assert result == 1 # Should fail validation finally: os.unlink(f.name) @pytest.mark.benchmark - def test_positional_arg_nonexistent_file(self, mocker): + def test_positional_arg_nonexistent_file(self, mocker, monkeypatch): """Test that positional argument with non-existent file falls back to git.""" # Mock git to return a valid commit message mocker.patch( @@ -504,7 +530,7 @@ def test_positional_arg_nonexistent_file(self, mocker): return_value="feat: add fallback commit from git", ) - sys.argv = ["commit-check", "/nonexistent/commit_msg.txt"] + monkeypatch.setattr("sys.argv", ["commit-check", "/nonexistent/commit_msg.txt"]) result = main() # Should fall back to git and pass assert result == 0 @@ -514,12 +540,12 @@ class TestJsonFormat: """Tests for --format json machine-readable output.""" @pytest.mark.benchmark - def test_json_format_valid_message_returns_pass(self, mocker, capsys): + def test_json_format_valid_message_returns_pass(self, mocker, capsys, monkeypatch): """JSON output for a valid commit message has status=pass.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: add new feature\n") - sys.argv = [CMD, "-m", "--format", "json"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--format", "json"]) rc = main() out, _ = capsys.readouterr() @@ -530,13 +556,15 @@ def test_json_format_valid_message_returns_pass(self, mocker, capsys): assert all("check" in c and "status" in c for c in data["checks"]) @pytest.mark.benchmark - def test_json_format_invalid_message_returns_fail(self, mocker, capsys): + def test_json_format_invalid_message_returns_fail( + self, mocker, capsys, monkeypatch + ): """JSON output for an invalid commit message has status=fail.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="invalid commit message\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") - sys.argv = [CMD, "-m", "--format", "json"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--format", "json"]) rc = main() out, _ = capsys.readouterr() @@ -550,13 +578,13 @@ def test_json_format_invalid_message_returns_fail(self, mocker, capsys): assert "suggest" in failed[0] and failed[0]["suggest"] @pytest.mark.benchmark - def test_json_format_no_ascii_art_in_stdout(self, mocker, capsys): + def test_json_format_no_ascii_art_in_stdout(self, mocker, capsys, monkeypatch): """JSON mode must not include ASCII art / colour codes in stdout.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="bad commit\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") - sys.argv = [CMD, "-m", "--format", "json"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--format", "json"]) main() out, _ = capsys.readouterr() @@ -567,14 +595,14 @@ def test_json_format_no_ascii_art_in_stdout(self, mocker, capsys): assert "\033[" not in out @pytest.mark.benchmark - def test_json_format_from_file(self, capsys): + def test_json_format_from_file(self, capsys, monkeypatch): """JSON mode works when reading commit message from a file.""" with tempfile.NamedTemporaryFile(mode="w", suffix=".txt", delete=False) as f: f.write("fix: resolve null pointer in auth module") tmp_path = f.name try: - sys.argv = [CMD, "-m", tmp_path, "--format", "json"] + monkeypatch.setattr("sys.argv", [CMD, "-m", tmp_path, "--format", "json"]) rc = main() out, _ = capsys.readouterr() data = json.loads(out) @@ -584,12 +612,12 @@ def test_json_format_from_file(self, capsys): os.unlink(tmp_path) @pytest.mark.benchmark - def test_json_format_exit_code_matches_status(self, mocker, capsys): + def test_json_format_exit_code_matches_status(self, mocker, capsys, monkeypatch): """Exit code 1 when JSON status is fail, exit code 0 when pass.""" # --- pass case --- mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="chore: update dependencies\n") - sys.argv = [CMD, "-m", "--format", "json"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--format", "json"]) rc_pass = main() out, _ = capsys.readouterr() assert rc_pass == 0 @@ -599,7 +627,7 @@ def test_json_format_exit_code_matches_status(self, mocker, capsys): mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="not a conventional commit\n") mocker.patch("commit_check.engine.get_commit_info", return_value="author") - sys.argv = [CMD, "-m", "--format", "json"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--format", "json"]) rc_fail = main() out, _ = capsys.readouterr() assert rc_fail == 1 @@ -610,13 +638,13 @@ class TestNoBanner: """Tests for --no-banner flag.""" @pytest.mark.benchmark - def test_no_banner_suppresses_ascii_art(self, mocker, capsys): + def test_no_banner_suppresses_ascii_art(self, mocker, capsys, monkeypatch): """--no-banner must suppress the ASCII art / teddy bear header.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="invalid commit message\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") - sys.argv = [CMD, "-m", "--no-banner"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--no-banner"]) rc = main() out, _ = capsys.readouterr() @@ -627,13 +655,13 @@ def test_no_banner_suppresses_ascii_art(self, mocker, capsys): assert "check failed ==>" in out @pytest.mark.benchmark - def test_no_banner_still_shows_error_details(self, mocker, capsys): + def test_no_banner_still_shows_error_details(self, mocker, capsys, monkeypatch): """--no-banner keeps error messages and suggestions.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="invalid commit message\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") - sys.argv = [CMD, "-m", "--no-banner"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--no-banner"]) main() out, _ = capsys.readouterr() @@ -641,12 +669,12 @@ def test_no_banner_still_shows_error_details(self, mocker, capsys): assert "Suggest:" in out @pytest.mark.benchmark - def test_no_banner_passes_valid_commit(self, mocker): + def test_no_banner_passes_valid_commit(self, mocker, monkeypatch): """--no-banner with a valid commit should still return 0.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: add new feature\n") - sys.argv = [CMD, "-m", "--no-banner"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--no-banner"]) assert main() == 0 @@ -654,13 +682,13 @@ class TestCompact: """Tests for --compact flag.""" @pytest.mark.benchmark - def test_compact_suppresses_ascii_art(self, mocker, capsys): + def test_compact_suppresses_ascii_art(self, mocker, capsys, monkeypatch): """--compact must not include ASCII art in output.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="invalid commit message\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") - sys.argv = [CMD, "-m", "--compact"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--compact"]) rc = main() out, _ = capsys.readouterr() @@ -669,13 +697,13 @@ def test_compact_suppresses_ascii_art(self, mocker, capsys): assert "(c).-.(c)" not in out @pytest.mark.benchmark - def test_compact_shows_one_line_per_failure(self, mocker, capsys): + def test_compact_shows_one_line_per_failure(self, mocker, capsys, monkeypatch): """--compact outputs one [FAIL] line per failing check.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="invalid commit message\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") - sys.argv = [CMD, "-m", "--compact"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--compact"]) main() out, _ = capsys.readouterr() @@ -684,25 +712,25 @@ def test_compact_shows_one_line_per_failure(self, mocker, capsys): assert len(lines) >= 1 @pytest.mark.benchmark - def test_compact_no_suggestions(self, mocker, capsys): + def test_compact_no_suggestions(self, mocker, capsys, monkeypatch): """--compact output must not include 'Suggest:' lines.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="invalid commit message\n") mocker.patch("commit_check.engine.get_commit_info", return_value="test-author") - sys.argv = [CMD, "-m", "--compact"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--compact"]) main() out, _ = capsys.readouterr() assert "Suggest:" not in out @pytest.mark.benchmark - def test_compact_passes_valid_commit(self, mocker): + def test_compact_passes_valid_commit(self, mocker, monkeypatch): """--compact with a valid commit should still return 0.""" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value="feat: add new feature\n") - sys.argv = [CMD, "-m", "--compact"] + monkeypatch.setattr("sys.argv", [CMD, "-m", "--compact"]) assert main() == 0 @@ -712,7 +740,7 @@ class TestNoForcePushFlag: ZERO_SHA = "0000000000000000000000000000000000000000" @pytest.mark.benchmark - def test_no_force_push_new_branch_passes(self, mocker): + def test_no_force_push_new_branch_passes(self, mocker, monkeypatch): """Push to a new remote branch (zero SHA) always passes.""" push_info = ( f"refs/heads/feature/new abc123 refs/heads/feature/new {self.ZERO_SHA}" @@ -720,42 +748,42 @@ def test_no_force_push_new_branch_passes(self, mocker): mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value=push_info) - sys.argv = [CMD, "--no-force-push"] + monkeypatch.setattr("sys.argv", [CMD, "--no-force-push"]) assert main() == 0 @pytest.mark.benchmark - def test_no_force_push_fast_forward_passes(self, mocker): + def test_no_force_push_fast_forward_passes(self, mocker, monkeypatch): """Fast-forward push (remote is ancestor of local) passes.""" push_info = "refs/heads/main abc123 refs/heads/main def456" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value=push_info) mocker.patch("commit_check.engine.git_merge_base", return_value=0) - sys.argv = [CMD, "--no-force-push"] + monkeypatch.setattr("sys.argv", [CMD, "--no-force-push"]) assert main() == 0 @pytest.mark.benchmark - def test_no_force_push_force_push_fails(self, mocker): + def test_no_force_push_force_push_fails(self, mocker, monkeypatch): """Force push (remote is not ancestor of local) fails.""" push_info = "refs/heads/main abc123 refs/heads/main def456" mocker.patch("sys.stdin.isatty", return_value=False) mocker.patch("sys.stdin.read", return_value=push_info) mocker.patch("commit_check.engine.git_merge_base", return_value=1) - sys.argv = [CMD, "--no-force-push"] + monkeypatch.setattr("sys.argv", [CMD, "--no-force-push"]) assert main() == 1 @pytest.mark.benchmark - def test_no_force_push_no_stdin_passes(self, mocker): + def test_no_force_push_no_stdin_passes(self, mocker, monkeypatch): """When no stdin and no upstream are available, the check is skipped.""" mocker.patch("sys.stdin.isatty", return_value=True) mocker.patch("commit_check.engine.get_upstream_branch", return_value="") - sys.argv = [CMD, "--no-force-push"] + monkeypatch.setattr("sys.argv", [CMD, "--no-force-push"]) assert main() == 0 @pytest.mark.benchmark - def test_no_force_push_no_stdin_uses_upstream_fallback(self, mocker): + def test_no_force_push_no_stdin_uses_upstream_fallback(self, mocker, monkeypatch): """Without stdin, the CLI falls back to checking the current upstream.""" mocker.patch("sys.stdin.isatty", return_value=True) mocker.patch( @@ -763,11 +791,13 @@ def test_no_force_push_no_stdin_uses_upstream_fallback(self, mocker): ) mocker.patch("commit_check.engine.git_merge_base", return_value=0) - sys.argv = [CMD, "--no-force-push"] + monkeypatch.setattr("sys.argv", [CMD, "--no-force-push"]) assert main() == 0 @pytest.mark.benchmark - def test_no_force_push_no_stdin_blocks_non_fast_forward_upstream(self, mocker): + def test_no_force_push_no_stdin_blocks_non_fast_forward_upstream( + self, mocker, monkeypatch + ): """Without stdin, a non-fast-forward upstream relationship fails.""" mocker.patch("sys.stdin.isatty", return_value=True) mocker.patch( @@ -776,11 +806,13 @@ def test_no_force_push_no_stdin_blocks_non_fast_forward_upstream(self, mocker): mocker.patch("commit_check.engine.get_branch_name", return_value="main") mocker.patch("commit_check.engine.git_merge_base", return_value=1) - sys.argv = [CMD, "--no-force-push"] + monkeypatch.setattr("sys.argv", [CMD, "--no-force-push"]) assert main() == 1 @pytest.mark.benchmark - def test_no_force_push_uses_pre_commit_env_before_upstream(self, mocker): + def test_no_force_push_uses_pre_commit_env_before_upstream( + self, mocker, monkeypatch + ): """pre-commit pre-push metadata drives the check when stdin is unavailable.""" mocker.patch("sys.stdin.isatty", return_value=True) mocker.patch.dict( @@ -796,14 +828,14 @@ def test_no_force_push_uses_pre_commit_env_before_upstream(self, mocker): mock_merge = mocker.patch("commit_check.engine.git_merge_base", return_value=1) mock_upstream = mocker.patch("commit_check.engine.get_upstream_branch") - sys.argv = [CMD, "--no-force-push"] + monkeypatch.setattr("sys.argv", [CMD, "--no-force-push"]) assert main() == 1 mock_merge.assert_called_once_with("remote-sha", "local-sha") mock_upstream.assert_not_called() @pytest.mark.benchmark - def test_no_force_push_pre_commit_env_fetches_remote_sha(self, mocker): + def test_no_force_push_pre_commit_env_fetches_remote_sha(self, mocker, monkeypatch): """pre-commit metadata can resolve the remote tip when FROM_REF is absent.""" mocker.patch("sys.stdin.isatty", return_value=True) mocker.patch.dict( @@ -827,7 +859,7 @@ def test_no_force_push_pre_commit_env_fetches_remote_sha(self, mocker): ) mock_merge = mocker.patch("commit_check.engine.git_merge_base", return_value=0) - sys.argv = [CMD, "--no-force-push"] + monkeypatch.setattr("sys.argv", [CMD, "--no-force-push"]) assert main() == 0 mock_run.assert_called_once_with( @@ -887,9 +919,9 @@ def test_build_pre_commit_push_input_prefers_remote_sha(self, mocker): ) @pytest.mark.benchmark - def test_no_force_push_flag_in_help(self, capfd): + def test_no_force_push_flag_in_help(self, capfd, monkeypatch): """The --no-force-push flag appears in help output.""" - sys.argv = [CMD, "--help"] + monkeypatch.setattr("sys.argv", [CMD, "--help"]) with pytest.raises(SystemExit): main() out, _ = capfd.readouterr() From ae0362ebaf887fb5a090aeff4e8fb3b4846007cb Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 25 Jul 2026 09:26:42 +0300 Subject: [PATCH 02/12] test: mock git config in signoff ignore-author test (#481) --- tests/engine_test.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/engine_test.py b/tests/engine_test.py index 9d97ce81..dd1eff97 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -821,9 +821,12 @@ def test_default_signoff_rejects_missing_signoff(self): result = validator.validate(context) assert result == ValidationResult.FAIL + @patch(GIT_CONFIG_VALUE) @patch("commit_check.engine.get_commit_info") @pytest.mark.benchmark - def test_default_signoff_skips_ignored_author(self, mock_get_commit_info): + def test_default_signoff_skips_ignored_author( + self, mock_get_commit_info, mock_get_git_config_value + ): """Signoff check is skipped when the author is in ignore_authors. A commit with no signoff would normally fail, but an ignored author @@ -831,6 +834,9 @@ def test_default_signoff_skips_ignored_author(self, mock_get_commit_info): commit check. """ mock_get_commit_info.return_value = "dependabot[bot]" + # Mock git config so author resolution falls back to the commit author + # instead of the developer's real local user.name. + mock_get_git_config_value.return_value = "" validator = SignoffValidator(self._default_signoff_rule()) config = {"commit": {"ignore_authors": ["dependabot[bot]"]}} context = ValidationContext(stdin_text="chore: bump dep", config=config) From dca495f8277c671c1be57557564fae8369664002 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 25 Jul 2026 10:10:28 +0300 Subject: [PATCH 03/12] fix: resolve SonarCloud code scanning alerts for pip install security (#479) --- .github/workflows/main.yml | 12 ++++++------ .github/workflows/publish-package.yml | 3 ++- pyproject.toml | 3 ++- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 826a6427..85442119 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -27,8 +27,8 @@ jobs: - name: Install nox run: | - python -m pip install --upgrade pip - python -m pip install nox + python -m pip install --only-binary :all: --upgrade pip + python -m pip install --only-binary :all: ".[dev]" - name: Run pre-commit run: | @@ -71,8 +71,8 @@ jobs: with: python-version: ${{ matrix.py }} - run: | - pip install --upgrade pip - pip install .[dev] + pip install --only-binary :all: --upgrade pip + pip install --only-binary :all: .[dev] - name: Download wheel artifact uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 @@ -97,8 +97,8 @@ jobs: - name: Install nox run: | - python -m pip install --upgrade pip - python -m pip install nox + python -m pip install --only-binary :all: --upgrade pip + python -m pip install --only-binary :all: ".[dev]" - name: Build docs run: nox -s docs diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 68eaabe3..86018824 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -26,7 +26,8 @@ jobs: - name: Build wheel run: | # Install dependencies - python -m pip install --upgrade pip twine + python -m pip install --only-binary :all: --upgrade pip + python -m pip install --only-binary :all: ".[ci]" # Build wheel python -m pip wheel -w dist . # Check distribution diff --git a/pyproject.toml b/pyproject.toml index 9841c626..4f909775 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -46,9 +46,10 @@ tracker = "https://github.com/commit-check/commit-check/issues" # https://packaging.python.org/en/latest/specifications/declaring-project-metadata/ [project.optional-dependencies] -dev = ['nox'] +dev = ['nox==2026.7.11'] test = ['coverage', 'pytest', 'pytest-mock', 'pytest-codspeed'] docs = ['sphinx<9', 'sphinx-immaterial', 'sphinx-autobuild', 'sphinx_issues', 'myst-parser'] +ci = ['twine==6.2.0'] [tool.setuptools] zip-safe = false From 5a3dd93c022a8c8747d73d62ff4a7781adede56f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:03:32 +0300 Subject: [PATCH 04/12] chore(deps): bump the github-actions group with 2 updates (#482) --- .github/workflows/codspeed.yml | 2 +- .github/workflows/main.yml | 6 +++--- .github/workflows/publish-package.yml | 2 +- .github/workflows/scorecard.yml | 4 ++-- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.github/workflows/codspeed.yml b/.github/workflows/codspeed.yml index c7585f18..324a4ff2 100644 --- a/.github/workflows/codspeed.yml +++ b/.github/workflows/codspeed.yml @@ -29,7 +29,7 @@ jobs: name: Run benchmarks runs-on: ubuntu-latest steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 85442119..487ea65b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,7 +20,7 @@ jobs: build: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: '3.x' @@ -66,7 +66,7 @@ jobs: os: ['windows-latest', 'ubuntu-24.04', 'macos-latest'] runs-on: ${{ matrix.os }} steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: ${{ matrix.py }} @@ -90,7 +90,7 @@ jobs: permissions: contents: write steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.10" diff --git a/.github/workflows/publish-package.yml b/.github/workflows/publish-package.yml index 86018824..61fc7120 100644 --- a/.github/workflows/publish-package.yml +++ b/.github/workflows/publish-package.yml @@ -14,7 +14,7 @@ jobs: publish: runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 # use fetch --all for setuptools_scm to work with: fetch-depth: 0 diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml index fdfdc8fe..dbac2c81 100644 --- a/.github/workflows/scorecard.yml +++ b/.github/workflows/scorecard.yml @@ -15,11 +15,11 @@ jobs: id-token: write contents: read steps: - - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false - - uses: ossf/scorecard-action@4eaacf0543bb3f2c246792bd56e8cdeffafb205a # v2.4.3 + - uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 with: results_file: results.sarif results_format: sarif From ad606e1ab60aaab6d8019183656f95a4b06ea203 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Wed, 29 Jul 2026 23:28:43 +0300 Subject: [PATCH 05/12] fix: handle PackageNotFoundError when commit-check is not installed (#483) * fix: handle PackageNotFoundError when commit-check is not installed Wrap __version__ in try/except so that importing commit-check does not crash in development environments where the package has not been pip installed (e.g. running directly via uv or python -m). Falls back to '0.0.0.dev'. * test: add tests for __version__ fallback on PackageNotFoundError --- commit_check/__init__.py | 7 +++++-- tests/version_test.py | 28 ++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 tests/version_test.py diff --git a/commit_check/__init__.py b/commit_check/__init__.py index fb2cffbb..e3c59b11 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -6,7 +6,7 @@ __version__ (package version) """ -from importlib.metadata import version +from importlib.metadata import version, PackageNotFoundError # Exit codes used across the package PASS = 0 @@ -76,4 +76,7 @@ DEFAULT_AI_ATTRIBUTION = "ignore" # "ignore" | "forbid" -__version__ = version("commit-check") +try: + __version__ = version("commit-check") +except PackageNotFoundError: + __version__ = "0.0.0.dev" diff --git a/tests/version_test.py b/tests/version_test.py new file mode 100644 index 00000000..8923d476 --- /dev/null +++ b/tests/version_test.py @@ -0,0 +1,28 @@ +"""Tests for commit_check.__version__.""" + +import importlib +import pytest +from unittest.mock import patch +from importlib.metadata import PackageNotFoundError +import commit_check + + +class TestVersion: + """Tests for __version__ resolution.""" + + @pytest.mark.benchmark + def test_version_is_string_when_installed(self): + """When the package is installed, __version__ must be a non-empty string.""" + assert isinstance(commit_check.__version__, str) + assert len(commit_check.__version__) > 0 + + @pytest.mark.benchmark + def test_version_fallback_when_not_installed(self): + """When PackageNotFoundError is raised, __version__ must fall back to '0.0.0.dev'.""" + with patch("importlib.metadata.version", side_effect=PackageNotFoundError): + importlib.reload(commit_check) + assert commit_check.__version__ == "0.0.0.dev" + + # Reload again to restore original version + importlib.reload(commit_check) + assert isinstance(commit_check.__version__, str) From cb0c44f13a2aae479c3d47b0315a020c9335800f Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Wed, 29 Jul 2026 23:42:24 +0300 Subject: [PATCH 06/12] chore: remove dead code _find_check (#484) _find_check in commit_check/util.py was no longer used by any production code. Only its tests in tests/util_test.py referenced it. Remove both the function and its corresponding test class. --- commit_check/util.py | 8 -------- tests/util_test.py | 27 --------------------------- 2 files changed, 35 deletions(-) diff --git a/commit_check/util.py b/commit_check/util.py index 2d9c9703..af68cf07 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -12,14 +12,6 @@ from commit_check import RED, GREEN, YELLOW, RESET_COLOR -def _find_check(checks: list, check_type: str) -> dict | None: - """Return the first check dict matching check_type, else None.""" - for check in checks: - if check.get("check") == check_type: - return check - return None - - def _print_failure( check: dict, actual: str, diff --git a/tests/util_test.py b/tests/util_test.py index 7b2bbf0f..2a106b00 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -15,7 +15,6 @@ print_error_header, print_error_message, print_suggestion, - _find_check, ) from subprocess import CalledProcessError, PIPE from unittest.mock import MagicMock @@ -615,32 +614,6 @@ def test_print_suggestion_exit1(self, capfd): stdout, _ = capfd.readouterr() assert "commit-check does not support" in stdout - class TestHelperFunctions: - """Tests for utility helper functions to improve coverage.""" - - def test_find_check_found(self): - """Test _find_check when check is found.""" - checks = [ - {"check": "commit-message", "regex": ".*"}, - {"check": "branch-name", "regex": ".*"}, - ] - result = _find_check(checks, "commit-message") - assert result == {"check": "commit-message", "regex": ".*"} - - def test_find_check_not_found(self): - """Test _find_check when check is not found.""" - checks = [ - {"check": "commit-message", "regex": ".*"}, - ] - result = _find_check(checks, "author-name") - assert result is None - - def test_find_check_empty_list(self): - """Test _find_check with empty list.""" - checks = [] - result = _find_check(checks, "commit-message") - assert result is None - class TestGetGitConfigValue: """Tests for get_git_config_value utility function.""" From 18d7cee05a549e2e6fa2df4134d2bacd754e3705 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Thu, 30 Jul 2026 19:01:01 +0300 Subject: [PATCH 07/12] chore: use pull_request_target for auto-labeler on fork PRs (#488) fix: use pull_request_target for auto-labeler on fork PRs When a PR is opened from a fork repository, the GITHUB_TOKEN in pull_request events only has read permissions, preventing the autolabeler from adding labels. Using pull_request_target runs the workflow in the context of the base repository with write permissions, which is safe since the autolabeler only reads PR metadata and adds labels without checking out any code. Fixes auto-labeler failing on PR #487 from a fork. --- .github/workflows/labeler.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/labeler.yml b/.github/workflows/labeler.yml index fdcef892..011cb85d 100644 --- a/.github/workflows/labeler.yml +++ b/.github/workflows/labeler.yml @@ -1,8 +1,8 @@ name: PR Auto-labeler on: - # pull_request event is required for auto-labeler - pull_request: + # pull_request_target event is required for auto-labeler on fork PRs + pull_request_target: types: [opened, reopened, synchronize] permissions: {} From 56e6e3619cef604c3cbec37973ee901b88ea9ae0 Mon Sep 17 00:00:00 2001 From: XEDAB <293830580+XEDAB@users.noreply.github.com> Date: Fri, 31 Jul 2026 00:06:30 +0800 Subject: [PATCH 08/12] chore: remove dead else branch in print_suggestion() (#487) * fix: remove dead else branch in print_suggestion() (#486) Removed the unreachable else branch in print_suggestion() that could never be entered since all callers already guard with a truthy check. Updated the type hint from str | None to str, and removed the corresponding test. Close #486 * fix: resolve mypy type error in print_suggestion call Fix the mypy error: the argument passed to print_suggestion in _print_failure is now guaranteed to be str (still works at runtime, just shuts up the type checker). --------- Co-authored-by: XEDAB --- commit_check/util.py | 7 ++----- tests/util_test.py | 9 --------- 2 files changed, 2 insertions(+), 14 deletions(-) diff --git a/commit_check/util.py b/commit_check/util.py index af68cf07..f0be979a 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -27,7 +27,7 @@ def _print_failure( print_error_header() print_error_message(check["check"], check.get("error", ""), actual) if check.get("suggest"): - print_suggestion(check.get("suggest")) + print_suggestion(check["suggest"]) def get_branch_name() -> str: @@ -301,7 +301,7 @@ def print_error_message(check_type: str, error: str, reason: str): print(error) -def print_suggestion(suggest: str | None) -> None: +def print_suggestion(suggest: str) -> None: """Print suggestion to user :param suggest: what message to print out """ @@ -310,7 +310,4 @@ def print_suggestion(suggest: str | None) -> None: f"Suggest: {GREEN}{suggest}{RESET_COLOR} ", end="", ) - else: - print(f"commit-check does not support {suggest} yet.") - raise SystemExit(1) print("\n") diff --git a/tests/util_test.py b/tests/util_test.py index 2a106b00..33651195 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -605,15 +605,6 @@ def test_print_suggestion(self, capfd): stdout, _ = capfd.readouterr() assert "Suggest:" in stdout - @pytest.mark.benchmark - def test_print_suggestion_exit1(self, capfd): - # Must exit with 1 when "" passed - with pytest.raises(SystemExit) as e: - print_suggestion("") - assert e.value.code == 1 - stdout, _ = capfd.readouterr() - assert "commit-check does not support" in stdout - class TestGetGitConfigValue: """Tests for get_git_config_value utility function.""" From 30955099333fc6c5f8411ebe7f361b46186e5073 Mon Sep 17 00:00:00 2001 From: Ruben Sanosh Date: Thu, 30 Jul 2026 23:10:36 -0600 Subject: [PATCH 09/12] test: add cases for BodyValidator leading blank line edge cases (#492) --- tests/engine_test.py | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/engine_test.py b/tests/engine_test.py index dd1eff97..65a239a9 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -968,6 +968,50 @@ def test_validate_without_body(self): result = validator.validate(context) assert result == ValidationResult.FAIL + @pytest.mark.benchmark + def test_validate_with_leading_blank_lines_and_body(self): + """Test body validation with leading blank lines before body content. + + _get_commit_message() strips input before BodyValidator sees it, so + leading blank lines are removed and this collapses to a single line + with no separate subject/body it should FAIL. + """ + rule = ValidationRule(check="require_body") + validator = BodyValidator(rule) + context = ValidationContext(stdin_text="\n\nbody content") + + with patch("commit_check.util._print_failure"): + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @pytest.mark.benchmark + def test_validate_with_leading_blank_lines_no_body(self): + """Test body validation with only leading blank lines and no content. + + After stripping, this becomes an empty message, which is treated as + having no commit message at all — it should PASS. + """ + rule = ValidationRule(check="require_body") + validator = BodyValidator(rule) + context = ValidationContext(stdin_text="\n\n") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + @pytest.mark.benchmark + def test_validate_with_whitespace_only_message(self): + """Test body validation with a whitespace-only message. + + After stripping, this becomes an empty message, same as the + leading-blank-lines-only case — it should PASS. + """ + rule = ValidationRule(check="require_body") + validator = BodyValidator(rule) + context = ValidationContext(stdin_text=" \n ") + + result = validator.validate(context) + assert result == ValidationResult.PASS + class TestMergeBaseValidator: @patch("commit_check.util.git_merge_base") From 91c4a9fe78d8b92853ee65ae29ac49fc7eee2f01 Mon Sep 17 00:00:00 2001 From: Loi Nguyen Date: Sat, 1 Aug 2026 00:27:14 +0700 Subject: [PATCH 10/12] chore: recognize additional common imperative verbs (#496) --- commit_check/imperatives.py | 5 +++++ tests/engine_test.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/commit_check/imperatives.py b/commit_check/imperatives.py index 875273a5..3704ee79 100644 --- a/commit_check/imperatives.py +++ b/commit_check/imperatives.py @@ -25,6 +25,7 @@ "authenticate", "authorize", "auto", + "backport", "batch", "bind", "block", @@ -44,6 +45,7 @@ "close", "collect", "combine", + "comment", "commit", "compare", "compose", @@ -231,6 +233,7 @@ "pipe", "plot", "poll", + "polyfill", "populate", "post", "prefix", @@ -295,6 +298,7 @@ "retry", "return", "reuse", + "revamp", "revert", "revoke", "rework", @@ -385,6 +389,7 @@ "upload", "use", "validate", + "vendor", "verify", "view", "wait", diff --git a/tests/engine_test.py b/tests/engine_test.py index 65a239a9..f5b514e3 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -1327,6 +1327,28 @@ def test_validate_empty_subject_passes( class TestSubjectImperativeValidator: """Test SubjectImperativeValidator edge cases.""" + @pytest.mark.benchmark + @pytest.mark.parametrize( + "subject", + [ + "fix: backport the patch", + "chore: comment out the entry", + "docs: embed the example", + "build: polyfill the API", + "docs: revamp the profile", + "chore: vendor the dependency", + ], + ) + def test_validate_with_common_imperative_subjects(self, subject): + """Common imperative verbs pass subject validation.""" + rule = ValidationRule(check="subject_imperative") + validator = SubjectImperativeValidator(rule) + context = ValidationContext(stdin_text=subject) + + result = validator.validate(context) + + assert result == ValidationResult.PASS + @pytest.mark.benchmark def test_validate_with_imperative_subject(self): """Test validation with proper imperative subject.""" From b75ec1c1280215b3c9a9d734732191bacb4b9df8 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sat, 1 Aug 2026 01:12:47 +0300 Subject: [PATCH 11/12] chore: Update cchk.toml to disable conventional branch check --- cchk.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cchk.toml b/cchk.toml index cb2eb833..1c8246b8 100644 --- a/cchk.toml +++ b/cchk.toml @@ -17,6 +17,6 @@ ignore_authors = ["dependabot[bot]", "copilot[bot]", "pre-commit-ci[bot]", "code [branch] # https://conventionalbranch.org -conventional_branch = true +conventional_branch = false require_rebase_target = "main" ignore_authors = ["dependabot[bot]", "copilot[bot]", "pre-commit-ci[bot]", "shenxianpeng"] From d534a3f5165ac6503952b2aefa7ddc32cbb1d757 Mon Sep 17 00:00:00 2001 From: Stas Shevchenko Date: Sat, 1 Aug 2026 00:28:14 +0200 Subject: [PATCH 12/12] fix: detect space-separated AI model names in co-author trailers (#506) * fix: detect space-separated AI model names in trailers * ci: auto fixes from pre-commit.com hooks --------- Co-authored-by: sshevchenko Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> --- commit_check/ai_signatures_data.py | 18 ++++++++++++++++++ tests/ai_signatures_test.py | 29 +++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+) diff --git a/commit_check/ai_signatures_data.py b/commit_check/ai_signatures_data.py index 2924eea9..d247a405 100644 --- a/commit_check/ai_signatures_data.py +++ b/commit_check/ai_signatures_data.py @@ -89,6 +89,14 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: r"|\d+\+Claude@users\.noreply\.github\.com)>)?", "``Co-authored-by: Claude`` trailer", ), + # Any co-author name with the Anthropic noreply email — catches + # model-name variants such as "Claude Opus 4.5 (1M context)" that + # the pattern above misses. + _trailer( + "Co-authored-by", + r"[^<\n]*", + "``Co-authored-by`` with Anthropic noreply email", + ), # Assisted-by trailer (Linux kernel style, with optional tool list) _trailer( "Assisted-by", @@ -223,6 +231,16 @@ def _body_marker(pattern: str, description: str = "") -> AiSignaturePattern: r"(?:claude|gpt|gemini)[\w.]*-[\w.-]+(?:\s*<[^>]*>)?", "``Co-authored-by`` with AI model name", ), + # Catch space-separated AI model identifiers in Co-authored-by + # (e.g. "Claude Opus 4.5", "Gemini 2.5 Pro", "GPT 4 Turbo"). + # A purely numeric version token is required so human names with + # ordinals ("Claude Dubois 3rd") are NOT flagged. + _trailer( + "Co-authored-by", + r"(?:claude|gpt|gemini)(?:\s+[a-z]+)*\s+\d+(?:\.\d+)*(?!\w)" + r"(?:\s+[a-z]+)*(?:\s*\([^)]*\))?(?:\s*<[^>]*>)?", + "``Co-authored-by`` with space-separated AI model name", + ), # Catch Assisted-by trailer (Linux kernel style) regardless of agent, # with optional trailing tool list. _trailer( diff --git a/tests/ai_signatures_test.py b/tests/ai_signatures_test.py index 4a755e25..ba3b1931 100644 --- a/tests/ai_signatures_test.py +++ b/tests/ai_signatures_test.py @@ -47,6 +47,35 @@ def test_human_claude_with_personal_email_ignored(self): claude_hits = [s for s in result if s["tool"] == "Claude Code"] assert len(claude_hits) == 0 + @pytest.mark.benchmark + def test_claude_model_name_with_noreply_email(self): + """Model-name co-author with anthropic noreply is detected.""" + message = ( + "feat: add feature\n\n" + "Co-authored-by: Claude Opus 4.5 (1M context) " + ) + result = detect_ai_signatures(message) + assert any(s["tool"] == "Claude Code" for s in result) + + @pytest.mark.benchmark + def test_space_separated_model_name_detected(self): + """Space-separated model names with a version are detected.""" + for trailer in ( + "Co-authored-by: Claude Sonnet 4.5 ", + "Co-authored-by: Gemini 2.5 Pro ", + "Co-authored-by: GPT 4 Turbo", + ): + message = f"feat: add feature\n\n{trailer}" + assert has_ai_signature(message), trailer + + @pytest.mark.benchmark + def test_human_name_with_ordinal_ignored(self): + """A human name with an ordinal suffix is NOT detected.""" + message = ( + "feat: add feature\n\nCo-authored-by: Claude Dubois 3rd " + ) + assert not has_ai_signature(message) + @pytest.mark.benchmark def test_copilot_with_noreply_email(self): """Co-authored-by: Copilot with GitHub noreply is detected."""