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/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: {} diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 826a6427..487ea65b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -20,15 +20,15 @@ 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' - 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: | @@ -66,13 +66,13 @@ 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 }} - 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 @@ -90,15 +90,15 @@ 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" - 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..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 @@ -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/.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 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"] 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/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/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/commit_check/util.py b/commit_check/util.py index 2d9c9703..f0be979a 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, @@ -35,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: @@ -309,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 """ @@ -318,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/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 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.""" 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/engine_test.py b/tests/engine_test.py index 9d97ce81..f5b514e3 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) @@ -962,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") @@ -1277,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.""" 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() diff --git a/tests/util_test.py b/tests/util_test.py index 7b2bbf0f..33651195 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 @@ -606,41 +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 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.""" 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)