diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 3489b0e..8b83e88 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -21,7 +21,7 @@ repos: args: [ --fix ] - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.20.0 + rev: v1.19.1 hooks: - id: mypy additional_dependencies: [types-PyYAML] diff --git a/README.rst b/README.rst index e3e61ff..b21b013 100644 --- a/README.rst +++ b/README.rst @@ -31,6 +31,11 @@ Commit Check |ci-badge| |sonar-badge| |pypi-version| |pypi-downloads| |python-versions| |commit-check-badge| |codecov-badge| +.. contents:: Table of Contents + :depth: 2 + :local: + :backlinks: none + Overview -------- @@ -39,89 +44,11 @@ Overview As a lightweight, free alternative to GitHub Enterprise `Metadata restrictions `_ and Bitbucket's paid `Yet Another Commit Checker `_ plugin, Commit Check integrates DevOps principles and Infrastructure as Code (IaC) practices for a modern workflow. -**Why Commit Check?** - -The table below compares common approaches to commit policy enforcement. -``commitlint`` is a specialized commit-message linter. Custom Git hooks and -the ``pre-commit`` framework are integration mechanisms, so the last column -reflects a DIY approach rather than built-in product features. - -.. list-table:: - :header-rows: 1 - :widths: 36 18 18 28 - - * - Feature - - Commit Check ✅ - - commitlint - - Custom hooks - * - Conventional Commits enforcement - - ✅ - - ✅ - - DIY - * - Branch naming validation - - ✅ - - ❌ - - DIY - * - Author name / email validation - - ✅ - - ❌ - - DIY - * - Signed-off-by trailer enforcement - - ✅ - - ✅ - - DIY - * - Co-author ignore list - - ✅ - - ❌ - - DIY - * - Organization-level shared config - - ✅ - - ✅ - - DIY - * - Zero-config defaults - - ✅ - - ❌ - - ❌ - * - Works without Node.js - - ✅ - - ❌ - - Depends - * - Native TOML configuration - - ✅ - - ❌ - - Depends - * - Git hook / pre-commit integration - - ✅ - - Partial - - ✅ - * - CI/CD-friendly configuration - - ✅ - - Partial - - DIY - -For ``commitlint``, organization-level shared config is typically delivered via -shareable config packages or local files. ``DIY`` means you can implement a -capability with custom Git hooks or ``pre-commit`` scripts, but it is not -provided as a turnkey policy layer. - -Installation ------------- - -To install Commit Check, you can use pip: - -.. code-block:: bash - - pip install commit-check - -Or install directly from the GitHub repository: - -.. code-block:: bash - - pip install git+https://github.com/commit-check/commit-check.git@main - -Then, run ``commit-check --help`` or ``cchk --help`` (alias for ``commit-check``) from the command line. -For more information, see the `docs `_. +.. image:: https://github.com/commit-check/commit-check/raw/main/docs/demo.gif + :alt: commit-check demo + :align: center +| Quick Start ----------- @@ -150,6 +77,24 @@ Quick Start [![commit-check](https://img.shields.io/badge/commit--check-enabled-brightgreen?logo=Git&logoColor=white&color=%232c9ccd)](https://github.com/commit-check/commit-check) +Installation +------------ + +To install Commit Check, you can use pip: + +.. code-block:: bash + + pip install commit-check + +Or install directly from the GitHub repository: + +.. code-block:: bash + + pip install git+https://github.com/commit-check/commit-check.git@main + +Then, run ``commit-check --help`` or ``cchk --help`` (alias for ``commit-check``) from the command line. +For more information, see the `docs `_. + Configuration ------------- @@ -242,20 +187,125 @@ For one-off checks or CI/CD pipelines, you can configure via CLI arguments or en See the `Configuration documentation `_ for all available options. -Usage ------ +AI-Native Usage +--------------- + +Commit Check is designed to be consumed by AI agents, LLM toolchains, and +automation scripts — not just by humans reading terminal output. + +Machine-Readable JSON Output (``--format json``) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Pass ``--format json`` to any CLI invocation to receive structured JSON instead +of human-readable ASCII art. The exit code is unchanged (``0`` = pass, ``1`` = fail), +so existing CI scripts continue to work: + +.. code-block:: bash + + echo "feat: add streaming support" | commit-check -m --format json + +.. code-block:: json + + { + "status": "pass", + "checks": [ + { "check": "message", "status": "pass", "value": "", "error": "", "suggest": "" }, + { "check": "subject_imperative", "status": "pass", "value": "", "error": "", "suggest": "" } + ] + } + +On failure the failing checks carry the full ``error`` and ``suggest`` fields +an agent needs to self-correct: + +.. code-block:: bash + + echo "wip bad commit" | commit-check -m --format json + +.. code-block:: json + + { + "status": "fail", + "checks": [ + { + "check": "message", + "status": "fail", + "value": "wip bad commit", + "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", + "suggest": "Use (): , where is one of: feat, fix, docs, ..." + } + ] + } + +Python API (no subprocess required) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``commit_check.api`` module exposes a lightweight, import-friendly interface +so AI agents, tools, and scripts can validate commits **without spawning a +subprocess**. All functions return plain dicts that are easy to serialise, +forward to an LLM, or chain into larger workflows: + +.. code-block:: python + + from commit_check.api import validate_message, validate_branch, validate_all + + # --- validate a single commit message --- + result = validate_message("feat: add streaming support") + print(result["status"]) # "pass" + + # --- validate a branch name --- + result = validate_branch("feature/add-streaming") + print(result["status"]) # "pass" + + # --- run multiple checks at once --- + result = validate_all( + message="feat: implement new feature", + branch="feature/new-feature", + author_name="Ada Lovelace", + author_email="ada@example.com", + ) + if result["status"] == "fail": + for check in result["checks"]: + if check["status"] == "fail": + print(f"[{check['check']}] {check['error']}") + print(f" suggestion: {check['suggest']}") + + # --- supply a custom config to restrict allowed types --- + result = validate_message( + "docs: update readme", + config={"commit": {"allow_commit_types": ["feat", "fix"]}}, + ) + print(result["status"]) # "fail" — 'docs' not in allowed types + +**Return-value schema** (all API functions): + +.. code-block:: python + + { + "status": "pass" | "fail", + "checks": [ + { + "check": "", + "status": "pass" | "fail", + "value": "", + "error": "", + "suggest": "", + }, + # ... one entry per active rule + ] + } + +Available API functions: + +* ``validate_message(message, *, config=None)`` — validate a commit message string +* ``validate_branch(branch=None, *, config=None)`` — validate a branch name (defaults to current git branch) +* ``validate_author(name=None, email=None, *, config=None)`` — validate author name/email +* ``validate_all(message, branch, author_name, author_email, *, config=None)`` — run all checks at once For detailed usage instructions including pre-commit hooks, CLI commands, and STDIN examples, see the `Usage Examples documentation `_. Examples -------- -.. image:: https://github.com/commit-check/commit-check/raw/main/docs/demo.gif - :alt: commit-check demo - :align: center - -| - Check Commit Message Failed .. code-block:: text @@ -325,6 +375,73 @@ reStructuredText :alt: commit-check +Why Commit Check? +----------------- + +The table below compares common approaches to commit policy enforcement. +``commitlint`` is a specialized commit-message linter. Custom Git hooks and +the ``pre-commit`` framework are integration mechanisms, so the last column +reflects a DIY approach rather than built-in product features. + +.. list-table:: + :header-rows: 1 + :widths: 36 18 18 28 + + * - Feature + - Commit Check ✅ + - commitlint + - Custom hooks + * - Conventional Commits enforcement + - ✅ + - ✅ + - DIY + * - Branch naming validation + - ✅ + - ❌ + - DIY + * - Author name / email validation + - ✅ + - ❌ + - DIY + * - Signed-off-by trailer enforcement + - ✅ + - ✅ + - DIY + * - Co-author ignore list + - ✅ + - ❌ + - DIY + * - Organization-level shared config + - ✅ + - ✅ + - DIY + * - Zero-config defaults + - ✅ + - ❌ + - ❌ + * - Works without Node.js + - ✅ + - ❌ + - Depends + * - Native TOML configuration + - ✅ + - ❌ + - Depends + * - Git hook / pre-commit integration + - ✅ + - Partial + - ✅ + * - CI/CD-friendly configuration + - ✅ + - Partial + - DIY + +For ``commitlint``, organization-level shared config is typically delivered via +shareable config packages or local files. ``DIY`` means you can implement a +capability with custom Git hooks or ``pre-commit`` scripts, but it is not +provided as a turnkey policy layer. + + Versioning ---------- diff --git a/commit_check/api.py b/commit_check/api.py new file mode 100644 index 0000000..b579b9b --- /dev/null +++ b/commit_check/api.py @@ -0,0 +1,274 @@ +"""Public Python API for commit-check. + +This module exposes a lightweight, import-friendly interface that AI agents, +automation scripts, and tooling can call **without spawning a subprocess**. +All functions return plain dicts so results are easy to serialise, log, or +forward to an LLM. + +Typical usage:: + + from commit_check.api import validate_message, validate_branch, validate_author + + result = validate_message("feat: add streaming support") + if result["status"] == "fail": + for check in result["checks"]: + if check["status"] == "fail": + print(check["error"]) + print(check["suggest"]) + +Return-value schema (all functions):: + + { + "status": "pass" | "fail", + "checks": [ + { + "check": "", + "status": "pass" | "fail", + "value": "", + "error": "", + "suggest": "", + }, + ... + ] + } +""" + +from __future__ import annotations + +import copy +from typing import Any, Dict, Optional + +from commit_check.config_merger import get_default_config +from commit_check.engine import ( + CheckOutcome, + ValidationContext, + ValidationEngine, +) +from commit_check.rule_builder import RuleBuilder + + +# --------------------------------------------------------------------------- +# Internal helpers +# --------------------------------------------------------------------------- + + +def _build_result(outcomes: list[CheckOutcome]) -> Dict[str, Any]: + """Convert a list of :class:`~commit_check.engine.CheckOutcome` into the + public return-value dict.""" + overall = "fail" if any(o.status == "fail" for o in outcomes) else "pass" + return { + "status": overall, + "checks": [o.to_dict() for o in outcomes], + } + + +def _run_checks( + check_names: list[str], + context: ValidationContext, + config: Dict[str, Any], +) -> Dict[str, Any]: + """Build rules, filter to *check_names*, run the engine, return result.""" + rule_builder = RuleBuilder(config) + all_rules = rule_builder.build_all_rules() + filtered = [r for r in all_rules if r.check in check_names] + engine = ValidationEngine(filtered) + outcomes = engine.validate_all_detailed(context) + return _build_result(outcomes) + + +def _merge_config(user_config: Optional[Dict[str, Any]]) -> Dict[str, Any]: + """Return the effective config: user overrides merged on top of defaults.""" + base = get_default_config() + if user_config: + from commit_check.config_merger import deep_merge + + # deep_copy the user config so that deep_merge cannot mutate the + # caller's dict (deep_merge operates in-place on `base`, and may + # assign nested objects from `override` directly into `base`). + deep_merge(base, copy.deepcopy(user_config)) + return base + + +# --------------------------------------------------------------------------- +# Public API +# --------------------------------------------------------------------------- + + +def validate_message( + message: str, + *, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Validate a commit message string. + + :param message: The full commit message to validate (subject + optional body). + :param config: Optional configuration dict in the same shape as ``cchk.toml``. + If *None*, built-in defaults are used. You can pass a partial dict to + override only the keys you care about, e.g. + ``{"commit": {"allow_commit_types": ["feat", "fix"]}}``. + :returns: A dict with ``"status"`` (``"pass"``/``"fail"``) and ``"checks"`` + (list of per-rule outcomes). + + Example:: + + >>> from commit_check.api import validate_message + >>> validate_message("feat: add streaming support")["status"] + 'pass' + >>> validate_message("WIP bad message")["status"] + 'fail' + """ + cfg = _merge_config(config) + context = ValidationContext(stdin_text=message.strip(), config=cfg) + check_names = [ + "message", + "subject_imperative", + "subject_max_length", + "subject_min_length", + "subject_capitalized", + "require_signed_off_by", + "require_body", + "allow_merge_commits", + "allow_revert_commits", + "allow_empty_commits", + "allow_fixup_commits", + "allow_wip_commits", + ] + return _run_checks(check_names, context, cfg) + + +def validate_branch( + branch: Optional[str] = None, + *, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Validate a branch name. + + :param branch: Branch name to validate. If *None*, the current git branch + is used (via ``git branch --show-current``). + :param config: Optional configuration override dict. + :returns: A dict with ``"status"`` and ``"checks"``. + + Example:: + + >>> from commit_check.api import validate_branch + >>> validate_branch("feature/add-json-output")["status"] + 'pass' + >>> validate_branch("bad_branch_name")["status"] + 'fail' + """ + cfg = _merge_config(config) + # Pass branch via stdin_text so BranchValidator picks it up without calling + # git. When branch is None the validator will fall back to git itself. + context = ValidationContext( + stdin_text=branch.strip() if branch else None, + config=cfg, + ) + return _run_checks(["branch", "merge_base"], context, cfg) + + +def validate_author( + name: Optional[str] = None, + email: Optional[str] = None, + *, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Validate commit author name and/or email. + + :param name: Author name to validate. If *None*, the value from + ``git config user.name`` is used. + :param email: Author email to validate. If *None*, the value from + ``git config user.email`` is used. + :param config: Optional configuration override dict. + :returns: A dict with ``"status"`` and ``"checks"``. + + Example:: + + >>> from commit_check.api import validate_author + >>> validate_author(name="Ada Lovelace", email="ada@example.com")["status"] + 'pass' + """ + cfg = _merge_config(config) + checks: list[str] = [] + if name is not None: + checks.append("author_name") + if email is not None: + checks.append("author_email") + if not checks: + # Validate both from git config + checks = ["author_name", "author_email"] + + # AuthorValidator reads from git config / git log when stdin_text is None. + # For an explicit single value we can only validate one at a time, so we + # run separate passes when both are supplied. + if name is not None and email is not None: + name_result = _run_checks( + ["author_name"], + ValidationContext(stdin_text=name.strip(), config=cfg), + cfg, + ) + email_result = _run_checks( + ["author_email"], + ValidationContext(stdin_text=email.strip(), config=cfg), + cfg, + ) + all_checks = name_result["checks"] + email_result["checks"] + overall = "fail" if any(c["status"] == "fail" for c in all_checks) else "pass" + return {"status": overall, "checks": all_checks} + + stdin = None + if name is not None: + stdin = name.strip() + elif email is not None: + stdin = email.strip() + + context = ValidationContext(stdin_text=stdin, config=cfg) + return _run_checks(checks, context, cfg) + + +def validate_all( + message: Optional[str] = None, + branch: Optional[str] = None, + author_name: Optional[str] = None, + author_email: Optional[str] = None, + *, + config: Optional[Dict[str, Any]] = None, +) -> Dict[str, Any]: + """Run all requested validations and return a combined result. + + This is the high-level entry point that mirrors the CLI ``commit-check -m -b`` + invocation but returns structured data instead of printing to the terminal. + + :param message: Commit message string to validate, or *None* to skip. + :param branch: Branch name to validate, or *None* to skip. + :param author_name: Author name to validate, or *None* to skip. + :param author_email: Author email to validate, or *None* to skip. + :param config: Optional configuration override dict. + :returns: A dict with ``"status"`` and ``"checks"`` combining all requested + validations. + + Example:: + + >>> from commit_check.api import validate_all + >>> result = validate_all( + ... message="feat: implement new feature", + ... branch="feature/new-feature", + ... ) + >>> result["status"] + 'pass' + """ + all_checks: list[Dict[str, Any]] = [] + + if message is not None: + msg_result = validate_message(message, config=config) + all_checks.extend(msg_result["checks"]) + + if branch is not None: + branch_result = validate_branch(branch, config=config) + all_checks.extend(branch_result["checks"]) + + if author_name is not None or author_email is not None: + author_result = validate_author(author_name, author_email, config=config) + all_checks.extend(author_result["checks"]) + + overall = "fail" if any(c["status"] == "fail" for c in all_checks) else "pass" + return {"status": overall, "checks": all_checks} diff --git a/commit_check/engine.py b/commit_check/engine.py index 75928be..c6f4aaa 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -33,11 +33,42 @@ class ValidationContext: config: Dict = field(default_factory=dict) +@dataclass +class CheckOutcome: + """Structured result of a single validation check. + + Returned by :meth:`ValidationEngine.validate_all_detailed` so that + callers (e.g. ``--format json`` output, the Python API) can inspect + individual check results without parsing human-readable terminal output. + """ + + check: str + status: str # "pass" or "fail" + value: str = "" + error: str = "" + suggest: str = "" + + def to_dict(self) -> Dict: + """Serialise to a plain dict (suitable for JSON encoding).""" + return { + "check": self.check, + "status": self.status, + "value": self.value, + "error": self.error, + "suggest": self.suggest, + } + + class BaseValidator(ABC): """Abstract base validator.""" def __init__(self, rule: ValidationRule): self.rule = rule + # Set to True by ValidationEngine.validate_all_detailed() to suppress + # human-readable terminal output while still collecting failure details. + self._suppress_output: bool = False + # Populated by _print_failure() on every failure, regardless of mode. + self._last_failure: Optional[Dict[str, str]] = None @abstractmethod def validate(self, context: ValidationContext) -> ValidationResult: @@ -113,12 +144,22 @@ def _should_skip_branch_validation(self, context: ValidationContext) -> bool: return context.stdin_text is None and not has_commits() def _print_failure(self, actual_value: str, regex_or_constraint: str = "") -> None: - """Print standardized failure message.""" - from commit_check.util import _print_failure - + """Record and (unless suppressed) print a standardised failure message.""" rule_dict = self.rule.to_dict() constraint = regex_or_constraint or rule_dict.get("regex", "") - _print_failure(rule_dict, constraint, actual_value) + + # Always store structured failure details for programmatic consumers. + self._last_failure = { + "check": self.rule.check, + "value": actual_value, + "error": self.rule.error or "", + "suggest": self.rule.suggest or "", + } + + if not self._suppress_output: + from commit_check.util import _print_failure + + _print_failure(rule_dict, constraint, actual_value) class CommitMessageValidator(BaseValidator): @@ -605,3 +646,45 @@ def validate_all(self, context: ValidationContext) -> ValidationResult: if ValidationResult.FAIL in results else ValidationResult.PASS ) + + def validate_all_detailed(self, context: ValidationContext) -> List[CheckOutcome]: + """Run all validations and return structured :class:`CheckOutcome` objects. + + Unlike :meth:`validate_all`, this method: + + * **Suppresses** all human-readable terminal output (ASCII art, colour). + * Returns one :class:`CheckOutcome` per rule so callers can inspect or + serialise individual check results (e.g. as JSON for AI agents). + + Example:: + + engine = ValidationEngine(rules) + outcomes = engine.validate_all_detailed(context) + failed = [o for o in outcomes if o.status == "fail"] + """ + outcomes: List[CheckOutcome] = [] + + for rule in self.rules: + validator_class = self.VALIDATOR_MAP.get(rule.check) + if not validator_class: + continue + + validator: BaseValidator = validator_class(rule) + validator._suppress_output = True # collect, don't print + result = validator.validate(context) + + if result == ValidationResult.FAIL: + failure = validator._last_failure or {} + outcomes.append( + CheckOutcome( + check=rule.check, + status="fail", + value=failure.get("value", ""), + error=failure.get("error", ""), + suggest=failure.get("suggest", ""), + ) + ) + else: + outcomes.append(CheckOutcome(check=rule.check, status="pass")) + + return outcomes diff --git a/commit_check/imperatives.py b/commit_check/imperatives.py index 53aa059..7188c1e 100644 --- a/commit_check/imperatives.py +++ b/commit_check/imperatives.py @@ -65,6 +65,7 @@ "disable", "display", "download", + "downgrade", "drop", "dump", "emit", @@ -188,6 +189,7 @@ "remove", "rename", "render", + "reorganize", "replace", "reply", "report", diff --git a/commit_check/main.py b/commit_check/main.py index cad9cca..a030996 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -1,13 +1,19 @@ """Modern commit-check CLI with clean architecture and TOML support.""" from __future__ import annotations +import json import sys import argparse from typing import Optional from commit_check.config_merger import ConfigMerger, parse_bool, parse_list, parse_int from commit_check.rule_builder import RuleBuilder -from commit_check.engine import ValidationEngine, ValidationContext, ValidationResult +from commit_check.engine import ( + ValidationEngine, + ValidationContext, + ValidationResult, + CheckOutcome, +) from . import __version__ @@ -98,6 +104,16 @@ def _get_parser() -> argparse.ArgumentParser: required=False, ) + check_group.add_argument( + "--format", + choices=["text", "json"], + default="text", + dest="output_format", + metavar="FORMAT", + help="output format: 'text' (default) for human-readable output, " + "'json' for machine-readable JSON (useful for AI agents and tooling)", + ) + # Commit message configuration options commit_group = parser.add_argument_group( "commit message options", "Configuration options for --message validation" @@ -389,7 +405,22 @@ def main() -> int: config=config_data, ) - # Run validation + # Run validation – choose output mode based on --format + output_format: str = getattr(args, "output_format", "text") + if output_format == "json": + outcomes: list[CheckOutcome] = engine.validate_all_detailed(context) + overall = "fail" if any(o.status == "fail" for o in outcomes) else "pass" + print( + json.dumps( + { + "status": overall, + "checks": [o.to_dict() for o in outcomes], + }, + indent=2, + ) + ) + return 0 if overall == "pass" else 1 + result = engine.validate_all(context) # Return appropriate exit code diff --git a/tests/api_test.py b/tests/api_test.py new file mode 100644 index 0000000..4d7692e --- /dev/null +++ b/tests/api_test.py @@ -0,0 +1,268 @@ +"""Tests for commit_check.api – the public Python API.""" + +import pytest +from unittest.mock import patch +from commit_check.api import ( + validate_message, + validate_branch, + validate_author, + validate_all, +) + + +class TestValidateMessage: + """Tests for validate_message().""" + + @pytest.mark.benchmark + def test_valid_conventional_commit_passes(self): + """A well-formed conventional commit message returns status='pass'.""" + result = validate_message("feat: add streaming endpoint") + assert result["status"] == "pass" + assert isinstance(result["checks"], list) + + @pytest.mark.benchmark + def test_invalid_commit_returns_fail(self): + """A non-conventional commit message returns status='fail'.""" + with patch("commit_check.engine.get_commit_info", return_value="test-user"): + result = validate_message("bad commit message without type") + assert result["status"] == "fail" + + @pytest.mark.benchmark + def test_failed_check_has_required_keys(self): + """Each failed check dict exposes check/status/value/error/suggest.""" + with patch("commit_check.engine.get_commit_info", return_value="test-user"): + result = validate_message("wrong format") + failed = [c for c in result["checks"] if c["status"] == "fail"] + assert len(failed) >= 1 + for check in failed: + assert "check" in check + assert "status" in check + assert "value" in check + assert "error" in check + assert "suggest" in check + + @pytest.mark.benchmark + def test_result_contains_check_names(self): + """Result checks list always contains the expected check names.""" + result = validate_message("docs: update readme") + check_names = {c["check"] for c in result["checks"]} + # The 'message' check must always be present + assert "message" in check_names + + @pytest.mark.benchmark + def test_no_terminal_output_produced(self, capsys): + """validate_message must not print anything to stdout or stderr.""" + with patch("commit_check.engine.get_commit_info", return_value="test-user"): + validate_message("bad commit no type") + captured = capsys.readouterr() + assert captured.out == "" + assert "Commit rejected" not in captured.err + + @pytest.mark.benchmark + def test_custom_config_restricts_types(self): + """Custom config limiting allowed types causes unknown types to fail.""" + cfg = {"commit": {"allow_commit_types": ["feat", "fix"]}} + # 'docs' type should now be disallowed + with patch("commit_check.engine.get_commit_info", return_value="test-user"): + result = validate_message("docs: update readme", config=cfg) + assert result["status"] == "fail" + + @pytest.mark.benchmark + def test_custom_config_pass(self): + """Custom config with explicit types still passes valid commits.""" + cfg = {"commit": {"allow_commit_types": ["feat", "fix", "docs"]}} + result = validate_message("feat: new feature", config=cfg) + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_fix_commit_passes(self): + """fix: type always passes with default config.""" + result = validate_message("fix: correct null pointer dereference") + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_commit_with_scope_passes(self): + """Commit with optional scope passes.""" + result = validate_message("feat(api): add user endpoint") + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_breaking_change_notation_passes(self): + """Commit with breaking-change '!' notation passes.""" + result = validate_message("feat!: remove legacy auth") + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_wip_commit_fails_by_default(self): + """WIP commits fail when allow_wip_commits=false (default in cchk.toml).""" + cfg = {"commit": {"allow_wip_commits": False}} + with patch("commit_check.engine.get_commit_info", return_value="test-user"): + result = validate_message("WIP: half-baked change", config=cfg) + assert result["status"] == "fail" + + @pytest.mark.benchmark + def test_empty_message_returns_fail(self): + """Empty commit messages fail the message check.""" + cfg = {"commit": {"allow_empty_commits": False}} + with patch("commit_check.engine.get_commit_info", return_value="test-user"): + result = validate_message("", config=cfg) + assert result["status"] == "fail" + + +class TestValidateBranch: + """Tests for validate_branch().""" + + @pytest.mark.benchmark + def test_valid_feature_branch_passes(self): + """feature/ branch passes conventional branch check.""" + result = validate_branch("feature/add-json-output") + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_valid_fix_branch_passes(self): + """fix/ branch passes.""" + result = validate_branch("fix/null-pointer") + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_main_branch_passes(self): + """'main' is always allowed.""" + result = validate_branch("main") + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_invalid_branch_fails(self): + """Branch name without a conventional prefix fails.""" + result = validate_branch("my_random_branch") + assert result["status"] == "fail" + + @pytest.mark.benchmark + def test_result_contains_branch_check(self): + """Result always contains a 'branch' check entry.""" + result = validate_branch("feature/test") + check_names = {c["check"] for c in result["checks"]} + assert "branch" in check_names + + @pytest.mark.benchmark + def test_no_terminal_output_produced(self, capsys): + """validate_branch must not print anything.""" + validate_branch("bad_branch_name") + captured = capsys.readouterr() + assert captured.out == "" + + @pytest.mark.benchmark + def test_custom_allowed_types(self): + """Custom branch types are respected.""" + cfg = {"branch": {"allow_branch_types": ["topic"]}} + result = validate_branch("topic/my-change", config=cfg) + assert result["status"] == "pass" + + +class TestValidateAuthor: + """Tests for validate_author().""" + + @pytest.mark.benchmark + def test_valid_name_and_email_pass(self): + """Valid name and email both pass.""" + result = validate_author(name="Ada Lovelace", email="ada@example.com") + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_invalid_email_fails(self): + """Email without '@' fails the author_email check.""" + result = validate_author(email="not-an-email") + assert result["status"] == "fail" + failed = [c for c in result["checks"] if c["status"] == "fail"] + assert any(c["check"] == "author_email" for c in failed) + + @pytest.mark.benchmark + def test_valid_email_passes(self): + """Valid email passes.""" + result = validate_author(email="dev@example.org") + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_no_terminal_output_produced(self, capsys): + """validate_author must not print anything.""" + validate_author(email="bad-email") + captured = capsys.readouterr() + assert captured.out == "" + + @pytest.mark.benchmark + def test_both_name_and_email_validated(self): + """When both name and email are passed, both checks appear in output.""" + result = validate_author(name="Jane Doe", email="jane@example.com") + check_names = {c["check"] for c in result["checks"]} + assert "author_name" in check_names + assert "author_email" in check_names + + +class TestValidateAll: + """Tests for validate_all().""" + + @pytest.mark.benchmark + def test_all_valid_returns_pass(self): + """Valid message and branch return combined pass.""" + result = validate_all( + message="feat: implement search", + branch="feature/implement-search", + ) + assert result["status"] == "pass" + + @pytest.mark.benchmark + def test_invalid_message_causes_fail(self): + """Invalid commit message causes overall fail.""" + with patch("commit_check.engine.get_commit_info", return_value="test-user"): + result = validate_all( + message="not a conventional commit", + branch="feature/something", + ) + assert result["status"] == "fail" + + @pytest.mark.benchmark + def test_invalid_branch_causes_fail(self): + """Invalid branch name causes overall fail.""" + result = validate_all( + message="feat: good commit", + branch="bad_branch", + ) + assert result["status"] == "fail" + + @pytest.mark.benchmark + def test_combined_checks_appear_in_result(self): + """Result checks list merges message and branch check entries.""" + result = validate_all( + message="fix: patch auth", + branch="fix/patch-auth", + ) + check_names = {c["check"] for c in result["checks"]} + assert "message" in check_names + assert "branch" in check_names + + @pytest.mark.benchmark + def test_no_args_returns_pass(self): + """Called with no args, validate_all returns pass with empty checks.""" + result = validate_all() + assert result["status"] == "pass" + assert result["checks"] == [] + + @pytest.mark.benchmark + def test_no_terminal_output(self, capsys): + """validate_all must not write to stdout or stderr.""" + with patch("commit_check.engine.get_commit_info", return_value="test"): + validate_all(message="bad message", branch="bad_branch") + captured = capsys.readouterr() + assert captured.out == "" + assert "Commit rejected" not in captured.err + + @pytest.mark.benchmark + def test_author_validation_included(self): + """Author checks appear in combined result when requested.""" + result = validate_all( + message="feat: add feature", + author_name="Valid Name", + author_email="valid@example.com", + ) + check_names = {c["check"] for c in result["checks"]} + assert "author_name" in check_names + assert "author_email" in check_names diff --git a/tests/main_test.py b/tests/main_test.py index 6cd50df..aa16443 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -1,3 +1,4 @@ +import json import sys import pytest import tempfile @@ -564,3 +565,99 @@ def test_positional_arg_nonexistent_file(self, mocker): result = main() # Should fall back to git and pass assert result == 0 + + +class TestJsonFormat: + """Tests for --format json machine-readable output.""" + + @pytest.mark.benchmark + def test_json_format_valid_message_returns_pass(self, mocker, capsys): + """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"] + rc = main() + + out, _ = capsys.readouterr() + data = json.loads(out) + assert rc == 0 + assert data["status"] == "pass" + assert isinstance(data["checks"], list) + 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): + """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"] + rc = main() + + out, _ = capsys.readouterr() + data = json.loads(out) + assert rc == 1 + assert data["status"] == "fail" + failed = [c for c in data["checks"] if c["status"] == "fail"] + assert len(failed) >= 1 + assert failed[0]["check"] == "message" + assert "error" in failed[0] and failed[0]["error"] + assert "suggest" in failed[0] and failed[0]["suggest"] + + @pytest.mark.benchmark + def test_json_format_no_ascii_art_in_stdout(self, mocker, capsys): + """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"] + main() + + out, _ = capsys.readouterr() + # Must be valid JSON + json.loads(out) + # No ANSI codes or ASCII art strings in the JSON output + assert "Commit rejected" not in out + assert "\033[" not in out + + @pytest.mark.benchmark + def test_json_format_from_file(self, capsys): + """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"] + rc = main() + out, _ = capsys.readouterr() + data = json.loads(out) + assert rc == 0 + assert data["status"] == "pass" + finally: + os.unlink(tmp_path) + + @pytest.mark.benchmark + def test_json_format_exit_code_matches_status(self, mocker, capsys): + """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"] + rc_pass = main() + out, _ = capsys.readouterr() + assert rc_pass == 0 + assert json.loads(out)["status"] == "pass" + + # --- fail case --- + 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"] + rc_fail = main() + out, _ = capsys.readouterr() + assert rc_fail == 1 + assert json.loads(out)["status"] == "fail"