From 2f232b889134887a82fdc90dd35cb80778dbaf8a Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Tue, 9 Sep 2025 21:32:51 +0300 Subject: [PATCH 01/37] docs: add new configuration docs --- docs/configuration.rst | 41 +++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 1 + 2 files changed, 42 insertions(+) create mode 100644 docs/configuration.rst diff --git a/docs/configuration.rst b/docs/configuration.rst new file mode 100644 index 00000000..c27c4bec --- /dev/null +++ b/docs/configuration.rst @@ -0,0 +1,41 @@ +Configuration +============= + +``commit-check`` configuration file support TOML format. + +See ``cchk.toml`` for the default configuration values. + + +commit-check can be configured via a ``cchk.toml``, ``commit-check.toml``, or in ``pyproject.toml`` file. + +The file should be placed in the root of your repository. + +.. code-block:: toml + + [commit] + conventional_commits = true + subject_capitalized = true + subject_imperative = true + subject_max_length = 50 + subject_min_length = 5 + allow_commit_types = ["feat", "fix", "docs", "style", "refactor", "test", "chore"] + allow_merge_commits = true + allow_revert_commits = true + allow_empty_commits = false + allow_fixup_commits = true + allow_wip_commits = false + require_body = false + + [branch] + conventional_branch = true + allow_branch_types = ["feature", "bugfix", "hotfix"] + require_rebase_target = "main" + + [author] + allow_authors = [] + ignore_authors = ["dependabot[bot]", "dependabot-preview[bot]"] + + [signed-off-by] + require_signed_off_by = true + required_signoff_name = "Your Name" + required_signoff_email = "your.email@example.com" diff --git a/docs/index.rst b/docs/index.rst index 61c26d5e..59ec79fc 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -4,6 +4,7 @@ :hidden: self + configuration .. toctree:: :hidden: From 251797b3d86fff27baecce1314654d1c8dbe8ec5 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 10 Sep 2025 10:01:53 +0300 Subject: [PATCH 02/37] docs: update new configuration docs --- cchk.toml | 27 +++++++++++ docs/conf.py | 10 ++-- docs/configuration.rst | 106 +++++++++++++++++++++++++++++++++++++++-- noxfile.py | 2 +- 4 files changed, 135 insertions(+), 10 deletions(-) create mode 100644 cchk.toml diff --git a/cchk.toml b/cchk.toml new file mode 100644 index 00000000..ea7ad611 --- /dev/null +++ b/cchk.toml @@ -0,0 +1,27 @@ +[commit] +# https://www.conventionalcommits.org +conventional_commits = true +subject_capitalized = true +subject_imperative = true +subject_max_length = 50 +subject_min_length = 5 +allow_commit_types = ["feat", "fix", "docs", "style", "refactor", "test", "chore"] +allow_merge_commits = true +allow_revert_commits = true +allow_empty_commits = false +allow_fixup_commits = true +allow_wip_commits = false +require_body = false + +[branch] +# https://conventional-branch.github.io/ +conventional_branch = true +allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] +require_rebase_target = "main" + +[author] +allow_authors = [] +ignore_authors = ["dependabot[bot]", "copilot[bot]"] +require_signed_off_by = true +required_signoff_name = "Your Name" +required_signoff_email = "your.email@example.com" diff --git a/docs/conf.py b/docs/conf.py index 2f3e1d9a..0e51deb2 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -40,7 +40,7 @@ html_logo = "_static/logo.jpg" # html_favicon = "_static/favicon.ico" html_css_files = ["extra_css.css"] -html_title = "commit-check" +html_title = "Commit Check" html_theme_options = { "repo_url": "https://github.com/commit-check/commit-check", @@ -49,8 +49,8 @@ { "media": "(prefers-color-scheme: light)", "scheme": "default", - "primary": "light-blue", - "accent": "deep-purple", + "primary": "blue", + "accent": "cyan", "toggle": { "icon": "material/lightbulb-outline", "name": "Switch to dark mode", @@ -59,8 +59,8 @@ { "media": "(prefers-color-scheme: dark)", "scheme": "slate", - "primary": "light-blue", - "accent": "deep-purple", + "primary": "blue", + "accent": "cyan", "toggle": { "icon": "material/lightbulb", "name": "Switch to light mode", diff --git a/docs/configuration.rst b/docs/configuration.rst index c27c4bec..f3f65a2d 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -6,13 +6,14 @@ Configuration See ``cchk.toml`` for the default configuration values. -commit-check can be configured via a ``cchk.toml``, ``commit-check.toml``, or in ``pyproject.toml`` file. +commit-check can be configured via a ``cchk.toml`` or ``commit-check.toml`` file. The file should be placed in the root of your repository. .. code-block:: toml [commit] + # https://www.conventionalcommits.org conventional_commits = true subject_capitalized = true subject_imperative = true @@ -27,15 +28,112 @@ The file should be placed in the root of your repository. require_body = false [branch] + # https://conventional-branch.github.io/ conventional_branch = true - allow_branch_types = ["feature", "bugfix", "hotfix"] + allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] require_rebase_target = "main" [author] allow_authors = [] ignore_authors = ["dependabot[bot]", "dependabot-preview[bot]"] - - [signed-off-by] require_signed_off_by = true required_signoff_name = "Your Name" required_signoff_email = "your.email@example.com" + + +options table description +------------------------- + +.. list-table:: + :header-rows: 1 + + * - Section + - Option + - Type + - Default + - Description + * - commit + - conventional_commits + - bool + - true + - Enforce Conventional Commits specification. + * - commit + - subject_capitalized + - bool + - true + - Subject must start with a capital letter. + * - commit + - subject_imperative + - bool + - true + - Subject must be in imperative mood. + * - commit + - subject_max_length + - int + - 50 + - Maximum length of the subject line. + * - commit + - subject_min_length + - int + - 5 + - Minimum length of the subject line. + * - commit + - allow_commit_types + - list[str] + - ["feat", "fix", "docs", "style", "refactor", "test", "chore"] + - Allowed commit types when conventional_commits is true. + * - commit + - allow_merge_commits + - bool + - true + - Allow merge commits. + * - commit + - allow_revert_commits + - bool + - true + - Allow revert commits. + * - commit + - allow_empty_commits + - bool + - false + - Allow empty commits. + * - commit + - allow_fixup_commits + - bool + - true + - Allow fixup commits (e.g., "fixup! "). + * - commit + - allow_wip_commits + - bool + - false + - Allow work-in-progress commits (e.g., "WIP: "). + * - commit + - require_body + - bool + - false + - Require a body in the commit message. + * - branch + - conventional_branch + - bool + - true + - Enforce Conventional Branch specification. + * - branch + - allow_branch_types + - list[str] + - ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] + - Allowed branch types when conventional_branch is true. + * - author + - allow_authors + - list[str] + - [] + - List of allowed authors. If empty, all authors are allowed except those in ignore_authors. + * - author + - ignore_authors + - list[str] + - ["dependabot[bot]", "dependabot-preview[bot]"] + - List of authors to ignore (i.e., always allow). + * - author + - require_signed_off_by + - bool + - true + - Require "Signed-off-by" line in the commit message footer. diff --git a/noxfile.py b/noxfile.py index 50599af3..d04b327f 100644 --- a/noxfile.py +++ b/noxfile.py @@ -64,4 +64,4 @@ def docs(session): @nox.session(name="docs-live") def docs_live(session): session.install('.[docs]') - session.run("sphinx-autobuild", "-b", "html", "docs", "_build/html") + session.run("sphinx-autobuild", "-b", "html", "docs", "_build/html", "--watch", "docs/") From 14daa97cb612b8ef6fe38bd0a3f7115c7a2c49f5 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 10 Sep 2025 10:03:00 +0300 Subject: [PATCH 03/37] docs: revert conf.py --- docs/conf.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 0e51deb2..f9725a6a 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -49,8 +49,8 @@ { "media": "(prefers-color-scheme: light)", "scheme": "default", - "primary": "blue", - "accent": "cyan", + "primary": "light-blue", + "accent": "deep-purple", "toggle": { "icon": "material/lightbulb-outline", "name": "Switch to dark mode", @@ -59,8 +59,8 @@ { "media": "(prefers-color-scheme: dark)", "scheme": "slate", - "primary": "blue", - "accent": "cyan", + "primary": "light-blue", + "accent": "deep-purple", "toggle": { "icon": "material/lightbulb", "name": "Switch to light mode", From b34ed02d7a4e2c0f4663de9882d519536c4731d7 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 10 Sep 2025 10:34:56 +0300 Subject: [PATCH 04/37] feat: remove .commit-check.yml and move config to rules --- .commit-check.yml | 39 ---------- commit_check/__init__.py | 74 +++---------------- commit_check/rules.py | 130 +++++++++++++++++++++++++++++++++ commit_check/util.py | 150 ++++++++++++++++++++++++++++++++++++--- 4 files changed, 278 insertions(+), 115 deletions(-) delete mode 100644 .commit-check.yml create mode 100644 commit_check/rules.py diff --git a/.commit-check.yml b/.commit-check.yml deleted file mode 100644 index d215d90d..00000000 --- a/.commit-check.yml +++ /dev/null @@ -1,39 +0,0 @@ -checks: - - check: message - regex: '^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)' - error: "The commit message should be structured as follows:\n\n - [optional scope]: \n - [optional body]\n - [optional footer(s)]\n\n - More details please refer to https://www.conventionalcommits.org" - suggest: please check your commit message whether matches above regex - - - check: branch - regex: ^(bugfix|feature|release|hotfix|task|chore)\/.+|(master)|(main)|(HEAD)|(PR-.+) - error: "Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ chore/" - suggest: run command `git checkout -b type/branch_name` - - - check: author_name - regex: ^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.\'-]+$|.*(\[bot]) - error: The committer name seems invalid - suggest: run command `git config user.name "Your Name"` - - - check: author_email - regex: ^.+@.+$ - error: The committer email seems invalid - suggest: run command `git config user.email yourname@example.com` - - - check: commit_signoff - regex: Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+> - error: Signed-off-by not found in latest commit - suggest: run command `git commit -m "conventional commit message" --signoff` - - - check: merge_base - regex: main # it can be master, develop, devel etc based on your project. - error: Current branch is not rebased onto target branch - suggest: Please ensure your branch is rebased with the target branch - - - check: imperative - regex: '' # Not used for imperative mood check - error: 'Commit message should use imperative mood (e.g., "Add feature" not "Added feature")' - suggest: 'Use imperative mood in commit message like "Add", "Fix", "Update", "Remove"' diff --git a/commit_check/__init__.py b/commit_check/__init__.py index 8b787628..666d63b5 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -1,73 +1,17 @@ """The commit-check package's base module.""" from importlib.metadata import version +from commit_check.rules import default_checks as _default_checks -RED = '\033[0;31m' -GREEN = "\033[32m" -YELLOW = '\033[93m' -RESET_COLOR = '\033[0m' - +# Exit codes used across the package PASS = 0 FAIL = 1 -""" -Use default config if .commit-check.yml not exist. -""" -DEFAULT_CONFIG = { - 'checks': [ - { - 'check': 'message', - 'regex': r'^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)', - 'error': 'The commit message should be structured as follows:\n\n' - '[optional scope]: \n' - '[optional body]\n' - '[optional footer(s)]\n\n' - 'More details please refer to https://www.conventionalcommits.org', - 'suggest': 'please check your commit message whether matches above regex' - }, - { - 'check': 'branch', - 'regex': r'^(bugfix|feature|release|hotfix|task|chore)\/.+|(master)|(main)|(HEAD)|(PR-.+)', - 'error': 'Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ chore/', - 'suggest': 'run command `git checkout -b type/branch_name`', - }, - { - 'check': 'author_name', - 'regex': r'^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.\'-]+$|.*(\[bot])', - 'error': 'The committer name seems invalid', - 'suggest': 'run command `git config user.name "Your Name"`', - }, - { - 'check': 'author_email', - 'regex': r'^.+@.+$', - 'error': 'The committer\'s email seems invalid', - 'suggest': 'run command `git config user.email yourname@example.com`', - }, - { - 'check': 'commit_signoff', - 'regex': r'Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>', - 'error': 'Signed-off-by not found in latest commit', - 'suggest': 'run command `git commit -m "conventional commit message" --signoff`', - }, - { - 'check': 'merge_base', - 'regex': r'main', # it can be master, develop, devel etc based on your project. - 'error': 'Current branch is not rebased onto target branch', - 'suggest': 'Please ensure your branch is rebased with the target branch', - }, - { - 'check': 'imperative', - 'regex': r'', # Not used for imperative mood check - 'error': 'Commit message should use imperative mood (e.g., "Add feature" not "Added feature")', - 'suggest': 'Use imperative mood in commit message like "Add", "Fix", "Update", "Remove"', - }, - ], -} - - -""" -Overwrite DEFAULT_CONFIG if `.commit-check.yml` exist. -""" - -CONFIG_FILE = '.commit-check.yml' +# ANSI color codes used for CLI output +RED = "\033[91m" +GREEN = "\033[92m" +YELLOW = "\033[93m" +RESET_COLOR = "\033[0m" +DEFAULT_CONFIG = { 'checks': _default_checks() } +CONFIG_FILE = '.' # Search current directory for commit-check.toml or cchk.toml __version__ = version("commit-check") diff --git a/commit_check/rules.py b/commit_check/rules.py new file mode 100644 index 00000000..f923f70e --- /dev/null +++ b/commit_check/rules.py @@ -0,0 +1,130 @@ +"""Centralized built-in rules and TOML translation for commit-check.""" +from __future__ import annotations +from typing import Any, Dict, List + + +def default_checks() -> List[Dict[str, Any]]: + return [ + { + 'check': 'message', + 'regex': r'^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)', + 'error': 'The commit message should be structured as follows:\n\n' + '[optional scope]: \n' + '[optional body]\n' + '[optional footer(s)]\n\n' + 'More details please refer to https://www.conventionalcommits.org', + 'suggest': 'please check your commit message whether matches above regex', + }, + { + 'check': 'branch', + 'regex': r'^(bugfix|feature|release|hotfix|task|chore)\/.+|(master)|(main)|(HEAD)|(PR-.+)', + 'error': 'Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ chore/', + 'suggest': 'run command `git checkout -b type/branch_name`', + }, + { + 'check': 'author_name', + 'regex': r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", + 'error': 'The committer name seems invalid', + 'suggest': 'run command `git config user.name "Your Name"`', + }, + { + 'check': 'author_email', + 'regex': r'^.+@.+$', + 'error': "The committer's email seems invalid", + 'suggest': 'run command `git config user.email yourname@example.com`', + }, + { + 'check': 'commit_signoff', + 'regex': r'Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>', + 'error': 'Signed-off-by not found in latest commit', + 'suggest': 'run command `git commit -m "conventional commit message" --signoff`', + }, + { + 'check': 'merge_base', + 'regex': r'main', + 'error': 'Current branch is not rebased onto target branch', + 'suggest': 'Please ensure your branch is rebased with the target branch', + }, + { + 'check': 'imperative', + 'regex': r'', # Not used for imperative mood check + 'error': 'Commit message should use imperative mood (e.g., "Add feature" not "Added feature")', + 'suggest': 'Use imperative mood in commit message like "Add", "Fix", "Update", "Remove"', + }, + ] + + +def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: + """Translate high-level TOML options into internal checks list.""" + checks: List[Dict[str, Any]] = [] + + commit_cfg = conf.get("commit", {}) or {} + branch_cfg = conf.get("branch", {}) or {} + author_cfg = conf.get("author", {}) or {} + + # message regex (Conventional Commits) + if commit_cfg.get("conventional_commits", True): + checks.append({ + "check": "message", + "regex": r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)", + "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", + "suggest": "Use (): with allowed types", + }) + + # Imperative mood check + if commit_cfg.get("subject_imperative", True): + checks.append({ + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood (e.g., 'Add feature' not 'Added feature')", + "suggest": "Use imperative mood in the subject line", + }) + + # Branch naming + if branch_cfg.get("conventional_branch", True): + allowed = branch_cfg.get("allow_branch_types") or [ + "bugfix", "feature", "release", "hotfix", "task", "chore", "feat", "fix", + ] + allowed_re = "|".join(sorted(set(allowed))) + regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" + checks.append({ + "check": "branch", + "regex": regex, + "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", + "suggest": "git checkout -b /", + }) + + # Merge base requirement + target = branch_cfg.get("require_rebase_target") + if isinstance(target, str) and target: + checks.append({ + "check": "merge_base", + "regex": target, + "error": "Current branch is not rebased onto target branch", + "suggest": "Rebase or merge with the target branch", + }) + + # Author checks (basic format validation retained) + checks.append({ + "check": "author_name", + "regex": r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", + "error": "The committer name seems invalid", + "suggest": "git config user.name 'Your Name'", + }) + checks.append({ + "check": "author_email", + "regex": r"^.+@.+$", + "error": "The committer's email seems invalid", + "suggest": "git config user.email yourname@example.com", + }) + + # Signoff requirement + if author_cfg.get("require_signed_off_by", False): + checks.append({ + "check": "commit_signoff", + "regex": r"Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>", + "error": "Signed-off-by not found in latest commit", + "suggest": "git commit --amend --signoff or use --signoff on commit", + }) + + return {"checks": checks} diff --git a/commit_check/util.py b/commit_check/util.py index ca62d63c..d88859f0 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -6,10 +6,21 @@ """ import subprocess -import yaml -from pathlib import PurePath +from pathlib import Path, PurePath from subprocess import CalledProcessError +import yaml from commit_check import RED, GREEN, YELLOW, RESET_COLOR +from commit_check.rules import build_checks_from_toml +from typing import Any, Dict, List, Optional + +# Prefer stdlib tomllib (3.11+); fall back to tomli if available; else disabled +try: # pragma: no cover - import paths differ by Python version + import tomllib as _toml # type: ignore[attr-defined] +except Exception: # pragma: no cover + try: + import tomli as _toml # type: ignore[no-redef] + except Exception: # pragma: no cover + _toml = None # type: ignore[assignment] def _find_check(checks: list, check_type: str) -> dict | None: @@ -122,19 +133,136 @@ def cmd_output(commands: list) -> str: return '' -def validate_config(path_to_config: str) -> dict: - """Validate config file. - :param path_to_config: path to config file +def _load_toml(path: PurePath) -> Dict[str, Any]: + """Load TOML from file, tolerant if toml support missing.""" + if _toml is None: + return {} + try: + with open(path, "rb") as f: + return _toml.load(f) # type: ignore[call-arg] + except FileNotFoundError: + return {} + except Exception: + return {} + - :returns: Get `dict` value if exist else get empty. +def _find_config_file(path_hint: str) -> Optional[PurePath]: + """Resolve config file. + + - If a directory is passed, search in priority: commit-check.toml, cchk.toml + - If a file ending with .toml is passed, use it if exists. + - Ignore legacy .commit-check.yml entirely. """ - configuration = {} + p = Path(path_hint) + if p.is_dir(): + for name in ("commit-check.toml", "cchk.toml"): + candidate = p / name + if candidate.exists(): + return candidate + return None + # If explicit file path provided + if str(p).endswith((".toml",)) and p.exists(): + return p + return None + + +def _build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: + """Translate high-level TOML options into internal checks list.""" + checks: List[Dict[str, Any]] = [] + + commit_cfg = conf.get("commit", {}) or {} + branch_cfg = conf.get("branch", {}) or {} + author_cfg = conf.get("author", {}) or {} + + # message regex (Conventional Commits) + if commit_cfg.get("conventional_commits", True): + checks.append({ + "check": "message", + "regex": r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)", + "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", + "suggest": "Use (): with allowed types", + }) + + # Imperative mood check + if commit_cfg.get("subject_imperative", True): + checks.append({ + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood (e.g., 'Add feature' not 'Added feature')", + "suggest": "Use imperative mood in the subject line", + }) + + # Branch naming + if branch_cfg.get("conventional_branch", True): + allowed = branch_cfg.get("allow_branch_types") or [ + "bugfix", "feature", "release", "hotfix", "task", "chore", "feat", "fix", + ] + allowed_re = "|".join(sorted(set(allowed))) + regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" + checks.append({ + "check": "branch", + "regex": regex, + "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", + "suggest": "git checkout -b /", + }) + + # Merge base requirement + target = branch_cfg.get("require_rebase_target") + if isinstance(target, str) and target: + checks.append({ + "check": "merge_base", + "regex": target, + "error": "Current branch is not rebased onto target branch", + "suggest": "Rebase or merge with the target branch", + }) + + # Author checks (basic format validation retained) + checks.append({ + "check": "author_name", + "regex": r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", + "error": "The committer name seems invalid", + "suggest": "git config user.name 'Your Name'", + }) + checks.append({ + "check": "author_email", + "regex": r"^.+@.+$", + "error": "The committer's email seems invalid", + "suggest": "git config user.email yourname@example.com", + }) + + # Signoff requirement + if author_cfg.get("require_signed_off_by", False): + checks.append({ + "check": "commit_signoff", + "regex": r"Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>", + "error": "Signed-off-by not found in latest commit", + "suggest": "git commit --amend --signoff or use --signoff on commit", + }) + + return {"checks": checks} + + +def validate_config(path_hint: str) -> dict: + """Validate and load configuration from TOML. + + Returns a dict containing a 'checks' list or empty dict if not found/invalid. + """ + cfg_path = _find_config_file(path_hint) + if cfg_path: + raw = _load_toml(cfg_path) + if not raw: + return {} + return build_checks_from_toml(raw) + + # Legacy YAML fallback (maintained for test compatibility) try: - with open(PurePath(path_to_config)) as f: - configuration = yaml.safe_load(f) + with open(PurePath(path_hint)) as f: + data = yaml.safe_load(f) # type: ignore[no-redef] + return data or {} except FileNotFoundError: - pass - return configuration + return {} + except Exception: + return {} def track_print_call(func): From 2f95c58b40103b8e0d76039e32d3f781e391fe68 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Wed, 10 Sep 2025 10:39:29 +0300 Subject: [PATCH 05/37] chore: code cleanup --- commit_check/rules.py | 2 +- commit_check/util.py | 80 ++----------------------------------------- 2 files changed, 3 insertions(+), 79 deletions(-) diff --git a/commit_check/rules.py b/commit_check/rules.py index f923f70e..012b0a8c 100644 --- a/commit_check/rules.py +++ b/commit_check/rules.py @@ -83,7 +83,7 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any # Branch naming if branch_cfg.get("conventional_branch", True): allowed = branch_cfg.get("allow_branch_types") or [ - "bugfix", "feature", "release", "hotfix", "task", "chore", "feat", "fix", + "bugfix", "feature", "release", "hotfix", "chore", "feat", "fix", ] allowed_re = "|".join(sorted(set(allowed))) regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" diff --git a/commit_check/util.py b/commit_check/util.py index d88859f0..9a660035 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -6,12 +6,12 @@ """ import subprocess +import yaml from pathlib import Path, PurePath +from typing import Any, Dict, Optional from subprocess import CalledProcessError -import yaml from commit_check import RED, GREEN, YELLOW, RESET_COLOR from commit_check.rules import build_checks_from_toml -from typing import Any, Dict, List, Optional # Prefer stdlib tomllib (3.11+); fall back to tomli if available; else disabled try: # pragma: no cover - import paths differ by Python version @@ -166,82 +166,6 @@ def _find_config_file(path_hint: str) -> Optional[PurePath]: return None -def _build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: - """Translate high-level TOML options into internal checks list.""" - checks: List[Dict[str, Any]] = [] - - commit_cfg = conf.get("commit", {}) or {} - branch_cfg = conf.get("branch", {}) or {} - author_cfg = conf.get("author", {}) or {} - - # message regex (Conventional Commits) - if commit_cfg.get("conventional_commits", True): - checks.append({ - "check": "message", - "regex": r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)", - "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", - "suggest": "Use (): with allowed types", - }) - - # Imperative mood check - if commit_cfg.get("subject_imperative", True): - checks.append({ - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood (e.g., 'Add feature' not 'Added feature')", - "suggest": "Use imperative mood in the subject line", - }) - - # Branch naming - if branch_cfg.get("conventional_branch", True): - allowed = branch_cfg.get("allow_branch_types") or [ - "bugfix", "feature", "release", "hotfix", "task", "chore", "feat", "fix", - ] - allowed_re = "|".join(sorted(set(allowed))) - regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" - checks.append({ - "check": "branch", - "regex": regex, - "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", - "suggest": "git checkout -b /", - }) - - # Merge base requirement - target = branch_cfg.get("require_rebase_target") - if isinstance(target, str) and target: - checks.append({ - "check": "merge_base", - "regex": target, - "error": "Current branch is not rebased onto target branch", - "suggest": "Rebase or merge with the target branch", - }) - - # Author checks (basic format validation retained) - checks.append({ - "check": "author_name", - "regex": r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", - "error": "The committer name seems invalid", - "suggest": "git config user.name 'Your Name'", - }) - checks.append({ - "check": "author_email", - "regex": r"^.+@.+$", - "error": "The committer's email seems invalid", - "suggest": "git config user.email yourname@example.com", - }) - - # Signoff requirement - if author_cfg.get("require_signed_off_by", False): - checks.append({ - "check": "commit_signoff", - "regex": r"Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>", - "error": "Signed-off-by not found in latest commit", - "suggest": "git commit --amend --signoff or use --signoff on commit", - }) - - return {"checks": checks} - - def validate_config(path_hint: str) -> dict: """Validate and load configuration from TOML. From 7229ed69e15a8aaa2f177cfde8c6d0bec1ac9cf8 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 12 Sep 2025 03:27:40 +0300 Subject: [PATCH 06/37] feat(breaking): update commit-check commands --- commit_check/author.py | 55 ++++++++++ commit_check/branch.py | 12 ++ commit_check/commit.py | 147 +++++++++++++++++++++++++ commit_check/main.py | 244 ++++++++++++++++++++--------------------- commit_check/rules.py | 142 ++++++++++++++++++++++-- docs/conf.py | 12 +- docs/configuration.rst | 11 +- noxfile.py | 7 +- pyproject.toml | 2 +- tests/main_test.py | 228 +++++++++++++------------------------- 10 files changed, 549 insertions(+), 311 deletions(-) diff --git a/commit_check/author.py b/commit_check/author.py index 1b59daa3..40f9c3c4 100644 --- a/commit_check/author.py +++ b/commit_check/author.py @@ -48,3 +48,58 @@ def check_author(checks: list, check_type: str, stdin_text: Optional[str] = None _print_failure(check, regex, value) return FAIL + + +# --- Additional per-option checks --- + +def check_allow_authors(checks: list, check_type: str, stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'allow_authors') + if not check: + return PASS + allowed = set(check.get('allowed') or []) + value = stdin_text if stdin_text is not None else _get_author_value(check_type) + if value in allowed: + return PASS + _print_failure(check, f'allowed={sorted(allowed)}', value) + return FAIL + + +def check_ignore_authors(checks: list, check_type: str, stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + rule = _find_check(checks, 'ignore_authors') + if not rule: + return PASS + ignored = set(rule.get('ignored') or []) + value = stdin_text if stdin_text is not None else _get_author_value(check_type) + if value in ignored: + return PASS + return PASS # ignore list only whitelists, no failure + + +def check_required_signoff_details(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + """If configured, ensure signoff includes specific name/email.""" + # Reuse existing signoff check result; only apply extra constraints if present + base = _find_check(checks, 'commit_signoff') + if not base: + return PASS + required_name = base.get('required_name') + required_email = base.get('required_email') + if not (required_name or required_email): + return PASS + # Read commit message (stdin_text here is the full commit message) + msg = stdin_text if stdin_text is not None else get_commit_info('b') + trailer = 'Signed-off-by:' in msg + if not trailer: + return PASS # let the main signoff check handle failure + ok = True + if required_name and required_name not in msg: + ok = False + if required_email and required_email not in msg: + ok = False + if ok: + return PASS + _print_failure(base, 'required signoff details', required_name or required_email or '') + return FAIL diff --git a/commit_check/branch.py b/commit_check/branch.py index 8e62f93d..3bc26a26 100644 --- a/commit_check/branch.py +++ b/commit_check/branch.py @@ -54,3 +54,15 @@ def check_merge_base(checks: list) -> int: _print_failure(check, regex, current_branch) return FAIL + + +# --- Additional per-option checks (aliases to existing ones) --- + +def check_conventional_branch(checks: list, stdin_text: Optional[str] = None) -> int: + """Alias to check_branch for explicit rule mapping.""" + return check_branch(checks, stdin_text=stdin_text) + + +def check_require_rebase_target(checks: list) -> int: + """Alias to check_merge_base for explicit rule mapping.""" + return check_merge_base(checks) diff --git a/commit_check/commit.py b/commit_check/commit.py index 57087ef4..6e5479c7 100644 --- a/commit_check/commit.py +++ b/commit_check/commit.py @@ -132,6 +132,153 @@ def check_imperative(checks: list, commit_msg_file: str = "", stdin_text: Option return FAIL +# --- Additional per-option checks (not yet wired into CLI) --- + +def _get_subject_and_body(stdin_text: Optional[str], commit_msg_file: str) -> tuple[str, str]: + if stdin_text is not None: + commit_msg = stdin_text + else: + path = _ensure_msg_file(commit_msg_file) + commit_msg = read_commit_msg(path) + subject = commit_msg.split('\n')[0].strip() + body = '\n'.join(commit_msg.split('\n')[1:]).strip() + return subject, body + + +def check_subject_capitalized(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'subject_capitalized') + if not check: + return PASS + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + if not subject or subject[0].isupper(): + return PASS + _print_failure(check, 'capitalized first letter', subject) + return FAIL + + +def check_subject_max_length(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'subject_max_length') + if not check: + return PASS + max_len = int(check.get('value', 0) or 0) + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + if not max_len or len(subject) <= max_len: + return PASS + _print_failure(check, f'max_length={max_len}', subject) + return FAIL + + +def check_subject_min_length(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'subject_min_length') + if not check: + return PASS + min_len = int(check.get('value', 0) or 0) + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + if len(subject) >= min_len: + return PASS + _print_failure(check, f'min_length={min_len}', subject) + return FAIL + + +def check_allow_commit_types(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'allow_commit_types') + if not check: + return PASS + allowed = set(check.get('allowed') or []) + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + ctype = subject.split(':', 1)[0].split('(')[0].strip() if ':' in subject else subject.split('(')[0].strip() + if ctype in allowed: + return PASS + _print_failure(check, f'allowed={sorted(allowed)}', subject) + return FAIL + + +def check_allow_merge_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'allow_merge_commits') + if not check: + return PASS + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + if subject.startswith('Merge'): + _print_failure(check, 'no merge commits', subject) + return FAIL + return PASS + + +def check_allow_revert_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'allow_revert_commits') + if not check: + return PASS + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + if subject.lower().startswith('revert'): + _print_failure(check, 'no revert commits', subject) + return FAIL + return PASS + + +def check_allow_empty_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'allow_empty_commits') + if not check: + return PASS + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + if subject: + return PASS + _print_failure(check, 'non-empty subject required', subject) + return FAIL + + +def check_allow_fixup_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'allow_fixup_commits') + if not check: + return PASS + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + if subject.startswith('fixup!'): + _print_failure(check, 'no fixup commits', subject) + return FAIL + return PASS + + +def check_allow_wip_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'allow_wip_commits') + if not check: + return PASS + subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + if subject.startswith('WIP') or subject.upper().startswith('WIP:'): + _print_failure(check, 'no WIP commits', subject) + return FAIL + return PASS + + +def check_require_body(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: + if stdin_text is None and has_commits() is False: + return PASS # pragma: no cover + check = _find_check(checks, 'require_body') + if not check: + return PASS + _, body = _get_subject_and_body(stdin_text, commit_msg_file) + if body: + return PASS + _print_failure(check, 'body required', '') + return FAIL + + def _is_imperative(description: str) -> bool: """Check if a description uses imperative mood.""" if not description: diff --git a/commit_check/main.py b/commit_check/main.py index 9e7b9f8e..87a723eb 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -1,150 +1,138 @@ -""" -``commit_check.main`` ---------------------- - -The module containing main entrypoint function. -""" -import argparse -import sys -from commit_check import branch -from commit_check import commit -from commit_check import author -from commit_check.util import validate_config -from commit_check.error import error_handler -from . import CONFIG_FILE, DEFAULT_CONFIG, PASS, FAIL, __version__ - - -def get_parser() -> argparse.ArgumentParser: - """Get and parser to interpret CLI args.""" - parser = argparse.ArgumentParser( - prog='commit-check', - description="Check commit message, branch naming, committer name, email, and more." - ) +"""Minimal argparse CLI. - parser.add_argument( - '-v', - '--version', - action='version', - version=f'%(prog)s {__version__}', - ) +Only command: run - parser.add_argument( - '-c', - '--config', - default=CONFIG_FILE, - help='path to config file. default is . (current directory)', - ) +Usage: + commit-check run [PATH] [--config FILE] [-v|-q|-s] [--version] - parser.add_argument( - '-m', - '--message', - help='check commit message', - action="store_true", - required=False, - ) - - parser.add_argument('commit_msg_file', nargs='?', help='commit message file') +Behavior: loads config and executes every defined check exactly once. +Exit codes: 0 all pass, 1 any fail. +""" - parser.add_argument( - '-b', - '--branch', - help='check branch naming', - action="store_true", - required=False, - ) +from __future__ import annotations +import sys +import argparse +from typing import Optional, Dict, Callable - parser.add_argument( - '-n', - '--author-name', - help='check committer\'s name', - action="store_true", - required=False, - ) +from commit_check import branch, commit, author +from commit_check.error import error_handler +from commit_check.util import validate_config +from . import DEFAULT_CONFIG, PASS, FAIL, __version__ - parser.add_argument( - '-e', - '--author-email', - help='check committer\'s email', - action="store_true", - required=False, - ) - parser.add_argument( - '-s', - '--commit-signoff', - help='check committer\'s signature', - action="store_true", - required=False, - ) +class LogLevel: + VERBOSE = 3 + QUIET = 1 + SILENT = 0 + NORMAL = 2 - parser.add_argument( - '-mb', - '--merge-base', - help='check branch is rebased onto target branch', - action="store_true", - required=False, - ) - parser.add_argument( - '-d', - '--dry-run', - help='run checks without failing', - action="store_true", - required=False, - ) +LOG_LEVEL = LogLevel.NORMAL - parser.add_argument( - '-i', - '--imperative', - help='check commit message uses imperative mood', - action="store_true", - required=False, - ) - return parser +def set_log_level(verbose: bool, quiet: bool, silent: bool) -> None: + global LOG_LEVEL + # Mutual exclusivity: priority silent > verbose > quiet > normal + if silent: + LOG_LEVEL = LogLevel.SILENT + elif verbose: + LOG_LEVEL = LogLevel.VERBOSE + elif quiet: + LOG_LEVEL = LogLevel.QUIET + else: + LOG_LEVEL = LogLevel.NORMAL -def main() -> int: - """The main entrypoint of commit-check program.""" - parser = get_parser() - args = parser.parse_args() +def log(msg: str, level: int = LogLevel.NORMAL) -> None: + if LOG_LEVEL == LogLevel.SILENT: + return + if LOG_LEVEL == LogLevel.QUIET and level > LogLevel.QUIET: + return + print(msg) - if args.dry_run: - return PASS - # Capture stdin (if piped) once and pass to checks. - stdin_text = None +def _read_stdin() -> Optional[str]: # read commit message content if piped try: if not sys.stdin.isatty(): data = sys.stdin.read() - stdin_text = data or None + return data or None except Exception: - stdin_text = None + return None + return None + + +def _dispatch_checks_full(checks: list, stdin_text: Optional[str]) -> int: + """Execute ALL configured checks once (used by future run mode).""" + dispatcher: Dict[str, Callable[[], int]] = { + 'message': lambda: commit.check_commit_msg(checks, stdin_text=stdin_text), + 'imperative': lambda: commit.check_imperative(checks, stdin_text=stdin_text), + 'branch': lambda: branch.check_branch(checks, stdin_text=stdin_text), + 'merge_base': lambda: branch.check_merge_base(checks), + 'author_name': lambda: author.check_author(checks, 'author_name', stdin_text=stdin_text), + 'author_email': lambda: author.check_author(checks, 'author_email', stdin_text=stdin_text), + 'commit_signoff': lambda: commit.check_commit_signoff(checks, stdin_text=stdin_text), + 'subject_capitalized': lambda: commit.check_subject_capitalized(checks, stdin_text=stdin_text), + 'subject_max_length': lambda: commit.check_subject_max_length(checks, stdin_text=stdin_text), + 'subject_min_length': lambda: commit.check_subject_min_length(checks, stdin_text=stdin_text), + 'allow_commit_types': lambda: commit.check_allow_commit_types(checks, stdin_text=stdin_text), + 'allow_merge_commits': lambda: commit.check_allow_merge_commits(checks, stdin_text=stdin_text), + 'allow_revert_commits': lambda: commit.check_allow_revert_commits(checks, stdin_text=stdin_text), + 'allow_empty_commits': lambda: commit.check_allow_empty_commits(checks, stdin_text=stdin_text), + 'allow_fixup_commits': lambda: commit.check_allow_fixup_commits(checks, stdin_text=stdin_text), + 'allow_wip_commits': lambda: commit.check_allow_wip_commits(checks, stdin_text=stdin_text), + 'require_body': lambda: commit.check_require_body(checks, stdin_text=stdin_text), + 'allow_authors': lambda: author.check_allow_authors(checks, 'author_name', stdin_text=stdin_text), + 'ignore_authors': lambda: author.check_ignore_authors(checks, 'author_name', stdin_text=stdin_text), + 'commit_signoff_details': lambda: author.check_required_signoff_details(checks, stdin_text=stdin_text), + } + seen = set() + results: list[int] = [] + for chk in checks: + ctype = chk.get('check') + if ctype in seen: + continue + seen.add(ctype) + func = dispatcher.get(ctype) + if func: + res = func() + results.append(res) + if LOG_LEVEL == LogLevel.VERBOSE: + log(f"[commit-check] {ctype} => {'OK' if res == PASS else 'FAIL'}") + return PASS if not results else (PASS if all(r == PASS for r in results) else FAIL) - check_results: list[int] = [] +def main() -> int: + argv = sys.argv[1:] + parser = argparse.ArgumentParser( + prog='commit-check', + description='check commit message, branch naming, committer name/email, commit signoff and more.' + ) + parser.add_argument('command', choices=['run'], help="Only supported command: run") + parser.add_argument('path', nargs='?', default='.', help='Repository path (default: current directory)') + parser.add_argument('--config', type=str, default=None, help='Path to TOML configuration file (commit-check.toml or cchk.toml)') + parser.add_argument('-v', '--verbose', action='store_true', help='Verbose logging') + parser.add_argument('-q', '--quiet', action='store_true', help='Quiet logging') + parser.add_argument('-s', '--silent', action='store_true', help='Silent mode') + parser.add_argument('-V', '--version', action='store_true', help='Show version and exit') + args = parser.parse_args(argv) + + if args.version: + print(__version__) + raise SystemExit(0) + + # Command guard (argparse choices already enforce) + if args.command != 'run': # pragma: no cover + parser.error("only 'run' is supported") + + set_log_level(args.verbose, args.quiet, args.silent) + cfg_path = args.config or args.path + stdin_text = _read_stdin() with error_handler(): - config = validate_config(args.config) if validate_config( - args.config, - ) else DEFAULT_CONFIG - checks = config['checks'] - if args.message: - check_results.append(commit.check_commit_msg(checks, args.commit_msg_file, stdin_text=stdin_text)) - if args.author_name: - check_results.append(author.check_author(checks, "author_name", stdin_text=stdin_text)) - if args.author_email: - check_results.append(author.check_author(checks, "author_email", stdin_text=stdin_text)) - if args.branch: - check_results.append(branch.check_branch(checks, stdin_text=stdin_text)) - if args.commit_signoff: - check_results.append(commit.check_commit_signoff(checks, args.commit_msg_file, stdin_text=stdin_text)) - if args.merge_base: - check_results.append(branch.check_merge_base(checks)) - if args.imperative: - check_results.append(commit.check_imperative(checks, args.commit_msg_file, stdin_text=stdin_text)) - - return PASS if all(val == PASS for val in check_results) else FAIL - - -if __name__ == '__main__': - raise SystemExit(main()) # pragma: no cover + cfg = validate_config(cfg_path) or DEFAULT_CONFIG + checks = cfg['checks'] + status = _dispatch_checks_full(checks, stdin_text=stdin_text) + return status + + +if __name__ == '__main__': # pragma: no cover + raise SystemExit(main()) diff --git a/commit_check/rules.py b/commit_check/rules.py index 012b0a8c..f9bb5a3a 100644 --- a/commit_check/rules.py +++ b/commit_check/rules.py @@ -55,23 +55,40 @@ def default_checks() -> List[Dict[str, Any]]: def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: - """Translate high-level TOML options into internal checks list.""" + """Translate high-level TOML options into internal checks list. + + Each documented option in docs/configuration.rst yields a corresponding + rule here. Regex remains internal; users do not provide regex. + """ checks: List[Dict[str, Any]] = [] commit_cfg = conf.get("commit", {}) or {} branch_cfg = conf.get("branch", {}) or {} author_cfg = conf.get("author", {}) or {} - # message regex (Conventional Commits) + # --- commit section --- if commit_cfg.get("conventional_commits", True): + allowed_types = commit_cfg.get("allow_commit_types") or [ + "feat", "fix", "docs", "style", "refactor", "test", "chore", + ] + allowed_re = "|".join(sorted(set(allowed_types))) + conv_regex = rf"^({allowed_re}){{1}}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)" checks.append({ "check": "message", - "regex": r"^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)", + "regex": conv_regex, "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", "suggest": "Use (): with allowed types", + "allowed_types": allowed_types, + }) + + if commit_cfg.get("subject_capitalized", True): + checks.append({ + "check": "subject_capitalized", + "regex": "", + "error": "Subject must start with a capital letter", + "suggest": "Capitalize the first word of the subject", }) - # Imperative mood check if commit_cfg.get("subject_imperative", True): checks.append({ "check": "imperative", @@ -80,10 +97,88 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any "suggest": "Use imperative mood in the subject line", }) - # Branch naming + max_len = commit_cfg.get("subject_max_length") + if isinstance(max_len, int): + checks.append({ + "check": "subject_max_length", + "regex": "", + "error": f"Subject must be at most {max_len} characters", + "suggest": "Keep the subject concise (<= configured max)", + "value": max_len, + }) + min_len = commit_cfg.get("subject_min_length") + if isinstance(min_len, int): + checks.append({ + "check": "subject_min_length", + "regex": "", + "error": f"Subject must be at least {min_len} characters", + "suggest": "Provide a meaningful subject (>= configured min)", + "value": min_len, + }) + + allowed_types_cfg = commit_cfg.get("allow_commit_types") + if isinstance(allowed_types_cfg, list) and allowed_types_cfg: + checks.append({ + "check": "allow_commit_types", + "regex": "", + "error": "Commit type is not in the allowed list", + "suggest": "Use an allowed type or update configuration", + "allowed": allowed_types_cfg, + }) + + if commit_cfg.get("allow_merge_commits", True) is False: + checks.append({ + "check": "allow_merge_commits", + "regex": "", + "error": "Merge commits are not allowed", + "suggest": "Rebase or squash your changes instead of merging", + "value": False, + }) + if commit_cfg.get("allow_revert_commits", True) is False: + checks.append({ + "check": "allow_revert_commits", + "regex": "", + "error": "Revert commits are not allowed", + "suggest": "Avoid using 'revert' commits; rewrite history if necessary", + "value": False, + }) + if commit_cfg.get("allow_empty_commits", False) is False: + checks.append({ + "check": "allow_empty_commits", + "regex": "", + "error": "Empty commit messages are not allowed", + "suggest": "Provide a non-empty subject", + "value": False, + }) + if commit_cfg.get("allow_fixup_commits", True) is False: + checks.append({ + "check": "allow_fixup_commits", + "regex": "", + "error": "Fixup commits are not allowed", + "suggest": "Use interactive rebase to clean up fixup commits", + "value": False, + }) + if commit_cfg.get("allow_wip_commits", False) is False: + checks.append({ + "check": "allow_wip_commits", + "regex": "", + "error": "WIP commits are not allowed", + "suggest": "Complete the work before committing or remove 'WIP'", + "value": False, + }) + if commit_cfg.get("require_body", False): + checks.append({ + "check": "require_body", + "regex": "", + "error": "Commit body is required", + "suggest": "Add a body explaining the change", + "value": True, + }) + + # --- branch section --- if branch_cfg.get("conventional_branch", True): allowed = branch_cfg.get("allow_branch_types") or [ - "bugfix", "feature", "release", "hotfix", "chore", "feat", "fix", + "feature", "bugfix", "hotfix", "release", "chore", "feat", "fix", ] allowed_re = "|".join(sorted(set(allowed))) regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" @@ -92,9 +187,9 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any "regex": regex, "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", "suggest": "git checkout -b /", + "allowed_types": allowed, }) - # Merge base requirement target = branch_cfg.get("require_rebase_target") if isinstance(target, str) and target: checks.append({ @@ -104,7 +199,7 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any "suggest": "Rebase or merge with the target branch", }) - # Author checks (basic format validation retained) + # --- author section --- checks.append({ "check": "author_name", "regex": r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", @@ -118,13 +213,38 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any "suggest": "git config user.email yourname@example.com", }) - # Signoff requirement - if author_cfg.get("require_signed_off_by", False): + allow_authors = author_cfg.get("allow_authors") + if isinstance(allow_authors, list) and allow_authors: + checks.append({ + "check": "allow_authors", + "regex": "", + "error": "Author is not allowed", + "suggest": "Use a configured author or adjust configuration", + "allowed": allow_authors, + }) + ignore_authors = author_cfg.get("ignore_authors") + if isinstance(ignore_authors, list) and ignore_authors: checks.append({ + "check": "ignore_authors", + "regex": "", + "error": "", + "suggest": "", + "ignored": ignore_authors, + }) + + if author_cfg.get("require_signed_off_by", False): + sign_name = author_cfg.get("required_signoff_name") + sign_email = author_cfg.get("required_signoff_email") + rule: Dict[str, Any] = { "check": "commit_signoff", "regex": r"Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>", "error": "Signed-off-by not found in latest commit", "suggest": "git commit --amend --signoff or use --signoff on commit", - }) + } + if sign_name: + rule["required_name"] = sign_name + if sign_email: + rule["required_email"] = sign_email + checks.append(rule) return {"checks": checks} diff --git a/docs/conf.py b/docs/conf.py index f9725a6a..7479df8f 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,9 +6,8 @@ import re import datetime from pathlib import Path -import io +import subprocess from sphinx.application import Sphinx -from commit_check.main import get_parser # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information @@ -103,13 +102,12 @@ def setup(app: Sphinx): """Generate a doc from the executable script's ``--help`` output.""" - with io.StringIO() as help_out: - parser = get_parser() - parser.print_help(help_out) - output = help_out.getvalue() + result = subprocess.run( + ["commit-check", "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8' + ) doc = "commit-check --help\n==============================\n\n" CLI_OPT_NAME = re.compile(r"^\s*(\-\w)\s?[A-Z_]*,\s(\-\-.*?)\s") - for line in output.splitlines(): + for line in result.stdout.splitlines(): match = CLI_OPT_NAME.search(line) if match is not None: # print(match.groups()) diff --git a/docs/configuration.rst b/docs/configuration.rst index f3f65a2d..9f4f5a98 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -26,6 +26,11 @@ The file should be placed in the root of your repository. allow_fixup_commits = true allow_wip_commits = false require_body = false + allow_authors = [] + ignore_authors = ["dependabot[bot]", "dependabot-preview[bot]"] + require_signed_off_by = true + required_signoff_name = "Your Name" + required_signoff_email = "your.email@example.com" [branch] # https://conventional-branch.github.io/ @@ -33,12 +38,6 @@ The file should be placed in the root of your repository. allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] require_rebase_target = "main" - [author] - allow_authors = [] - ignore_authors = ["dependabot[bot]", "dependabot-preview[bot]"] - require_signed_off_by = true - required_signoff_name = "Your Name" - required_signoff_email = "your.email@example.com" options table description diff --git a/noxfile.py b/noxfile.py index d04b327f..d3bdc34a 100644 --- a/noxfile.py +++ b/noxfile.py @@ -14,12 +14,7 @@ def lint(session): session.install("pre-commit") # only need pre-commit hook for local development session.run("pre-commit", "install", "--hook-type", "pre-commit") - if session.posargs: - args = session.posargs + ["--all-files"] - else: - args = ["--all-files", "--show-diff-on-failure"] - - session.run("pre-commit", "run", *args) + session.run("pre-commit", "run", "--all-files") @nox.session(name="test-hook") diff --git a/pyproject.toml b/pyproject.toml index b2e38487..1d04d8e2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,7 +12,7 @@ authors = [ { name = "Xianpeng Shen", email = "xianpeng.shen@gmail.com" }, ] requires-python = ">=3.9" -dependencies = ["pyyaml"] +dependencies = ["pyyaml", "typer"] classifiers = [ # https://pypi.org/pypi?%3Aaction=list_classifiers "Development Status :: 5 - Production/Stable", diff --git a/tests/main_test.py b/tests/main_test.py index 7675e4fa..8701b07b 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -7,183 +7,107 @@ class TestMain: - @pytest.mark.benchmark - @pytest.mark.parametrize("argv, check_commit_call_count, check_branch_call_count, check_author_call_count, check_commit_signoff_call_count, check_merge_base_call_count, check_imperative_call_count", [ - ([CMD, "--message"], 1, 0, 0, 0, 0, 0), - ([CMD, "--branch"], 0, 1, 0, 0, 0, 0), - ([CMD, "--author-name"], 0, 0, 1, 0, 0, 0), - ([CMD, "--author-email"], 0, 0, 1, 0, 0, 0), - ([CMD, "--commit-signoff"], 0, 0, 0, 1, 0, 0), - ([CMD, "--merge-base"], 0, 0, 0, 0, 1, 0), - ([CMD, "--imperative"], 0, 0, 0, 0, 0, 1), - ([CMD, "--message", "--author-email"], 1, 0, 1, 0, 0, 0), - ([CMD, "--branch", "--message"], 1, 1, 0, 0, 0, 0), - ([CMD, "--author-name", "--author-email"], 0, 0, 2, 0, 0, 0), - ([CMD, "--message", "--branch", "--author-email"], 1, 1, 1, 0, 0, 0), - ([CMD, "--branch", "--message", "--author-name", "--author-email"], 1, 1, 2, 0, 0, 0), - ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base"], 1, 1, 2, 1, 1, 0), - ([CMD, "--message", "--imperative"], 1, 0, 0, 0, 0, 1), - ([CMD, "--dry-run"], 0, 0, 0, 0, 0, 0), - ]) - def test_main( - self, - mocker, - argv, - check_commit_call_count, - check_branch_call_count, - check_author_call_count, - check_commit_signoff_call_count, - check_merge_base_call_count, - check_imperative_call_count, - ): + def test_full_run_invokes_each_check_once(self, mocker): + """Given a config with several check types, ensure each dispatcher target is invoked exactly once.""" mocker.patch( "commit_check.main.validate_config", return_value={ "checks": [ - {"check": "dummy_check_type"} + {"check": "message"}, + {"check": "branch"}, + {"check": "author_name"}, + {"check": "author_email"}, + {"check": "commit_signoff"}, + {"check": "merge_base"}, + {"check": "imperative"}, ] } ) - m_check_commit = mocker.patch("commit_check.commit.check_commit_msg") - m_check_branch = mocker.patch("commit_check.branch.check_branch") - m_check_author = mocker.patch("commit_check.author.check_author") - m_check_commit_signoff = mocker.patch("commit_check.commit.check_commit_signoff") - m_check_merge_base = mocker.patch("commit_check.branch.check_merge_base") - m_check_imperative = mocker.patch("commit_check.commit.check_imperative") - sys.argv = argv - main() - assert m_check_commit.call_count == check_commit_call_count - assert m_check_branch.call_count == check_branch_call_count - assert m_check_author.call_count == check_author_call_count - assert m_check_commit_signoff.call_count == check_commit_signoff_call_count - assert m_check_merge_base.call_count == check_merge_base_call_count - assert m_check_imperative.call_count == check_imperative_call_count + m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) + m_branch = mocker.patch("commit_check.branch.check_branch", return_value=PASS) + m_author = mocker.patch("commit_check.author.check_author", return_value=PASS) + m_signoff = mocker.patch("commit_check.commit.check_commit_signoff", return_value=PASS) + m_merge = mocker.patch("commit_check.branch.check_merge_base", return_value=PASS) + m_imperative = mocker.patch("commit_check.commit.check_imperative", return_value=PASS) + sys.argv = [CMD, "run"] + assert main() == PASS + assert m_msg.call_count == 1 + assert m_branch.call_count == 1 + # author_name + author_email => 2 invocations + assert m_author.call_count == 2 + assert m_signoff.call_count == 1 + assert m_merge.call_count == 1 + assert m_imperative.call_count == 1 - @pytest.mark.benchmark - def test_main_help(self, mocker, capfd): - mocker.patch( - "commit_check.main.validate_config", - return_value={ - "checks": [ - {"check": "dummy_check_type"} - ] - } - ) - m_check_commit = mocker.patch("commit_check.commit.check_commit_msg") - m_check_branch = mocker.patch("commit_check.branch.check_branch") - m_check_author = mocker.patch("commit_check.author.check_author") - m_check_commit_signoff = mocker.patch("commit_check.commit.check_commit_signoff") - m_check_merge_base = mocker.patch("commit_check.branch.check_merge_base") - sys.argv = ["commit-check", "--h"] + def test_help(self, capfd): + sys.argv = [CMD, "run", "--help"] with pytest.raises(SystemExit): main() - assert m_check_commit.call_count == 0 - assert m_check_branch.call_count == 0 - assert m_check_author.call_count == 0 - assert m_check_commit_signoff.call_count == 0 - assert m_check_merge_base.call_count == 0 - stdout, _ = capfd.readouterr() - assert "usage: " in stdout + out, _ = capfd.readouterr() + assert "usage:" in out - @pytest.mark.benchmark - def test_main_version(self, mocker): - mocker.patch( - "commit_check.main.validate_config", - return_value={ - "checks": [ - {"check": "dummy_check_type"} - ] - } - ) - m_check_commit = mocker.patch("commit_check.commit.check_commit_msg") - m_check_branch = mocker.patch("commit_check.branch.check_branch") - m_check_author = mocker.patch("commit_check.author.check_author") - m_check_commit_signoff = mocker.patch("commit_check.commit.check_commit_signoff") - m_check_merge_base = mocker.patch("commit_check.branch.check_merge_base") - sys.argv = ["commit-check", "--v"] + def test_version(self): + sys.argv = [CMD, "run", "-V"] with pytest.raises(SystemExit): main() - assert m_check_commit.call_count == 0 - assert m_check_branch.call_count == 0 - assert m_check_author.call_count == 0 - assert m_check_commit_signoff.call_count == 0 - assert m_check_merge_base.call_count == 0 - @pytest.mark.benchmark - def test_main_validate_config_ret_none(self, mocker): - mocker.patch( - "commit_check.main.validate_config", - return_value={} - ) - m_check_commit = mocker.patch("commit_check.commit.check_commit_msg") - mocker.patch("commit_check.branch.check_branch") - mocker.patch("commit_check.author.check_author") - mocker.patch("commit_check.commit.check_commit_signoff") - mocker.patch("commit_check.branch.check_merge_base") - sys.argv = ["commit-check", "--message"] + def test_default_config_used_when_validate_returns_empty(self, mocker): + mocker.patch("commit_check.main.validate_config", return_value={}) + m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) + mocker.patch("commit_check.branch.check_branch", return_value=PASS) + mocker.patch("commit_check.author.check_author", return_value=PASS) + mocker.patch("commit_check.commit.check_commit_signoff", return_value=PASS) + mocker.patch("commit_check.branch.check_merge_base", return_value=PASS) + mocker.patch("commit_check.commit.check_imperative", return_value=PASS) + sys.argv = [CMD, "run"] main() - assert m_check_commit.call_count == 1 - assert m_check_commit.call_args[0][0] == DEFAULT_CONFIG["checks"] + # first positional arg to check_commit_msg is the list of checks + assert m_msg.call_args[0][0] == DEFAULT_CONFIG["checks"] - @pytest.mark.benchmark @pytest.mark.parametrize( - "argv, message_result, branch_result, author_name_result, author_email_result, commit_signoff_result, merge_base_result, final_result", + "message_result, branch_result, author_name_result, author_email_result, signoff_result, merge_base_result, imperative_result, expected", [ - ([CMD, "--message"], PASS, PASS, PASS, PASS, PASS, PASS, PASS), - ([CMD, "--message"], FAIL, PASS, PASS, PASS, PASS, PASS, FAIL), - ([CMD, "--message", "--commit-signoff"], FAIL, PASS, PASS, PASS, PASS, PASS, FAIL,), - ([CMD, "--message", "--commit-signoff"], PASS, PASS, PASS, PASS, FAIL, PASS, FAIL,), - ([CMD, "--message", "--author-name", "--author-email"], PASS, PASS, PASS, PASS, PASS, PASS, PASS,), - ([CMD, "--message", "--author-name", "--author-email"], FAIL, PASS, PASS, PASS, PASS, PASS, FAIL,), - ([CMD, "--message", "--author-name", "--author-email"], PASS, PASS, FAIL, PASS, PASS, PASS, FAIL,), - ([CMD, "--message", "--author-name", "--author-email"], PASS, PASS, PASS, FAIL, PASS, PASS, FAIL,), - ([CMD, "--message", "--author-name", "--author-email"], PASS, PASS, FAIL, FAIL, PASS, PASS, FAIL,), - ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base", ], PASS, PASS, PASS, PASS, PASS, PASS, PASS,), - ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base", ], FAIL, FAIL, FAIL, FAIL, FAIL, FAIL, FAIL,), - ([CMD, "--message", "--branch", "--author-name", "--author-email", "--commit-signoff", "--merge-base", ], FAIL, PASS, PASS, PASS, PASS, PASS, FAIL,), - ([CMD, "--dry-run"], FAIL, FAIL, FAIL, FAIL, FAIL, FAIL, PASS), + (PASS, PASS, PASS, PASS, PASS, PASS, PASS, PASS), + (FAIL, PASS, PASS, PASS, PASS, PASS, PASS, FAIL), + (PASS, PASS, FAIL, PASS, PASS, PASS, PASS, FAIL), + (PASS, PASS, PASS, FAIL, PASS, PASS, PASS, FAIL), + (PASS, PASS, PASS, PASS, FAIL, PASS, PASS, FAIL), + (PASS, PASS, PASS, PASS, PASS, FAIL, PASS, FAIL), + (PASS, PASS, PASS, PASS, PASS, PASS, FAIL, FAIL), ], ) - def test_main_multiple_checks( - self, - mocker, - argv, - message_result, - branch_result, - author_name_result, - author_email_result, - commit_signoff_result, - merge_base_result, - final_result, - ): + def test_exit_code_aggregation(self, mocker, message_result, branch_result, author_name_result, author_email_result, signoff_result, merge_base_result, imperative_result, expected): + # configure all check types mocker.patch( "commit_check.main.validate_config", - return_value={}, - ) - - mocker.patch( - "commit_check.commit.check_commit_msg", return_value=message_result, stdin_text=None - ) - mocker.patch( - "commit_check.commit.check_commit_signoff", - return_value=commit_signoff_result, stdin_text=None + return_value={ + "checks": [ + {"check": "message"}, + {"check": "branch"}, + {"check": "author_name"}, + {"check": "author_email"}, + {"check": "commit_signoff"}, + {"check": "merge_base"}, + {"check": "imperative"}, + ] + } ) - mocker.patch("commit_check.branch.check_branch", return_value=branch_result, stdin_text=None) - mocker.patch( - "commit_check.branch.check_merge_base", return_value=merge_base_result, stdin_text=None - ) - mocker.patch("commit_check.commit.check_imperative", return_value=PASS, stdin_text=None) + mocker.patch("commit_check.commit.check_commit_msg", return_value=message_result) + mocker.patch("commit_check.branch.check_branch", return_value=branch_result) - # Route author check results based on check_type while tolerating extra kwargs - def author_side_effect(_, check_type: str, **kwargs) -> int: # type: ignore[return] - assert check_type in ("author_name", "author_email") - if check_type == "author_name": - return author_name_result - elif check_type == "author_email": - return author_email_result + def author_side_effect(_, which, **_kw): # type: ignore[return] + return author_name_result if which == "author_name" else author_email_result mocker.patch("commit_check.author.check_author", side_effect=author_side_effect) + mocker.patch("commit_check.commit.check_commit_signoff", return_value=signoff_result) + mocker.patch("commit_check.branch.check_merge_base", return_value=merge_base_result) + mocker.patch("commit_check.commit.check_imperative", return_value=imperative_result) + sys.argv = [CMD, "run"] + assert main() == expected - sys.argv = argv - assert main() == final_result + def test_unknown_check_type_ignored(self, mocker): + mocker.patch("commit_check.main.validate_config", return_value={"checks": [{"check": "totally_unknown"}]}) + # no dispatcher functions patched intentionally + sys.argv = [CMD, "run"] + assert main() == PASS From 76cc6e3f952919254cdaa9812c1eb6745737f701 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 12 Sep 2025 03:32:32 +0300 Subject: [PATCH 07/37] ci: update ci to pass failure --- cchk.toml | 12 ++++-------- noxfile.py | 2 +- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/cchk.toml b/cchk.toml index ea7ad611..5606078e 100644 --- a/cchk.toml +++ b/cchk.toml @@ -1,7 +1,7 @@ [commit] # https://www.conventionalcommits.org conventional_commits = true -subject_capitalized = true +subject_capitalized = false subject_imperative = true subject_max_length = 50 subject_min_length = 5 @@ -12,16 +12,12 @@ allow_empty_commits = false allow_fixup_commits = true allow_wip_commits = false require_body = false +allow_authors = [] +ignore_authors = ["dependabot[bot]", "copilot[bot]"] +require_signed_off_by = false [branch] # https://conventional-branch.github.io/ conventional_branch = true allow_branch_types = ["feature", "bugfix", "hotfix", "release", "chore", "feat", "fix"] require_rebase_target = "main" - -[author] -allow_authors = [] -ignore_authors = ["dependabot[bot]", "copilot[bot]"] -require_signed_off_by = true -required_signoff_name = "Your Name" -required_signoff_email = "your.email@example.com" diff --git a/noxfile.py b/noxfile.py index d3bdc34a..6bccf045 100644 --- a/noxfile.py +++ b/noxfile.py @@ -39,7 +39,7 @@ def install_wheel(session): @nox.session(name="commit-check") def commit_check(session): session.install(".") - session.run("commit-check", "--message", "--branch", "--author-email") + session.run("commit-check", "run") @nox.session() From decded8c54aa044dbbf4760a224cb018ff0aca70 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 12 Sep 2025 03:41:46 +0300 Subject: [PATCH 08/37] fix: update rules.py and cchk.toml --- cchk.toml | 2 +- commit_check/rules.py | 27 +++++++-------------------- 2 files changed, 8 insertions(+), 21 deletions(-) diff --git a/cchk.toml b/cchk.toml index 5606078e..3982299b 100644 --- a/cchk.toml +++ b/cchk.toml @@ -5,7 +5,7 @@ subject_capitalized = false subject_imperative = true subject_max_length = 50 subject_min_length = 5 -allow_commit_types = ["feat", "fix", "docs", "style", "refactor", "test", "chore"] +allow_commit_types = ["feat", "fix", "docs", "style", "refactor", "test", "chore", "ci"] allow_merge_commits = true allow_revert_commits = true allow_empty_commits = false diff --git a/commit_check/rules.py b/commit_check/rules.py index f9bb5a3a..2165c4b6 100644 --- a/commit_check/rules.py +++ b/commit_check/rules.py @@ -67,18 +67,14 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any author_cfg = conf.get("author", {}) or {} # --- commit section --- - if commit_cfg.get("conventional_commits", True): - allowed_types = commit_cfg.get("allow_commit_types") or [ - "feat", "fix", "docs", "style", "refactor", "test", "chore", - ] - allowed_re = "|".join(sorted(set(allowed_types))) - conv_regex = rf"^({allowed_re}){{1}}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)" + allowed_types_cfg = commit_cfg.get("allow_commit_types") + if isinstance(allowed_types_cfg, list) and allowed_types_cfg: checks.append({ - "check": "message", - "regex": conv_regex, - "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", - "suggest": "Use (): with allowed types", - "allowed_types": allowed_types, + "check": "allow_commit_types", + "regex": "", + "error": "Commit type is not in the allowed list", + "suggest": "Use an allowed type or update configuration", + "allowed": allowed_types_cfg, }) if commit_cfg.get("subject_capitalized", True): @@ -116,15 +112,6 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any "value": min_len, }) - allowed_types_cfg = commit_cfg.get("allow_commit_types") - if isinstance(allowed_types_cfg, list) and allowed_types_cfg: - checks.append({ - "check": "allow_commit_types", - "regex": "", - "error": "Commit type is not in the allowed list", - "suggest": "Use an allowed type or update configuration", - "allowed": allowed_types_cfg, - }) if commit_cfg.get("allow_merge_commits", True) is False: checks.append({ From 66860c9784639c0505b9eda6dabd574e591400ee Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 12 Sep 2025 04:20:29 +0300 Subject: [PATCH 09/37] fix: code cleanup and fix tests --- commit_check/__init__.py | 15 +++++-- commit_check/main.py | 14 +++---- commit_check/rules.py | 84 +++++++++++----------------------------- tests/main_test.py | 2 +- 4 files changed, 42 insertions(+), 73 deletions(-) diff --git a/commit_check/__init__.py b/commit_check/__init__.py index 666d63b5..0b446b0a 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -1,6 +1,13 @@ -"""The commit-check package's base module.""" +"""The commit-check package's base module. + +Exports: + PASS / FAIL exit codes + DEFAULT_CONFIG: minimal default rule set used when no config found + ANSI color constants + __version__ (package version) +""" from importlib.metadata import version -from commit_check.rules import default_checks as _default_checks +from commit_check.rules import build_checks_from_toml as _build_checks_from_toml # Exit codes used across the package PASS = 0 @@ -12,6 +19,8 @@ YELLOW = "\033[93m" RESET_COLOR = "\033[0m" -DEFAULT_CONFIG = { 'checks': _default_checks() } +# Default (empty) configuration translated into internal checks structure +DEFAULT_CONFIG = _build_checks_from_toml({}) + CONFIG_FILE = '.' # Search current directory for commit-check.toml or cchk.toml __version__ = version("commit-check") diff --git a/commit_check/main.py b/commit_check/main.py index 87a723eb..2bb54512 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -17,7 +17,7 @@ from commit_check import branch, commit, author from commit_check.error import error_handler from commit_check.util import validate_config -from . import DEFAULT_CONFIG, PASS, FAIL, __version__ +from . import PASS, FAIL, __version__, DEFAULT_CONFIG class LogLevel: @@ -66,11 +66,6 @@ def _dispatch_checks_full(checks: list, stdin_text: Optional[str]) -> int: dispatcher: Dict[str, Callable[[], int]] = { 'message': lambda: commit.check_commit_msg(checks, stdin_text=stdin_text), 'imperative': lambda: commit.check_imperative(checks, stdin_text=stdin_text), - 'branch': lambda: branch.check_branch(checks, stdin_text=stdin_text), - 'merge_base': lambda: branch.check_merge_base(checks), - 'author_name': lambda: author.check_author(checks, 'author_name', stdin_text=stdin_text), - 'author_email': lambda: author.check_author(checks, 'author_email', stdin_text=stdin_text), - 'commit_signoff': lambda: commit.check_commit_signoff(checks, stdin_text=stdin_text), 'subject_capitalized': lambda: commit.check_subject_capitalized(checks, stdin_text=stdin_text), 'subject_max_length': lambda: commit.check_subject_max_length(checks, stdin_text=stdin_text), 'subject_min_length': lambda: commit.check_subject_min_length(checks, stdin_text=stdin_text), @@ -80,7 +75,12 @@ def _dispatch_checks_full(checks: list, stdin_text: Optional[str]) -> int: 'allow_empty_commits': lambda: commit.check_allow_empty_commits(checks, stdin_text=stdin_text), 'allow_fixup_commits': lambda: commit.check_allow_fixup_commits(checks, stdin_text=stdin_text), 'allow_wip_commits': lambda: commit.check_allow_wip_commits(checks, stdin_text=stdin_text), + 'commit_signoff': lambda: commit.check_commit_signoff(checks, stdin_text=stdin_text), 'require_body': lambda: commit.check_require_body(checks, stdin_text=stdin_text), + 'branch': lambda: branch.check_branch(checks, stdin_text=stdin_text), + 'merge_base': lambda: branch.check_merge_base(checks), + 'author_name': lambda: author.check_author(checks, 'author_name', stdin_text=stdin_text), + 'author_email': lambda: author.check_author(checks, 'author_email', stdin_text=stdin_text), 'allow_authors': lambda: author.check_allow_authors(checks, 'author_name', stdin_text=stdin_text), 'ignore_authors': lambda: author.check_ignore_authors(checks, 'author_name', stdin_text=stdin_text), 'commit_signoff_details': lambda: author.check_required_signoff_details(checks, stdin_text=stdin_text), @@ -129,7 +129,7 @@ def main() -> int: stdin_text = _read_stdin() with error_handler(): cfg = validate_config(cfg_path) or DEFAULT_CONFIG - checks = cfg['checks'] + checks = cfg.get('checks', []) status = _dispatch_checks_full(checks, stdin_text=stdin_text) return status diff --git a/commit_check/rules.py b/commit_check/rules.py index 2165c4b6..4ffa2bec 100644 --- a/commit_check/rules.py +++ b/commit_check/rules.py @@ -3,57 +3,6 @@ from typing import Any, Dict, List -def default_checks() -> List[Dict[str, Any]]: - return [ - { - 'check': 'message', - 'regex': r'^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test){1}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)', - 'error': 'The commit message should be structured as follows:\n\n' - '[optional scope]: \n' - '[optional body]\n' - '[optional footer(s)]\n\n' - 'More details please refer to https://www.conventionalcommits.org', - 'suggest': 'please check your commit message whether matches above regex', - }, - { - 'check': 'branch', - 'regex': r'^(bugfix|feature|release|hotfix|task|chore)\/.+|(master)|(main)|(HEAD)|(PR-.+)', - 'error': 'Branches must begin with these types: bugfix/ feature/ release/ hotfix/ task/ chore/', - 'suggest': 'run command `git checkout -b type/branch_name`', - }, - { - 'check': 'author_name', - 'regex': r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", - 'error': 'The committer name seems invalid', - 'suggest': 'run command `git config user.name "Your Name"`', - }, - { - 'check': 'author_email', - 'regex': r'^.+@.+$', - 'error': "The committer's email seems invalid", - 'suggest': 'run command `git config user.email yourname@example.com`', - }, - { - 'check': 'commit_signoff', - 'regex': r'Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>', - 'error': 'Signed-off-by not found in latest commit', - 'suggest': 'run command `git commit -m "conventional commit message" --signoff`', - }, - { - 'check': 'merge_base', - 'regex': r'main', - 'error': 'Current branch is not rebased onto target branch', - 'suggest': 'Please ensure your branch is rebased with the target branch', - }, - { - 'check': 'imperative', - 'regex': r'', # Not used for imperative mood check - 'error': 'Commit message should use imperative mood (e.g., "Add feature" not "Added feature")', - 'suggest': 'Use imperative mood in commit message like "Add", "Fix", "Update", "Remove"', - }, - ] - - def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: """Translate high-level TOML options into internal checks list. @@ -67,14 +16,18 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any author_cfg = conf.get("author", {}) or {} # --- commit section --- - allowed_types_cfg = commit_cfg.get("allow_commit_types") - if isinstance(allowed_types_cfg, list) and allowed_types_cfg: + if commit_cfg.get("conventional_commits", True): + allowed_types = commit_cfg.get("allow_commit_types") or [ + "feat", "fix", "docs", "style", "refactor", "test", "chore", + ] + allowed_re = "|".join(sorted(set(allowed_types))) + conv_regex = rf"^({allowed_re}){{1}}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)" checks.append({ - "check": "allow_commit_types", - "regex": "", - "error": "Commit type is not in the allowed list", - "suggest": "Use an allowed type or update configuration", - "allowed": allowed_types_cfg, + "check": "message", + "regex": conv_regex, + "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", + "suggest": "Use (): with allowed types", + "allowed_types": allowed_types, }) if commit_cfg.get("subject_capitalized", True): @@ -112,7 +65,6 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any "value": min_len, }) - if commit_cfg.get("allow_merge_commits", True) is False: checks.append({ "check": "allow_merge_commits", @@ -164,17 +116,25 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any # --- branch section --- if branch_cfg.get("conventional_branch", True): - allowed = branch_cfg.get("allow_branch_types") or [ + branch_allowed = branch_cfg.get("allow_branch_types") or [ "feature", "bugfix", "hotfix", "release", "chore", "feat", "fix", ] - allowed_re = "|".join(sorted(set(allowed))) + # Preserve order while de-duplicating + seen_b = set() + ordered_branch_allowed: List[str] = [] + for t in branch_allowed: + if t not in seen_b: + seen_b.add(t) + ordered_branch_allowed.append(t) + allowed_re = "|".join(ordered_branch_allowed) regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" checks.append({ "check": "branch", "regex": regex, "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", "suggest": "git checkout -b /", - "allowed_types": allowed, + "allowed": ordered_branch_allowed, + "allowed_types": ordered_branch_allowed, }) target = branch_cfg.get("require_rebase_target") diff --git a/tests/main_test.py b/tests/main_test.py index 8701b07b..71fc89d7 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -1,7 +1,7 @@ import sys import pytest from commit_check.main import main -from commit_check import DEFAULT_CONFIG, PASS, FAIL +from commit_check import PASS, FAIL, DEFAULT_CONFIG CMD = "commit-check" From 143803cbb780ce52afafe4c3653398698aea2604 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 13 Sep 2025 09:47:27 +0300 Subject: [PATCH 10/37] style: format code with ruff --- .pre-commit-config.yaml | 9 +- commit_check/__init__.py | 11 +- commit_check/author.py | 44 ++-- commit_check/branch.py | 20 +- commit_check/commit.py | 218 +++++++++++------ commit_check/error.py | 67 +++--- commit_check/imperatives.py | 456 ++++++++++++++++++------------------ commit_check/main.py | 110 ++++++--- commit_check/rules.py | 281 +++++++++++++--------- commit_check/util.py | 57 +++-- docs/conf.py | 7 +- noxfile.py | 10 +- tests/author_test.py | 162 ++++--------- tests/branch_test.py | 103 +++----- tests/commit_test.py | 285 ++++++++++------------ tests/error_test.py | 3 +- tests/main_test.py | 50 +++- tests/util_test.py | 195 ++++++++------- 18 files changed, 1093 insertions(+), 995 deletions(-) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 95cef572..4fe2ced0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -18,11 +18,12 @@ repos: - id: name-tests-test - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.12.12 + rev: v0.13.0 hooks: - # Run the linter. - - id: ruff - args: [ --fix ] + # Run the linter. + - id: ruff-check + # Run the formatter. + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.17.1 hooks: diff --git a/commit_check/__init__.py b/commit_check/__init__.py index 0b446b0a..dd1abf73 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -1,11 +1,12 @@ """The commit-check package's base module. Exports: - PASS / FAIL exit codes - DEFAULT_CONFIG: minimal default rule set used when no config found - ANSI color constants - __version__ (package version) + PASS / FAIL exit codes + DEFAULT_CONFIG: minimal default rule set used when no config found + ANSI color constants + __version__ (package version) """ + from importlib.metadata import version from commit_check.rules import build_checks_from_toml as _build_checks_from_toml @@ -22,5 +23,5 @@ # Default (empty) configuration translated into internal checks structure DEFAULT_CONFIG = _build_checks_from_toml({}) -CONFIG_FILE = '.' # Search current directory for commit-check.toml or cchk.toml +CONFIG_FILE = "." # Search current directory for commit-check.toml or cchk.toml __version__ = version("commit-check") diff --git a/commit_check/author.py b/commit_check/author.py index 40f9c3c4..4a108897 100644 --- a/commit_check/author.py +++ b/commit_check/author.py @@ -1,4 +1,5 @@ """Check git author name and email""" + import re from typing import Optional from commit_check import YELLOW, RESET_COLOR, PASS, FAIL @@ -22,10 +23,12 @@ def _get_author_value(check_type: str) -> str: return str(get_commit_info(format_str)) -def check_author(checks: list, check_type: str, stdin_text: Optional[str] = None) -> int: +def check_author( + checks: list, check_type: str, stdin_text: Optional[str] = None +) -> int: # If an explicit value is provided (stdin), validate it even if there are no commits if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover + return PASS # pragma: no cover check = _find_check(checks, check_type) if not check: @@ -52,46 +55,53 @@ def check_author(checks: list, check_type: str, stdin_text: Optional[str] = None # --- Additional per-option checks --- -def check_allow_authors(checks: list, check_type: str, stdin_text: Optional[str] = None) -> int: + +def check_allow_authors( + checks: list, check_type: str, stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'allow_authors') + check = _find_check(checks, "allow_authors") if not check: return PASS - allowed = set(check.get('allowed') or []) + allowed = set(check.get("allowed") or []) value = stdin_text if stdin_text is not None else _get_author_value(check_type) if value in allowed: return PASS - _print_failure(check, f'allowed={sorted(allowed)}', value) + _print_failure(check, f"allowed={sorted(allowed)}", value) return FAIL -def check_ignore_authors(checks: list, check_type: str, stdin_text: Optional[str] = None) -> int: +def check_ignore_authors( + checks: list, check_type: str, stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - rule = _find_check(checks, 'ignore_authors') + rule = _find_check(checks, "ignore_authors") if not rule: return PASS - ignored = set(rule.get('ignored') or []) + ignored = set(rule.get("ignored") or []) value = stdin_text if stdin_text is not None else _get_author_value(check_type) if value in ignored: return PASS return PASS # ignore list only whitelists, no failure -def check_required_signoff_details(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_required_signoff_details( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: """If configured, ensure signoff includes specific name/email.""" # Reuse existing signoff check result; only apply extra constraints if present - base = _find_check(checks, 'commit_signoff') + base = _find_check(checks, "commit_signoff") if not base: return PASS - required_name = base.get('required_name') - required_email = base.get('required_email') + required_name = base.get("required_name") + required_email = base.get("required_email") if not (required_name or required_email): return PASS # Read commit message (stdin_text here is the full commit message) - msg = stdin_text if stdin_text is not None else get_commit_info('b') - trailer = 'Signed-off-by:' in msg + msg = stdin_text if stdin_text is not None else get_commit_info("b") + trailer = "Signed-off-by:" in msg if not trailer: return PASS # let the main signoff check handle failure ok = True @@ -101,5 +111,7 @@ def check_required_signoff_details(checks: list, commit_msg_file: str = "", stdi ok = False if ok: return PASS - _print_failure(base, 'required signoff details', required_name or required_email or '') + _print_failure( + base, "required signoff details", required_name or required_email or "" + ) return FAIL diff --git a/commit_check/branch.py b/commit_check/branch.py index 3bc26a26..062c2a7d 100644 --- a/commit_check/branch.py +++ b/commit_check/branch.py @@ -1,16 +1,23 @@ """Check git branch naming convention.""" + import re from typing import Optional from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import _find_check, _print_failure, get_branch_name, git_merge_base, has_commits +from commit_check.util import ( + _find_check, + _print_failure, + get_branch_name, + git_merge_base, + has_commits, +) def check_branch(checks: list, stdin_text: Optional[str] = None) -> int: - check = _find_check(checks, 'branch') + check = _find_check(checks, "branch") if not check: return PASS - regex = check.get('regex', "") + regex = check.get("regex", "") if regex == "": print( f"{YELLOW}Not found regex for branch naming. skip checking.{RESET_COLOR}", @@ -32,14 +39,14 @@ def check_merge_base(checks: list) -> int: :returns PASS(0) if merge base check succeeds, FAIL(1) otherwise """ if has_commits() is False: - return PASS # pragma: no cover + return PASS # pragma: no cover # locate merge_base rule, if any - check = _find_check(checks, 'merge_base') + check = _find_check(checks, "merge_base") if not check: return PASS - regex = check.get('regex', "") + regex = check.get("regex", "") if regex == "": print( f"{YELLOW}Not found target branch for checking merge base. skip checking.{RESET_COLOR}", @@ -58,6 +65,7 @@ def check_merge_base(checks: list) -> int: # --- Additional per-option checks (aliases to existing ones) --- + def check_conventional_branch(checks: list, stdin_text: Optional[str] = None) -> int: """Alias to check_branch for explicit rule mapping.""" return check_branch(checks, stdin_text=stdin_text) diff --git a/commit_check/commit.py b/commit_check/commit.py index 6e5479c7..24627666 100644 --- a/commit_check/commit.py +++ b/commit_check/commit.py @@ -1,9 +1,16 @@ """Check git commit message formatting""" + from typing import Optional import re from pathlib import PurePath from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import _find_check, _print_failure, cmd_output, get_commit_info, has_commits +from commit_check.util import ( + _find_check, + _print_failure, + cmd_output, + get_commit_info, + has_commits, +) from commit_check.imperatives import IMPERATIVES @@ -11,6 +18,7 @@ def _load_imperatives() -> set: """Load imperative verbs from imperatives module.""" return IMPERATIVES + def _ensure_msg_file(commit_msg_file: str | None) -> str: """Return a commit message file path, falling back to the default when empty.""" if not commit_msg_file: @@ -20,36 +28,40 @@ def _ensure_msg_file(commit_msg_file: str | None) -> str: def get_default_commit_msg_file() -> str: """Get the default commit message file.""" - git_dir = cmd_output(['git', 'rev-parse', '--git-dir']).strip() + git_dir = cmd_output(["git", "rev-parse", "--git-dir"]).strip() return str(PurePath(git_dir, "COMMIT_EDITMSG")) def read_commit_msg(commit_msg_file) -> str: """Read the commit message from the specified file.""" try: - with open(commit_msg_file, 'r') as f: + with open(commit_msg_file, "r") as f: return f.read() except FileNotFoundError: # Commit message is composed by subject and body return str(get_commit_info("s") + "\n\n" + get_commit_info("b")) -def check_commit_msg(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_commit_msg( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: """Check commit message against the provided checks. If stdin_text is provided, use it directly (stdin override) and do not require a git repository state. Otherwise, fall back to reading from file/Git. """ if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover + return PASS # pragma: no cover - check = _find_check(checks, 'message') + check = _find_check(checks, "message") if not check: return PASS # pragma: no cover - regex = check.get('regex', "") + regex = check.get("regex", "") if regex == "": - print(f"{YELLOW}Not found regex for commit message. skip checking.{RESET_COLOR}") + print( + f"{YELLOW}Not found regex for commit message. skip checking.{RESET_COLOR}" + ) return PASS if stdin_text is not None: @@ -65,17 +77,21 @@ def check_commit_msg(checks: list, commit_msg_file: str = "", stdin_text: Option return FAIL -def check_commit_signoff(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_commit_signoff( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover + return PASS # pragma: no cover - check = _find_check(checks, 'commit_signoff') + check = _find_check(checks, "commit_signoff") if not check: return PASS # pragma: no cover - regex = check.get('regex', "") + regex = check.get("regex", "") if regex == "": - print(f"{YELLOW}Not found regex for commit signoff. skip checking.{RESET_COLOR}") + print( + f"{YELLOW}Not found regex for commit signoff. skip checking.{RESET_COLOR}" + ) return PASS if stdin_text is not None: @@ -85,10 +101,10 @@ def check_commit_signoff(checks: list, commit_msg_file: str = "", stdin_text: Op commit_msg = read_commit_msg(path) # Extract the subject line (first line of commit message) - subject = commit_msg.split('\n')[0].strip() + subject = commit_msg.split("\n")[0].strip() # Skip if merge commit - if subject.startswith('Merge'): + if subject.startswith("Merge"): return PASS commit_hash = get_commit_info("H") @@ -99,12 +115,14 @@ def check_commit_signoff(checks: list, commit_msg_file: str = "", stdin_text: Op return FAIL -def check_imperative(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_imperative( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: """Check if commit message uses imperative mood.""" if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover + return PASS # pragma: no cover - check = _find_check(checks, 'imperative') + check = _find_check(checks, "imperative") if not check: return PASS @@ -115,167 +133,194 @@ def check_imperative(checks: list, commit_msg_file: str = "", stdin_text: Option commit_msg = read_commit_msg(path) # Extract the subject line (first line of commit message) - subject = commit_msg.split('\n')[0].strip() + subject = commit_msg.split("\n")[0].strip() # Skip if empty or merge commit - if not subject or subject.startswith('Merge'): + if not subject or subject.startswith("Merge"): return PASS # For conventional commits, extract description after the colon - description = subject.split(':', 1)[1].strip() if ':' in subject else subject + description = subject.split(":", 1)[1].strip() if ":" in subject else subject # Check if the description uses imperative mood if _is_imperative(description): return PASS - _print_failure(check, 'imperative mood pattern', subject) + _print_failure(check, "imperative mood pattern", subject) return FAIL # --- Additional per-option checks (not yet wired into CLI) --- -def _get_subject_and_body(stdin_text: Optional[str], commit_msg_file: str) -> tuple[str, str]: + +def _get_subject_and_body( + stdin_text: Optional[str], commit_msg_file: str +) -> tuple[str, str]: if stdin_text is not None: commit_msg = stdin_text else: path = _ensure_msg_file(commit_msg_file) commit_msg = read_commit_msg(path) - subject = commit_msg.split('\n')[0].strip() - body = '\n'.join(commit_msg.split('\n')[1:]).strip() + subject = commit_msg.split("\n")[0].strip() + body = "\n".join(commit_msg.split("\n")[1:]).strip() return subject, body -def check_subject_capitalized(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_subject_capitalized( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'subject_capitalized') + check = _find_check(checks, "subject_capitalized") if not check: return PASS subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) if not subject or subject[0].isupper(): return PASS - _print_failure(check, 'capitalized first letter', subject) + _print_failure(check, "capitalized first letter", subject) return FAIL -def check_subject_max_length(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_subject_max_length( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'subject_max_length') + check = _find_check(checks, "subject_max_length") if not check: return PASS - max_len = int(check.get('value', 0) or 0) + max_len = int(check.get("value", 0) or 0) subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) if not max_len or len(subject) <= max_len: return PASS - _print_failure(check, f'max_length={max_len}', subject) + _print_failure(check, f"max_length={max_len}", subject) return FAIL -def check_subject_min_length(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_subject_min_length( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'subject_min_length') + check = _find_check(checks, "subject_min_length") if not check: return PASS - min_len = int(check.get('value', 0) or 0) + min_len = int(check.get("value", 0) or 0) subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) if len(subject) >= min_len: return PASS - _print_failure(check, f'min_length={min_len}', subject) + _print_failure(check, f"min_length={min_len}", subject) return FAIL -def check_allow_commit_types(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_allow_commit_types( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'allow_commit_types') + check = _find_check(checks, "allow_commit_types") if not check: return PASS - allowed = set(check.get('allowed') or []) + allowed = set(check.get("allowed") or []) subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - ctype = subject.split(':', 1)[0].split('(')[0].strip() if ':' in subject else subject.split('(')[0].strip() + ctype = ( + subject.split(":", 1)[0].split("(")[0].strip() + if ":" in subject + else subject.split("(")[0].strip() + ) if ctype in allowed: return PASS - _print_failure(check, f'allowed={sorted(allowed)}', subject) + _print_failure(check, f"allowed={sorted(allowed)}", subject) return FAIL -def check_allow_merge_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_allow_merge_commits( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'allow_merge_commits') + check = _find_check(checks, "allow_merge_commits") if not check: return PASS subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject.startswith('Merge'): - _print_failure(check, 'no merge commits', subject) + if subject.startswith("Merge"): + _print_failure(check, "no merge commits", subject) return FAIL return PASS -def check_allow_revert_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_allow_revert_commits( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'allow_revert_commits') + check = _find_check(checks, "allow_revert_commits") if not check: return PASS subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject.lower().startswith('revert'): - _print_failure(check, 'no revert commits', subject) + if subject.lower().startswith("revert"): + _print_failure(check, "no revert commits", subject) return FAIL return PASS -def check_allow_empty_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_allow_empty_commits( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'allow_empty_commits') + check = _find_check(checks, "allow_empty_commits") if not check: return PASS subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) if subject: return PASS - _print_failure(check, 'non-empty subject required', subject) + _print_failure(check, "non-empty subject required", subject) return FAIL -def check_allow_fixup_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_allow_fixup_commits( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'allow_fixup_commits') + check = _find_check(checks, "allow_fixup_commits") if not check: return PASS subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject.startswith('fixup!'): - _print_failure(check, 'no fixup commits', subject) + if subject.startswith("fixup!"): + _print_failure(check, "no fixup commits", subject) return FAIL return PASS -def check_allow_wip_commits(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_allow_wip_commits( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'allow_wip_commits') + check = _find_check(checks, "allow_wip_commits") if not check: return PASS subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject.startswith('WIP') or subject.upper().startswith('WIP:'): - _print_failure(check, 'no WIP commits', subject) + if subject.startswith("WIP") or subject.upper().startswith("WIP:"): + _print_failure(check, "no WIP commits", subject) return FAIL return PASS -def check_require_body(checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None) -> int: +def check_require_body( + checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None +) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, 'require_body') + check = _find_check(checks, "require_body") if not check: return PASS _, body = _get_subject_and_body(stdin_text, commit_msg_file) if body: return PASS - _print_failure(check, 'body required', '') + _print_failure(check, "body required", "") return FAIL @@ -291,23 +336,56 @@ def _is_imperative(description: str) -> bool: imperatives = _load_imperatives() # Check for common past tense pattern (-ed ending) but be more specific - if (first_word.endswith('ed') and len(first_word) > 3 and - first_word not in {'red', 'bed', 'fed', 'led', 'wed', 'shed', 'fled'}): + if ( + first_word.endswith("ed") + and len(first_word) > 3 + and first_word not in {"red", "bed", "fed", "led", "wed", "shed", "fled"} + ): return False # Check for present continuous pattern (-ing ending) but be more specific - if (first_word.endswith('ing') and len(first_word) > 4 and - first_word not in {'ring', 'sing', 'king', 'wing', 'thing', 'string', 'bring'}): + if ( + first_word.endswith("ing") + and len(first_word) > 4 + and first_word + not in {"ring", "sing", "king", "wing", "thing", "string", "bring"} + ): return False # Check for third person singular (-s ending) but be more specific # Only flag if it's clearly a verb in third person singular form - if first_word.endswith('s') and len(first_word) > 3: + if first_word.endswith("s") and len(first_word) > 3: # Common nouns ending in 's' that should be allowed - common_nouns_ending_s = {'process', 'access', 'address', 'progress', 'express', 'stress', 'success', 'class', 'pass', 'mass', 'loss', 'cross', 'gross', 'boss', 'toss', 'less', 'mess', 'dress', 'press', 'bless', 'guess', 'chess', 'glass', 'grass', 'brass'} + common_nouns_ending_s = { + "process", + "access", + "address", + "progress", + "express", + "stress", + "success", + "class", + "pass", + "mass", + "loss", + "cross", + "gross", + "boss", + "toss", + "less", + "mess", + "dress", + "press", + "bless", + "guess", + "chess", + "glass", + "grass", + "brass", + } # Words ending in 'ss' or 'us' are usually not third person singular verbs - if first_word.endswith('ss') or first_word.endswith('us'): + if first_word.endswith("ss") or first_word.endswith("us"): return True # Allow these # If it's a common noun, allow it diff --git a/commit_check/error.py b/commit_check/error.py index 2bdfa9b4..639c373c 100644 --- a/commit_check/error.py +++ b/commit_check/error.py @@ -4,6 +4,7 @@ A module containing error handler functions. """ + import contextlib import os import sys @@ -18,63 +19,63 @@ def error_handler() -> Generator[None, None, None]: yield except (Exception, KeyboardInterrupt) as e: if isinstance(e, RuntimeError): - msg, ret_code = 'An error has occurred', 1 + msg, ret_code = "An error has occurred", 1 elif isinstance(e, KeyboardInterrupt): - msg, ret_code = 'Interrupted (^C)', 130 + msg, ret_code = "Interrupted (^C)", 130 else: - msg, ret_code = 'An unexpected error has occurred', 3 + msg, ret_code = "An unexpected error has occurred", 3 log_and_exit(msg, ret_code, e, traceback.format_exc()) def log_and_exit(msg: str, ret_code: int, exc: BaseException, formatted: str) -> None: - error_msg = f'{msg}: {type(exc).__name__}: {exc}' - commit_check_version = cmd_output(['commit-check', '--version']) - git_version = cmd_output(['git', '--version']) + error_msg = f"{msg}: {type(exc).__name__}: {exc}" + commit_check_version = cmd_output(["commit-check", "--version"]) + git_version = cmd_output(["git", "--version"]) - store_dir = os.environ.get('COMMIT_CHECK_HOME') or os.path.join( - os.environ.get('XDG_CACHE_HOME') or os.path.expanduser('~/.cache'), - 'commit-check', + store_dir = os.environ.get("COMMIT_CHECK_HOME") or os.path.join( + os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), + "commit-check", ) - log_path = os.path.join(store_dir, 'commit-check.log') + log_path = os.path.join(store_dir, "commit-check.log") if not os.path.exists(store_dir): os.makedirs(store_dir, exist_ok=True) - with open(os.path.join(store_dir, 'README'), 'w') as f: + with open(os.path.join(store_dir, "README"), "w") as f: f.write( - 'This directory is maintained by the commit-check project.\n' - 'Learn more: https://github.com/commit-check/commit-check\n', + "This directory is maintained by the commit-check project.\n" + "Learn more: https://github.com/commit-check/commit-check\n", ) def write_line(log_ctx=""): - with open(log_path, 'a') as file: - file.write(f'{log_ctx}\n') + with open(log_path, "a") as file: + file.write(f"{log_ctx}\n") if os.access(store_dir, os.W_OK): - open(log_path, 'w').close() - write_line('### version information') - write_line('```') - write_line(f'commit-check --version: {commit_check_version}') - write_line(f'git --version: {git_version}') - write_line('sys.version:') + open(log_path, "w").close() + write_line("### version information") + write_line("```") + write_line(f"commit-check --version: {commit_check_version}") + write_line(f"git --version: {git_version}") + write_line("sys.version:") for line in sys.version.splitlines(): - write_line(f' {line}') - write_line(f'sys.executable: {sys.executable}') - write_line(f'os.name: {os.name}') - write_line(f'sys.platform: {sys.platform}') - write_line('```') + write_line(f" {line}") + write_line(f"sys.executable: {sys.executable}") + write_line(f"os.name: {os.name}") + write_line(f"sys.platform: {sys.platform}") + write_line("```") write_line() - write_line('### error information') + write_line("### error information") write_line() - write_line('```') + write_line("```") write_line(error_msg) - write_line('```') + write_line("```") write_line() - write_line('```') + write_line("```") write_line(formatted.rstrip()) - write_line('```') + write_line("```") else: - write_line(f'Failed to write to log at {log_path}') + write_line(f"Failed to write to log at {log_path}") print(error_msg) - print(f'Check the log at {log_path}') + print(f"Check the log at {log_path}") raise SystemExit(ret_code) diff --git a/commit_check/imperatives.py b/commit_check/imperatives.py index 0c3d0918..3c1df435 100644 --- a/commit_check/imperatives.py +++ b/commit_check/imperatives.py @@ -6,232 +6,232 @@ # nouns, but blacklisting them for this may cause false positives. IMPERATIVES = { - 'accept', - 'access', - 'add', - 'adjust', - 'aggregate', - 'allow', - 'append', - 'apply', - 'archive', - 'assert', - 'assign', - 'attempt', - 'authenticate', - 'authorize', - 'break', - 'build', - 'cache', - 'calculate', - 'call', - 'cancel', - 'capture', - 'change', - 'check', - 'clean', - 'clear', - 'close', - 'collect', - 'combine', - 'commit', - 'compare', - 'compute', - 'configure', - 'confirm', - 'connect', - 'construct', - 'control', - 'convert', - 'copy', - 'count', - 'create', - 'customize', - 'declare', - 'decode', - 'decorate', - 'define', - 'delegate', - 'delete', - 'deprecate', - 'derive', - 'describe', - 'detect', - 'determine', - 'display', - 'download', - 'drop', - 'dump', - 'emit', - 'empty', - 'enable', - 'encapsulate', - 'encode', - 'end', - 'ensure', - 'enumerate', - 'establish', - 'evaluate', - 'examine', - 'execute', - 'exit', - 'expand', - 'expect', - 'export', - 'extend', - 'extract', - 'feed', - 'fetch', - 'fill', - 'filter', - 'finalize', - 'find', - 'fire', - 'fix', - 'flag', - 'force', - 'format', - 'forward', - 'generate', - 'get', - 'give', - 'go', - 'group', - 'handle', - 'help', - 'hold', - 'identify', - 'implement', - 'import', - 'indicate', - 'init', - 'initialise', - 'initialize', - 'initiate', - 'input', - 'insert', - 'instantiate', - 'intercept', - 'invoke', - 'iterate', - 'join', - 'keep', - 'launch', - 'list', - 'listen', - 'load', - 'log', - 'look', - 'make', - 'manage', - 'manipulate', - 'map', - 'mark', - 'match', - 'merge', - 'mock', - 'modify', - 'monitor', - 'move', - 'normalize', - 'note', - 'obtain', - 'open', - 'output', - 'override', - 'overwrite', - 'package', - 'pad', - 'parse', - 'partial', - 'pass', - 'perform', - 'persist', - 'pick', - 'plot', - 'poll', - 'populate', - 'post', - 'prepare', - 'print', - 'process', - 'produce', - 'provide', - 'publish', - 'pull', - 'put', - 'query', - 'raise', - 'read', - 'record', - 'refer', - 'refresh', - 'register', - 'reload', - 'remove', - 'rename', - 'render', - 'replace', - 'reply', - 'report', - 'represent', - 'request', - 'require', - 'reset', - 'resolve', - 'retrieve', - 'return', - 'roll', - 'rollback', - 'round', - 'run', - 'sample', - 'save', - 'scan', - 'search', - 'select', - 'send', - 'serialise', - 'serialize', - 'serve', - 'set', - 'show', - 'simulate', - 'source', - 'specify', - 'split', - 'start', - 'step', - 'stop', - 'store', - 'strip', - 'submit', - 'subscribe', - 'sum', - 'swap', - 'sync', - 'synchronise', - 'synchronize', - 'take', - 'tear', - 'test', - 'time', - 'transform', - 'translate', - 'transmit', - 'truncate', - 'try', - 'turn', - 'tweak', - 'update', - 'upload', - 'use', - 'validate', - 'verify', - 'view', - 'wait', - 'walk', - 'wrap', - 'write', - 'yield', + "accept", + "access", + "add", + "adjust", + "aggregate", + "allow", + "append", + "apply", + "archive", + "assert", + "assign", + "attempt", + "authenticate", + "authorize", + "break", + "build", + "cache", + "calculate", + "call", + "cancel", + "capture", + "change", + "check", + "clean", + "clear", + "close", + "collect", + "combine", + "commit", + "compare", + "compute", + "configure", + "confirm", + "connect", + "construct", + "control", + "convert", + "copy", + "count", + "create", + "customize", + "declare", + "decode", + "decorate", + "define", + "delegate", + "delete", + "deprecate", + "derive", + "describe", + "detect", + "determine", + "display", + "download", + "drop", + "dump", + "emit", + "empty", + "enable", + "encapsulate", + "encode", + "end", + "ensure", + "enumerate", + "establish", + "evaluate", + "examine", + "execute", + "exit", + "expand", + "expect", + "export", + "extend", + "extract", + "feed", + "fetch", + "fill", + "filter", + "finalize", + "find", + "fire", + "fix", + "flag", + "force", + "format", + "forward", + "generate", + "get", + "give", + "go", + "group", + "handle", + "help", + "hold", + "identify", + "implement", + "import", + "indicate", + "init", + "initialise", + "initialize", + "initiate", + "input", + "insert", + "instantiate", + "intercept", + "invoke", + "iterate", + "join", + "keep", + "launch", + "list", + "listen", + "load", + "log", + "look", + "make", + "manage", + "manipulate", + "map", + "mark", + "match", + "merge", + "mock", + "modify", + "monitor", + "move", + "normalize", + "note", + "obtain", + "open", + "output", + "override", + "overwrite", + "package", + "pad", + "parse", + "partial", + "pass", + "perform", + "persist", + "pick", + "plot", + "poll", + "populate", + "post", + "prepare", + "print", + "process", + "produce", + "provide", + "publish", + "pull", + "put", + "query", + "raise", + "read", + "record", + "refer", + "refresh", + "register", + "reload", + "remove", + "rename", + "render", + "replace", + "reply", + "report", + "represent", + "request", + "require", + "reset", + "resolve", + "retrieve", + "return", + "roll", + "rollback", + "round", + "run", + "sample", + "save", + "scan", + "search", + "select", + "send", + "serialise", + "serialize", + "serve", + "set", + "show", + "simulate", + "source", + "specify", + "split", + "start", + "step", + "stop", + "store", + "strip", + "submit", + "subscribe", + "sum", + "swap", + "sync", + "synchronise", + "synchronize", + "take", + "tear", + "test", + "time", + "transform", + "translate", + "transmit", + "truncate", + "try", + "turn", + "tweak", + "update", + "upload", + "use", + "validate", + "verify", + "view", + "wait", + "walk", + "wrap", + "write", + "yield", } diff --git a/commit_check/main.py b/commit_check/main.py index 2bb54512..e1fb34c4 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -64,31 +64,63 @@ def _read_stdin() -> Optional[str]: # read commit message content if piped def _dispatch_checks_full(checks: list, stdin_text: Optional[str]) -> int: """Execute ALL configured checks once (used by future run mode).""" dispatcher: Dict[str, Callable[[], int]] = { - 'message': lambda: commit.check_commit_msg(checks, stdin_text=stdin_text), - 'imperative': lambda: commit.check_imperative(checks, stdin_text=stdin_text), - 'subject_capitalized': lambda: commit.check_subject_capitalized(checks, stdin_text=stdin_text), - 'subject_max_length': lambda: commit.check_subject_max_length(checks, stdin_text=stdin_text), - 'subject_min_length': lambda: commit.check_subject_min_length(checks, stdin_text=stdin_text), - 'allow_commit_types': lambda: commit.check_allow_commit_types(checks, stdin_text=stdin_text), - 'allow_merge_commits': lambda: commit.check_allow_merge_commits(checks, stdin_text=stdin_text), - 'allow_revert_commits': lambda: commit.check_allow_revert_commits(checks, stdin_text=stdin_text), - 'allow_empty_commits': lambda: commit.check_allow_empty_commits(checks, stdin_text=stdin_text), - 'allow_fixup_commits': lambda: commit.check_allow_fixup_commits(checks, stdin_text=stdin_text), - 'allow_wip_commits': lambda: commit.check_allow_wip_commits(checks, stdin_text=stdin_text), - 'commit_signoff': lambda: commit.check_commit_signoff(checks, stdin_text=stdin_text), - 'require_body': lambda: commit.check_require_body(checks, stdin_text=stdin_text), - 'branch': lambda: branch.check_branch(checks, stdin_text=stdin_text), - 'merge_base': lambda: branch.check_merge_base(checks), - 'author_name': lambda: author.check_author(checks, 'author_name', stdin_text=stdin_text), - 'author_email': lambda: author.check_author(checks, 'author_email', stdin_text=stdin_text), - 'allow_authors': lambda: author.check_allow_authors(checks, 'author_name', stdin_text=stdin_text), - 'ignore_authors': lambda: author.check_ignore_authors(checks, 'author_name', stdin_text=stdin_text), - 'commit_signoff_details': lambda: author.check_required_signoff_details(checks, stdin_text=stdin_text), + "message": lambda: commit.check_commit_msg(checks, stdin_text=stdin_text), + "imperative": lambda: commit.check_imperative(checks, stdin_text=stdin_text), + "subject_capitalized": lambda: commit.check_subject_capitalized( + checks, stdin_text=stdin_text + ), + "subject_max_length": lambda: commit.check_subject_max_length( + checks, stdin_text=stdin_text + ), + "subject_min_length": lambda: commit.check_subject_min_length( + checks, stdin_text=stdin_text + ), + "allow_commit_types": lambda: commit.check_allow_commit_types( + checks, stdin_text=stdin_text + ), + "allow_merge_commits": lambda: commit.check_allow_merge_commits( + checks, stdin_text=stdin_text + ), + "allow_revert_commits": lambda: commit.check_allow_revert_commits( + checks, stdin_text=stdin_text + ), + "allow_empty_commits": lambda: commit.check_allow_empty_commits( + checks, stdin_text=stdin_text + ), + "allow_fixup_commits": lambda: commit.check_allow_fixup_commits( + checks, stdin_text=stdin_text + ), + "allow_wip_commits": lambda: commit.check_allow_wip_commits( + checks, stdin_text=stdin_text + ), + "commit_signoff": lambda: commit.check_commit_signoff( + checks, stdin_text=stdin_text + ), + "require_body": lambda: commit.check_require_body( + checks, stdin_text=stdin_text + ), + "branch": lambda: branch.check_branch(checks, stdin_text=stdin_text), + "merge_base": lambda: branch.check_merge_base(checks), + "author_name": lambda: author.check_author( + checks, "author_name", stdin_text=stdin_text + ), + "author_email": lambda: author.check_author( + checks, "author_email", stdin_text=stdin_text + ), + "allow_authors": lambda: author.check_allow_authors( + checks, "author_name", stdin_text=stdin_text + ), + "ignore_authors": lambda: author.check_ignore_authors( + checks, "author_name", stdin_text=stdin_text + ), + "commit_signoff_details": lambda: author.check_required_signoff_details( + checks, stdin_text=stdin_text + ), } seen = set() results: list[int] = [] for chk in checks: - ctype = chk.get('check') + ctype = chk.get("check") if ctype in seen: continue seen.add(ctype) @@ -104,16 +136,28 @@ def _dispatch_checks_full(checks: list, stdin_text: Optional[str]) -> int: def main() -> int: argv = sys.argv[1:] parser = argparse.ArgumentParser( - prog='commit-check', - description='check commit message, branch naming, committer name/email, commit signoff and more.' + prog="commit-check", + description="check commit message, branch naming, committer name/email, commit signoff and more.", + ) + parser.add_argument("command", choices=["run"], help="Only supported command: run") + parser.add_argument( + "path", + nargs="?", + default=".", + help="Repository path (default: current directory)", + ) + parser.add_argument( + "--config", + type=str, + default=None, + help="Path to TOML configuration file (commit-check.toml or cchk.toml)", + ) + parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging") + parser.add_argument("-q", "--quiet", action="store_true", help="Quiet logging") + parser.add_argument("-s", "--silent", action="store_true", help="Silent mode") + parser.add_argument( + "-V", "--version", action="store_true", help="Show version and exit" ) - parser.add_argument('command', choices=['run'], help="Only supported command: run") - parser.add_argument('path', nargs='?', default='.', help='Repository path (default: current directory)') - parser.add_argument('--config', type=str, default=None, help='Path to TOML configuration file (commit-check.toml or cchk.toml)') - parser.add_argument('-v', '--verbose', action='store_true', help='Verbose logging') - parser.add_argument('-q', '--quiet', action='store_true', help='Quiet logging') - parser.add_argument('-s', '--silent', action='store_true', help='Silent mode') - parser.add_argument('-V', '--version', action='store_true', help='Show version and exit') args = parser.parse_args(argv) if args.version: @@ -121,7 +165,7 @@ def main() -> int: raise SystemExit(0) # Command guard (argparse choices already enforce) - if args.command != 'run': # pragma: no cover + if args.command != "run": # pragma: no cover parser.error("only 'run' is supported") set_log_level(args.verbose, args.quiet, args.silent) @@ -129,10 +173,10 @@ def main() -> int: stdin_text = _read_stdin() with error_handler(): cfg = validate_config(cfg_path) or DEFAULT_CONFIG - checks = cfg.get('checks', []) + checks = cfg.get("checks", []) status = _dispatch_checks_full(checks, stdin_text=stdin_text) return status -if __name__ == '__main__': # pragma: no cover +if __name__ == "__main__": # pragma: no cover raise SystemExit(main()) diff --git a/commit_check/rules.py b/commit_check/rules.py index 4ffa2bec..976516eb 100644 --- a/commit_check/rules.py +++ b/commit_check/rules.py @@ -1,4 +1,5 @@ """Centralized built-in rules and TOML translation for commit-check.""" + from __future__ import annotations from typing import Any, Dict, List @@ -18,106 +19,140 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any # --- commit section --- if commit_cfg.get("conventional_commits", True): allowed_types = commit_cfg.get("allow_commit_types") or [ - "feat", "fix", "docs", "style", "refactor", "test", "chore", + "feat", + "fix", + "docs", + "style", + "refactor", + "test", + "chore", ] allowed_re = "|".join(sorted(set(allowed_types))) conv_regex = rf"^({allowed_re}){{1}}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)" - checks.append({ - "check": "message", - "regex": conv_regex, - "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", - "suggest": "Use (): with allowed types", - "allowed_types": allowed_types, - }) + checks.append( + { + "check": "message", + "regex": conv_regex, + "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", + "suggest": "Use (): with allowed types", + "allowed_types": allowed_types, + } + ) if commit_cfg.get("subject_capitalized", True): - checks.append({ - "check": "subject_capitalized", - "regex": "", - "error": "Subject must start with a capital letter", - "suggest": "Capitalize the first word of the subject", - }) + checks.append( + { + "check": "subject_capitalized", + "regex": "", + "error": "Subject must start with a capital letter", + "suggest": "Capitalize the first word of the subject", + } + ) if commit_cfg.get("subject_imperative", True): - checks.append({ - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood (e.g., 'Add feature' not 'Added feature')", - "suggest": "Use imperative mood in the subject line", - }) + checks.append( + { + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood (e.g., 'Add feature' not 'Added feature')", + "suggest": "Use imperative mood in the subject line", + } + ) max_len = commit_cfg.get("subject_max_length") if isinstance(max_len, int): - checks.append({ - "check": "subject_max_length", - "regex": "", - "error": f"Subject must be at most {max_len} characters", - "suggest": "Keep the subject concise (<= configured max)", - "value": max_len, - }) + checks.append( + { + "check": "subject_max_length", + "regex": "", + "error": f"Subject must be at most {max_len} characters", + "suggest": "Keep the subject concise (<= configured max)", + "value": max_len, + } + ) min_len = commit_cfg.get("subject_min_length") if isinstance(min_len, int): - checks.append({ - "check": "subject_min_length", - "regex": "", - "error": f"Subject must be at least {min_len} characters", - "suggest": "Provide a meaningful subject (>= configured min)", - "value": min_len, - }) + checks.append( + { + "check": "subject_min_length", + "regex": "", + "error": f"Subject must be at least {min_len} characters", + "suggest": "Provide a meaningful subject (>= configured min)", + "value": min_len, + } + ) if commit_cfg.get("allow_merge_commits", True) is False: - checks.append({ - "check": "allow_merge_commits", - "regex": "", - "error": "Merge commits are not allowed", - "suggest": "Rebase or squash your changes instead of merging", - "value": False, - }) + checks.append( + { + "check": "allow_merge_commits", + "regex": "", + "error": "Merge commits are not allowed", + "suggest": "Rebase or squash your changes instead of merging", + "value": False, + } + ) if commit_cfg.get("allow_revert_commits", True) is False: - checks.append({ - "check": "allow_revert_commits", - "regex": "", - "error": "Revert commits are not allowed", - "suggest": "Avoid using 'revert' commits; rewrite history if necessary", - "value": False, - }) + checks.append( + { + "check": "allow_revert_commits", + "regex": "", + "error": "Revert commits are not allowed", + "suggest": "Avoid using 'revert' commits; rewrite history if necessary", + "value": False, + } + ) if commit_cfg.get("allow_empty_commits", False) is False: - checks.append({ - "check": "allow_empty_commits", - "regex": "", - "error": "Empty commit messages are not allowed", - "suggest": "Provide a non-empty subject", - "value": False, - }) + checks.append( + { + "check": "allow_empty_commits", + "regex": "", + "error": "Empty commit messages are not allowed", + "suggest": "Provide a non-empty subject", + "value": False, + } + ) if commit_cfg.get("allow_fixup_commits", True) is False: - checks.append({ - "check": "allow_fixup_commits", - "regex": "", - "error": "Fixup commits are not allowed", - "suggest": "Use interactive rebase to clean up fixup commits", - "value": False, - }) + checks.append( + { + "check": "allow_fixup_commits", + "regex": "", + "error": "Fixup commits are not allowed", + "suggest": "Use interactive rebase to clean up fixup commits", + "value": False, + } + ) if commit_cfg.get("allow_wip_commits", False) is False: - checks.append({ - "check": "allow_wip_commits", - "regex": "", - "error": "WIP commits are not allowed", - "suggest": "Complete the work before committing or remove 'WIP'", - "value": False, - }) + checks.append( + { + "check": "allow_wip_commits", + "regex": "", + "error": "WIP commits are not allowed", + "suggest": "Complete the work before committing or remove 'WIP'", + "value": False, + } + ) if commit_cfg.get("require_body", False): - checks.append({ - "check": "require_body", - "regex": "", - "error": "Commit body is required", - "suggest": "Add a body explaining the change", - "value": True, - }) + checks.append( + { + "check": "require_body", + "regex": "", + "error": "Commit body is required", + "suggest": "Add a body explaining the change", + "value": True, + } + ) # --- branch section --- if branch_cfg.get("conventional_branch", True): branch_allowed = branch_cfg.get("allow_branch_types") or [ - "feature", "bugfix", "hotfix", "release", "chore", "feat", "fix", + "feature", + "bugfix", + "hotfix", + "release", + "chore", + "feat", + "fix", ] # Preserve order while de-duplicating seen_b = set() @@ -128,56 +163,68 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any ordered_branch_allowed.append(t) allowed_re = "|".join(ordered_branch_allowed) regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" - checks.append({ - "check": "branch", - "regex": regex, - "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", - "suggest": "git checkout -b /", - "allowed": ordered_branch_allowed, - "allowed_types": ordered_branch_allowed, - }) + checks.append( + { + "check": "branch", + "regex": regex, + "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", + "suggest": "git checkout -b /", + "allowed": ordered_branch_allowed, + "allowed_types": ordered_branch_allowed, + } + ) target = branch_cfg.get("require_rebase_target") if isinstance(target, str) and target: - checks.append({ - "check": "merge_base", - "regex": target, - "error": "Current branch is not rebased onto target branch", - "suggest": "Rebase or merge with the target branch", - }) + checks.append( + { + "check": "merge_base", + "regex": target, + "error": "Current branch is not rebased onto target branch", + "suggest": "Rebase or merge with the target branch", + } + ) # --- author section --- - checks.append({ - "check": "author_name", - "regex": r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", - "error": "The committer name seems invalid", - "suggest": "git config user.name 'Your Name'", - }) - checks.append({ - "check": "author_email", - "regex": r"^.+@.+$", - "error": "The committer's email seems invalid", - "suggest": "git config user.email yourname@example.com", - }) + checks.append( + { + "check": "author_name", + "regex": r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", + "error": "The committer name seems invalid", + "suggest": "git config user.name 'Your Name'", + } + ) + checks.append( + { + "check": "author_email", + "regex": r"^.+@.+$", + "error": "The committer's email seems invalid", + "suggest": "git config user.email yourname@example.com", + } + ) allow_authors = author_cfg.get("allow_authors") if isinstance(allow_authors, list) and allow_authors: - checks.append({ - "check": "allow_authors", - "regex": "", - "error": "Author is not allowed", - "suggest": "Use a configured author or adjust configuration", - "allowed": allow_authors, - }) + checks.append( + { + "check": "allow_authors", + "regex": "", + "error": "Author is not allowed", + "suggest": "Use a configured author or adjust configuration", + "allowed": allow_authors, + } + ) ignore_authors = author_cfg.get("ignore_authors") if isinstance(ignore_authors, list) and ignore_authors: - checks.append({ - "check": "ignore_authors", - "regex": "", - "error": "", - "suggest": "", - "ignored": ignore_authors, - }) + checks.append( + { + "check": "ignore_authors", + "regex": "", + "error": "", + "suggest": "", + "ignored": ignore_authors, + } + ) if author_cfg.get("require_signed_off_by", False): sign_name = author_cfg.get("required_signoff_name") diff --git a/commit_check/util.py b/commit_check/util.py index 9a660035..709f4610 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -26,22 +26,18 @@ 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: + if check.get("check") == check_type: return check return None -def _print_failure( - check: dict, - regex: str, - actual: str -) -> None: +def _print_failure(check: dict, regex: str, actual: str) -> None: """Print a standardized failure message.""" if not print_error_header.has_been_called: print_error_header() - print_error_message(check['check'], regex, check.get('error', ''), actual) - if check.get('suggest'): - print_suggestion(check.get('suggest')) + print_error_message(check["check"], regex, check.get("error", ""), actual) + if check.get("suggest"): + print_suggestion(check.get("suggest")) def get_branch_name() -> str: @@ -55,10 +51,10 @@ def get_branch_name() -> str: """ try: # Git 2.22 and above supports `git branch --show-current` - commands = ['git', 'branch', '--show-current'] + commands = ["git", "branch", "--show-current"] branch_name = cmd_output(commands) or "HEAD" except CalledProcessError: - branch_name = '' + branch_name = "" return branch_name.strip() @@ -71,12 +67,13 @@ def has_commits() -> bool: ["git", "rev-parse", "--verify", "HEAD"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, - check=True + check=True, ) return True except subprocess.CalledProcessError: return False + def get_commit_info(format_string: str, sha: str = "HEAD") -> str: """Get latest commits information :param format_string: could be @@ -91,11 +88,16 @@ def get_commit_info(format_string: str, sha: str = "HEAD") -> str: """ try: commands = [ - 'git', 'log', '-n', '1', f"--pretty=format:%{format_string}", f"{sha}", + "git", + "log", + "-n", + "1", + f"--pretty=format:%{format_string}", + f"{sha}", ] output = cmd_output(commands) except CalledProcessError: - output = '' + output = "" return output @@ -107,9 +109,15 @@ def git_merge_base(target_branch: str, current_branch: str) -> int: :returns: 0 if ancestor exists, 1 if not, 128 if git command fails. """ try: - commands = ['git', 'merge-base', '--is-ancestor', f'{target_branch}', f'{current_branch}'] + commands = [ + "git", + "merge-base", + "--is-ancestor", + f"{target_branch}", + f"{current_branch}", + ] result = subprocess.run( - commands, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8' + commands, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8" ) return result.returncode except CalledProcessError: @@ -123,14 +131,14 @@ def cmd_output(commands: list) -> str: :returns: Get `str` output. """ result = subprocess.run( - commands, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8' + commands, stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding="utf-8" ) if result.returncode == 0 and result.stdout is not None: return result.stdout - elif result.stderr != '': + elif result.stderr != "": return result.stderr else: - return '' + return "" def _load_toml(path: PurePath) -> Dict[str, Any]: @@ -193,6 +201,7 @@ def track_print_call(func): def wrapper(*args, **kwargs): wrapper.has_been_called = True return func(*args, **kwargs) + wrapper.has_been_called = False # Initialize as False return wrapper @@ -226,7 +235,10 @@ def print_error_message(check_type: str, regex: str, error: str, reason: str): :returns: Give error messages to user """ - print(f"Type {YELLOW}{check_type}{RESET_COLOR} check failed => {RED}{reason}{RESET_COLOR} ", end='',) + print( + f"Type {YELLOW}{check_type}{RESET_COLOR} check failed => {RED}{reason}{RESET_COLOR} ", + end="", + ) print("") print(f"It doesn't match regex: {regex}") print(error) @@ -238,9 +250,10 @@ def print_suggestion(suggest: str | None) -> None: """ if suggest: print( - f"Suggest: {GREEN}{suggest}{RESET_COLOR} ", end='', + f"Suggest: {GREEN}{suggest}{RESET_COLOR} ", + end="", ) else: print(f"commit-check does not support {suggest} yet.") raise SystemExit(1) - print('\n') + print("\n") diff --git a/docs/conf.py b/docs/conf.py index 7479df8f..c2208929 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -91,7 +91,7 @@ "name": "note", "icon": "material/file-document-edit-outline", "override": True, - } + }, ] for name in ("hint", "tip", "important"): sphinx_immaterial_custom_admonitions.append( @@ -103,7 +103,10 @@ def setup(app: Sphinx): """Generate a doc from the executable script's ``--help`` output.""" result = subprocess.run( - ["commit-check", "--help"], stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8' + ["commit-check", "--help"], + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", ) doc = "commit-check --help\n==============================\n\n" CLI_OPT_NAME = re.compile(r"^\s*(\-\w)\s?[A-Z_]*,\s(\-\-.*?)\s") diff --git a/noxfile.py b/noxfile.py index 6bccf045..c5fdae61 100644 --- a/noxfile.py +++ b/noxfile.py @@ -44,7 +44,7 @@ def commit_check(session): @nox.session() def coverage(session): - session.install('.[test]') + session.install(".[test]") session.run("coverage", "run", "--source", "commit_check", "-m", "pytest") session.run("coverage", "report") session.run("coverage", "xml") @@ -52,11 +52,13 @@ def coverage(session): @nox.session() def docs(session): - session.install('.[docs]') + session.install(".[docs]") session.run("sphinx-build", "-E", "-W", "-b", "html", "docs", "_build/html") @nox.session(name="docs-live") def docs_live(session): - session.install('.[docs]') - session.run("sphinx-autobuild", "-b", "html", "docs", "_build/html", "--watch", "docs/") + session.install(".[docs]") + session.run( + "sphinx-autobuild", "-b", "html", "docs", "_build/html", "--watch", "docs/" + ) diff --git a/tests/author_test.py b/tests/author_test.py index 30bad283..37097a84 100644 --- a/tests/author_test.py +++ b/tests/author_test.py @@ -15,18 +15,11 @@ class TestAuthorName: @pytest.mark.benchmark def test_check_author(self, mocker): # Must call get_commit_info, re.match. - checks = [{ - "check": "author_name", - "regex": "dummy_regex" - }] + checks = [{"check": "author_name", "regex": "dummy_regex"}] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) + m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") retval = check_author(checks, "author_name") assert retval == PASS assert m_get_commit_info.call_count == 1 @@ -35,18 +28,12 @@ def test_check_author(self, mocker): @pytest.mark.benchmark def test_check_author_with_accented_letters(self, mocker): # Must call get_commit_info, re.match. - checks = [{ - "check": "author_name", - "regex": "dummy_regex" - }] + checks = [{"check": "author_name", "regex": "dummy_regex"}] m_get_commit_info = mocker.patch( f"{LOCATION}.get_commit_info", - return_value=self.fake_accented_author_value_an - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" + return_value=self.fake_accented_author_value_an, ) + m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") retval = check_author(checks, "author_name") assert retval == PASS assert m_get_commit_info.call_count == 1 @@ -57,13 +44,9 @@ def test_check_author_with_empty_checks(self, mocker): # Must NOT call get_commit_info, re.match. with `checks` param with length 0. checks = [] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_author_name" + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) + m_re_match = mocker.patch("re.match", return_value="fake_author_name") retval = check_author(checks, "author_name") assert retval == PASS assert m_get_commit_info.call_count == 0 @@ -72,18 +55,11 @@ def test_check_author_with_empty_checks(self, mocker): @pytest.mark.benchmark def test_check_author_with_different_check(self, mocker): # Must NOT call get_commit_info, re.match with not `author_name`. - checks = [{ - "check": "message", - "regex": "dummy_regex" - }] + checks = [{"check": "message", "regex": "dummy_regex"}] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_author_name" + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) + m_re_match = mocker.patch("re.match", return_value="fake_author_name") retval = check_author(checks, "author_name") assert retval == PASS assert m_get_commit_info.call_count == 0 @@ -92,20 +68,11 @@ def test_check_author_with_different_check(self, mocker): @pytest.mark.benchmark def test_check_author_with_len0_regex(self, mocker, capfd): # Must NOT call get_commit_info, re.match with `regex` with length 0. - checks = [ - { - "check": "author_name", - "regex": "" - } - ] + checks = [{"check": "author_name", "regex": ""}] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) + m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") retval = check_author(checks, "author_name") assert retval == PASS assert m_get_commit_info.call_count == 0 @@ -116,26 +83,22 @@ def test_check_author_with_len0_regex(self, mocker, capfd): @pytest.mark.benchmark def test_check_author_with_result_none(self, mocker): # Must call print_error_message, print_suggestion when re.match returns NONE. - checks = [{ - "check": "author_name", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest" - }] + checks = [ + { + "check": "author_name", + "regex": "dummy_regex", + "error": "error", + "suggest": "suggest", + } + ] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch( - "re.match", - return_value=None + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an ) + m_re_match = mocker.patch("re.match", return_value=None) m_print_error_message = mocker.patch( "commit_check.util.print_error_message" ) - m_print_suggestion = mocker.patch( - "commit_check.util.print_suggestion" - ) + m_print_suggestion = mocker.patch("commit_check.util.print_suggestion") retval = check_author(checks, "author_name") assert retval == FAIL assert m_get_commit_info.call_count == 1 @@ -150,18 +113,11 @@ class TestAuthorEmail: @pytest.mark.benchmark def test_check_author(self, mocker): # Must call get_commit_info, re.match. - checks = [{ - "check": "author_email", - "regex": "dummy_regex" - }] + checks = [{"check": "author_email", "regex": "dummy_regex"}] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) + m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") retval = check_author(checks, "author_email") assert retval == PASS assert m_get_commit_info.call_count == 1 @@ -172,13 +128,9 @@ def test_check_author_with_empty_checks(self, mocker): # Must NOT call get_commit_info, re.match. with `checks` param with length 0. checks = [] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_author_email" + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) + m_re_match = mocker.patch("re.match", return_value="fake_author_email") retval = check_author(checks, "author_email") assert retval == PASS assert m_get_commit_info.call_count == 0 @@ -187,18 +139,11 @@ def test_check_author_with_empty_checks(self, mocker): @pytest.mark.benchmark def test_check_author_with_different_check(self, mocker): # Must NOT call get_commit_info, re.match with not `author_email`. - checks = [{ - "check": "message", - "regex": "dummy_regex" - }] + checks = [{"check": "message", "regex": "dummy_regex"}] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_author_email" + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) + m_re_match = mocker.patch("re.match", return_value="fake_author_email") retval = check_author(checks, "author_email") assert retval == PASS assert m_get_commit_info.call_count == 0 @@ -207,20 +152,11 @@ def test_check_author_with_different_check(self, mocker): @pytest.mark.benchmark def test_check_author_with_len0_regex(self, mocker, capfd): # Must NOT call get_commit_info, re.match with `regex` with length 0. - checks = [ - { - "check": "author_email", - "regex": "" - } - ] + checks = [{"check": "author_email", "regex": ""}] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) + m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") retval = check_author(checks, "author_email") assert retval == PASS assert m_get_commit_info.call_count == 0 @@ -231,26 +167,22 @@ def test_check_author_with_len0_regex(self, mocker, capfd): @pytest.mark.benchmark def test_check_author_with_result_none(self, mocker): # Must call print_error_message, print_suggestion when re.match returns NONE. - checks = [{ - "check": "author_email", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest" - }] + checks = [ + { + "check": "author_email", + "regex": "dummy_regex", + "error": "error", + "suggest": "suggest", + } + ] m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch( - "re.match", - return_value=None + f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae ) + m_re_match = mocker.patch("re.match", return_value=None) m_print_error_message = mocker.patch( "commit_check.util.print_error_message" ) - m_print_suggestion = mocker.patch( - "commit_check.util.print_suggestion" - ) + m_print_suggestion = mocker.patch("commit_check.util.print_suggestion") retval = check_author(checks, "author_email") assert retval == FAIL assert m_get_commit_info.call_count == 1 diff --git a/tests/branch_test.py b/tests/branch_test.py index 580cc1fe..e24423a2 100644 --- a/tests/branch_test.py +++ b/tests/branch_test.py @@ -11,18 +11,11 @@ class TestCheckBranch: @pytest.mark.benchmark def test_check_branch(self, mocker): # Must call get_branch_name, re.match at once. - checks = [{ - "check": "branch", - "regex": "dummy_regex" - }] + checks = [{"check": "branch", "regex": "dummy_regex"}] m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" + f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME ) + m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") retval = check_branch(checks) assert retval == PASS assert m_get_branch_name.call_count == 1 @@ -33,13 +26,9 @@ def test_check_branch_with_empty_checks(self, mocker): # Must NOT call get_branch_name, re.match with `checks` param with length 0. checks = [] m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_branch_name" + f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME ) + m_re_match = mocker.patch("re.match", return_value="fake_branch_name") retval = check_branch(checks) assert retval == PASS assert m_get_branch_name.call_count == 0 @@ -48,18 +37,11 @@ def test_check_branch_with_empty_checks(self, mocker): @pytest.mark.benchmark def test_check_branch_with_different_check(self, mocker): # Must NOT call get_branch_name, re.match with not `branch`. - checks = [{ - "check": "message", - "regex": "dummy_regex" - }] + checks = [{"check": "message", "regex": "dummy_regex"}] m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_branch_name" + f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME ) + m_re_match = mocker.patch("re.match", return_value="fake_branch_name") retval = check_branch(checks) assert retval == PASS assert m_get_branch_name.call_count == 0 @@ -68,20 +50,11 @@ def test_check_branch_with_different_check(self, mocker): @pytest.mark.benchmark def test_check_branch_with_len0_regex(self, mocker, capfd): # Must NOT call get_branch_name, re.match with `regex` with length 0. - checks = [ - { - "check": "branch", - "regex": "" - } - ] + checks = [{"check": "branch", "regex": ""}] m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" + f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME ) + m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") retval = check_branch(checks) assert retval == PASS assert m_get_branch_name.call_count == 0 @@ -92,26 +65,20 @@ def test_check_branch_with_len0_regex(self, mocker, capfd): @pytest.mark.benchmark def test_check_branch_with_result_none(self, mocker): # Must call print_error_message, print_suggestion when re.match returns NONE. - checks = [{ - "check": "branch", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest" - }] + checks = [ + { + "check": "branch", + "regex": "dummy_regex", + "error": "error", + "suggest": "suggest", + } + ] m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", - return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch( - "re.match", - return_value=None - ) - m_print_error_message = mocker.patch( - "commit_check.util.print_error_message" - ) - m_print_suggestion = mocker.patch( - "commit_check.util.print_suggestion" + f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME ) + m_re_match = mocker.patch("re.match", return_value=None) + m_print_error_message = mocker.patch("commit_check.util.print_error_message") + m_print_suggestion = mocker.patch("commit_check.util.print_suggestion") retval = check_branch(checks) assert retval == FAIL assert m_get_branch_name.call_count == 1 @@ -131,10 +98,7 @@ def test_check_merge_base_with_empty_checks(self, mocker): @pytest.mark.benchmark def test_check_merge_base_with_empty_regex(self, mocker): - checks = [{ - "check": "merge_base", - "regex": "" - }] + checks = [{"check": "merge_base", "regex": ""}] m_check_merge = mocker.patch(f"{LOCATION}.check_merge_base") retval = check_merge_base(checks) assert retval == PASS @@ -142,10 +106,7 @@ def test_check_merge_base_with_empty_regex(self, mocker): @pytest.mark.benchmark def test_check_merge_base_with_different_check(self, mocker): - checks = [{ - "check": "branch", - "regex": "main" - }] + checks = [{"check": "branch", "regex": "main"}] m_check_merge = mocker.patch(f"{LOCATION}.check_merge_base") retval = check_merge_base(checks) assert retval == PASS @@ -153,12 +114,14 @@ def test_check_merge_base_with_different_check(self, mocker): @pytest.mark.benchmark def test_check_merge_base_fail_with_messages(self, mocker, capfd): - checks = [{ - "check": "merge_base", - "regex": "develop", - "error": "Current branch is not", - "suggest": "Please rebase" - }] + checks = [ + { + "check": "merge_base", + "regex": "develop", + "error": "Current branch is not", + "suggest": "Please rebase", + } + ] mocker.patch(f"{LOCATION}.check_merge_base", return_value=1) m_print_error = mocker.patch("commit_check.util.print_error_message") m_print_suggest = mocker.patch("commit_check.util.print_suggestion") diff --git a/tests/commit_test.py b/tests/commit_test.py index 141aa295..4c681432 100644 --- a/tests/commit_test.py +++ b/tests/commit_test.py @@ -1,13 +1,19 @@ import pytest from commit_check import PASS, FAIL -from commit_check.commit import check_commit_msg, get_default_commit_msg_file, read_commit_msg, check_commit_signoff, check_imperative +from commit_check.commit import ( + check_commit_msg, + get_default_commit_msg_file, + read_commit_msg, + check_commit_signoff, + check_imperative, +) # used by get_commit_info mock FAKE_BRANCH_NAME = "fake_commits_info" # The location of check_commit_msg() LOCATION = "commit_check.util" # Commit message file -MSG_FILE = '.git/COMMIT_EDITMSG' +MSG_FILE = ".git/COMMIT_EDITMSG" @pytest.mark.benchmark @@ -29,7 +35,9 @@ def test_read_commit_msg_from_existing_file(tmp_path): @pytest.mark.benchmark def test_read_commit_msg_file_not_found(mocker): - m_commits_info = mocker.patch('commit_check.util.get_commit_info', return_value='mocked_commits_info') + m_commits_info = mocker.patch( + "commit_check.util.get_commit_info", return_value="mocked_commits_info" + ) read_commit_msg("non_existent_file.txt") assert m_commits_info.call_count == 0 @@ -38,11 +46,10 @@ def test_read_commit_msg_file_not_found(mocker): def test_check_commit_msg_no_commit_msg_file(mocker): mock_get_default_commit_msg_file = mocker.patch( "commit_check.commit.get_default_commit_msg_file", - return_value=".git/COMMIT_EDITMSG" + return_value=".git/COMMIT_EDITMSG", ) mock_read_commit_msg = mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="Sample commit message" + "commit_check.commit.read_commit_msg", return_value="Sample commit message" ) checks = [{"regex": ".*", "check": "message", "error": "Invalid", "suggest": None}] @@ -57,10 +64,7 @@ def test_check_commit_msg_no_commit_msg_file(mocker): @pytest.mark.benchmark def test_check_commit_with_empty_checks(mocker): checks = [] - m_re_match = mocker.patch( - "re.match", - return_value="fake_commits_info" - ) + m_re_match = mocker.patch("re.match", return_value="fake_commits_info") retval = check_commit_msg(checks, MSG_FILE) assert retval == PASS assert m_re_match.call_count == 0 @@ -68,14 +72,8 @@ def test_check_commit_with_empty_checks(mocker): @pytest.mark.benchmark def test_check_commit_with_different_check(mocker): - checks = [{ - "check": "branch", - "regex": "dummy_regex" - }] - m_re_match = mocker.patch( - "re.match", - return_value="fake_commits_info" - ) + checks = [{"check": "branch", "regex": "dummy_regex"}] + m_re_match = mocker.patch("re.match", return_value="fake_commits_info") retval = check_commit_msg(checks, MSG_FILE) assert retval == PASS assert m_re_match.call_count == 0 @@ -83,16 +81,8 @@ def test_check_commit_with_different_check(mocker): @pytest.mark.benchmark def test_check_commit_with_len0_regex(mocker, capfd): - checks = [ - { - "check": "message", - "regex": "" - } - ] - m_re_match = mocker.patch( - "re.match", - return_value="fake_rematch_resp" - ) + checks = [{"check": "message", "regex": ""}] + m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") retval = check_commit_msg(checks, MSG_FILE) assert retval == PASS assert m_re_match.call_count == 0 @@ -102,22 +92,17 @@ def test_check_commit_with_len0_regex(mocker, capfd): @pytest.mark.benchmark def test_check_commit_with_result_none(mocker): - checks = [{ - "check": "message", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest" - }] - m_re_match = mocker.patch( - "re.match", - return_value=None - ) - m_print_error_message = mocker.patch( - f"{LOCATION}.print_error_message" - ) - m_print_suggestion = mocker.patch( - f"{LOCATION}.print_suggestion" - ) + checks = [ + { + "check": "message", + "regex": "dummy_regex", + "error": "error", + "suggest": "suggest", + } + ] + m_re_match = mocker.patch("re.match", return_value=None) + m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") + m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") retval = check_commit_msg(checks, MSG_FILE) assert retval == FAIL assert m_re_match.call_count == 1 @@ -127,26 +112,20 @@ def test_check_commit_with_result_none(mocker): @pytest.mark.benchmark def test_check_commit_signoff(mocker): - checks = [{ - "check": "commit_signoff", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest" - }] - m_re_search = mocker.patch( - "re.search", - return_value=None - ) - m_print_error_message = mocker.patch( - f"{LOCATION}.print_error_message" - ) - m_print_suggestion = mocker.patch( - f"{LOCATION}.print_suggestion" - ) + checks = [ + { + "check": "commit_signoff", + "regex": "dummy_regex", + "error": "error", + "suggest": "suggest", + } + ] + m_re_search = mocker.patch("re.search", return_value=None) + m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") + m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") # Ensure commit message is NOT a merge commit mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="feat: add new feature" + "commit_check.commit.read_commit_msg", return_value="feat: add new feature" ) retval = check_commit_signoff(checks) assert retval == FAIL @@ -157,16 +136,10 @@ def test_check_commit_signoff(mocker): @pytest.mark.benchmark def test_check_commit_signoff_with_empty_regex(mocker): - checks = [{ - "check": "commit_signoff", - "regex": "", - "error": "error", - "suggest": "suggest" - }] - m_re_match = mocker.patch( - "re.match", - return_value="fake_commits_info" - ) + checks = [ + {"check": "commit_signoff", "regex": "", "error": "error", "suggest": "suggest"} + ] + m_re_match = mocker.patch("re.match", return_value="fake_commits_info") retval = check_commit_signoff(checks) assert retval == PASS assert m_re_match.call_count == 0 @@ -175,10 +148,7 @@ def test_check_commit_signoff_with_empty_regex(mocker): @pytest.mark.benchmark def test_check_commit_signoff_with_empty_checks(mocker): checks = [] - m_re_match = mocker.patch( - "re.match", - return_value="fake_commits_info" - ) + m_re_match = mocker.patch("re.match", return_value="fake_commits_info") retval = check_commit_signoff(checks) assert retval == PASS assert m_re_match.call_count == 0 @@ -187,16 +157,18 @@ def test_check_commit_signoff_with_empty_checks(mocker): @pytest.mark.benchmark def test_check_commit_signoff_skip_merge_commit(mocker): """Test commit signoff check skips merge commits.""" - checks = [{ - "check": "commit_signoff", - "regex": "Signed-off-by:", - "error": "Signed-off-by not found", - "suggest": "Use --signoff" - }] + checks = [ + { + "check": "commit_signoff", + "regex": "Signed-off-by:", + "error": "Signed-off-by not found", + "suggest": "Use --signoff", + } + ] mocker.patch( "commit_check.commit.read_commit_msg", - return_value="Merge branch 'feature/test' into main" + return_value="Merge branch 'feature/test' into main", ) retval = check_commit_signoff(checks, MSG_FILE) @@ -206,16 +178,18 @@ def test_check_commit_signoff_skip_merge_commit(mocker): @pytest.mark.benchmark def test_check_commit_signoff_skip_merge_pr_commit(mocker): """Test commit signoff check skips GitHub merge PR commits.""" - checks = [{ - "check": "commit_signoff", - "regex": "Signed-off-by:", - "error": "Signed-off-by not found", - "suggest": "Use --signoff" - }] + checks = [ + { + "check": "commit_signoff", + "regex": "Signed-off-by:", + "error": "Signed-off-by not found", + "suggest": "Use --signoff", + } + ] mocker.patch( "commit_check.commit.read_commit_msg", - return_value="Merge pull request #123 from user/feature\n\nAdd new feature" + return_value="Merge pull request #123 from user/feature\n\nAdd new feature", ) retval = check_commit_signoff(checks, MSG_FILE) @@ -225,24 +199,22 @@ def test_check_commit_signoff_skip_merge_pr_commit(mocker): @pytest.mark.benchmark def test_check_commit_signoff_still_fails_non_merge_without_signoff(mocker): """Test commit signoff check still fails for non-merge commits without signoff.""" - checks = [{ - "check": "commit_signoff", - "regex": "Signed-off-by:", - "error": "Signed-off-by not found", - "suggest": "Use --signoff" - }] + checks = [ + { + "check": "commit_signoff", + "regex": "Signed-off-by:", + "error": "Signed-off-by not found", + "suggest": "Use --signoff", + } + ] mocker.patch( "commit_check.commit.read_commit_msg", - return_value="feat: add new feature\n\nThis adds a new feature" + return_value="feat: add new feature\n\nThis adds a new feature", ) - m_print_error_message = mocker.patch( - f"{LOCATION}.print_error_message" - ) - m_print_suggestion = mocker.patch( - f"{LOCATION}.print_suggestion" - ) + m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") + m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") retval = check_commit_signoff(checks, MSG_FILE) assert retval == FAIL @@ -253,16 +225,18 @@ def test_check_commit_signoff_still_fails_non_merge_without_signoff(mocker): @pytest.mark.benchmark def test_check_imperative_pass(mocker): """Test imperative mood check passes for valid imperative mood.""" - checks = [{ - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood" - }] + checks = [ + { + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood", + } + ] mocker.patch( "commit_check.commit.read_commit_msg", - return_value="feat: Add new feature\n\nThis adds a new feature to the application." + return_value="feat: Add new feature\n\nThis adds a new feature to the application.", ) retval = check_imperative(checks, MSG_FILE) @@ -272,24 +246,21 @@ def test_check_imperative_pass(mocker): @pytest.mark.benchmark def test_check_imperative_fail_past_tense(mocker): """Test imperative mood check fails for past tense.""" - checks = [{ - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood" - }] + checks = [ + { + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood", + } + ] mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="feat: Added new feature" + "commit_check.commit.read_commit_msg", return_value="feat: Added new feature" ) - m_print_error_message = mocker.patch( - f"{LOCATION}.print_error_message" - ) - m_print_suggestion = mocker.patch( - f"{LOCATION}.print_suggestion" - ) + m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") + m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") retval = check_imperative(checks, MSG_FILE) assert retval == FAIL @@ -300,24 +271,21 @@ def test_check_imperative_fail_past_tense(mocker): @pytest.mark.benchmark def test_check_imperative_fail_present_continuous(mocker): """Test imperative mood check fails for present continuous.""" - checks = [{ - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood" - }] + checks = [ + { + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood", + } + ] mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="feat: Adding new feature" + "commit_check.commit.read_commit_msg", return_value="feat: Adding new feature" ) - m_print_error_message = mocker.patch( - f"{LOCATION}.print_error_message" - ) - m_print_suggestion = mocker.patch( - f"{LOCATION}.print_suggestion" - ) + m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") + m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") retval = check_imperative(checks, MSG_FILE) assert retval == FAIL @@ -328,16 +296,18 @@ def test_check_imperative_fail_present_continuous(mocker): @pytest.mark.benchmark def test_check_imperative_skip_merge_commit(mocker): """Test imperative mood check skips merge commits.""" - checks = [{ - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood" - }] + checks = [ + { + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood", + } + ] mocker.patch( "commit_check.commit.read_commit_msg", - return_value="Merge branch 'feature/test' into main" + return_value="Merge branch 'feature/test' into main", ) retval = check_imperative(checks, MSG_FILE, stdin_text=None) @@ -347,14 +317,10 @@ def test_check_imperative_skip_merge_commit(mocker): @pytest.mark.benchmark def test_check_imperative_different_check_type(mocker): """Test imperative mood check skips different check types.""" - checks = [{ - "check": "message", - "regex": "dummy_regex" - }] + checks = [{"check": "message", "regex": "dummy_regex"}] m_read_commit_msg = mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="feat: Added new feature" + "commit_check.commit.read_commit_msg", return_value="feat: Added new feature" ) retval = check_imperative(checks, MSG_FILE, stdin_text="feat: Added new feature") @@ -365,12 +331,14 @@ def test_check_imperative_different_check_type(mocker): @pytest.mark.benchmark def test_check_imperative_no_commits(mocker): """Test imperative mood check passes when there are no commits.""" - checks = [{ - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood" - }] + checks = [ + { + "check": "imperative", + "regex": "", + "error": "Commit message should use imperative mood", + "suggest": "Use imperative mood", + } + ] mocker.patch("commit_check.commit.has_commits", return_value=False) @@ -384,8 +352,7 @@ def test_check_imperative_empty_checks(mocker): checks = [] m_read_commit_msg = mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="feat: Added new feature" + "commit_check.commit.read_commit_msg", return_value="feat: Added new feature" ) retval = check_imperative(checks, MSG_FILE, stdin_text=None) diff --git a/tests/error_test.py b/tests/error_test.py index ab9e9487..51da7f62 100644 --- a/tests/error_test.py +++ b/tests/error_test.py @@ -42,11 +42,12 @@ def test_error_handler_cannot_access(mocker): mocker.patch("sys.executable", "/mock/path/to/python") from commit_check.error import log_and_exit + log_and_exit( msg="Test error message", ret_code=1, exc=ValueError("Test exception"), - formatted="Mocked formatted stack trace" + formatted="Mocked formatted stack trace", ) mock_os_access.assert_called_once_with(store_dir, os.W_OK) diff --git a/tests/main_test.py b/tests/main_test.py index 71fc89d7..20478e6b 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -21,14 +21,20 @@ def test_full_run_invokes_each_check_once(self, mocker): {"check": "merge_base"}, {"check": "imperative"}, ] - } + }, ) m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) m_branch = mocker.patch("commit_check.branch.check_branch", return_value=PASS) m_author = mocker.patch("commit_check.author.check_author", return_value=PASS) - m_signoff = mocker.patch("commit_check.commit.check_commit_signoff", return_value=PASS) - m_merge = mocker.patch("commit_check.branch.check_merge_base", return_value=PASS) - m_imperative = mocker.patch("commit_check.commit.check_imperative", return_value=PASS) + m_signoff = mocker.patch( + "commit_check.commit.check_commit_signoff", return_value=PASS + ) + m_merge = mocker.patch( + "commit_check.branch.check_merge_base", return_value=PASS + ) + m_imperative = mocker.patch( + "commit_check.commit.check_imperative", return_value=PASS + ) sys.argv = [CMD, "run"] assert main() == PASS assert m_msg.call_count == 1 @@ -76,7 +82,18 @@ def test_default_config_used_when_validate_returns_empty(self, mocker): (PASS, PASS, PASS, PASS, PASS, PASS, FAIL, FAIL), ], ) - def test_exit_code_aggregation(self, mocker, message_result, branch_result, author_name_result, author_email_result, signoff_result, merge_base_result, imperative_result, expected): + def test_exit_code_aggregation( + self, + mocker, + message_result, + branch_result, + author_name_result, + author_email_result, + signoff_result, + merge_base_result, + imperative_result, + expected, + ): # configure all check types mocker.patch( "commit_check.main.validate_config", @@ -90,24 +107,35 @@ def test_exit_code_aggregation(self, mocker, message_result, branch_result, auth {"check": "merge_base"}, {"check": "imperative"}, ] - } + }, ) - mocker.patch("commit_check.commit.check_commit_msg", return_value=message_result) + mocker.patch( + "commit_check.commit.check_commit_msg", return_value=message_result + ) mocker.patch("commit_check.branch.check_branch", return_value=branch_result) def author_side_effect(_, which, **_kw): # type: ignore[return] return author_name_result if which == "author_name" else author_email_result mocker.patch("commit_check.author.check_author", side_effect=author_side_effect) - mocker.patch("commit_check.commit.check_commit_signoff", return_value=signoff_result) - mocker.patch("commit_check.branch.check_merge_base", return_value=merge_base_result) - mocker.patch("commit_check.commit.check_imperative", return_value=imperative_result) + mocker.patch( + "commit_check.commit.check_commit_signoff", return_value=signoff_result + ) + mocker.patch( + "commit_check.branch.check_merge_base", return_value=merge_base_result + ) + mocker.patch( + "commit_check.commit.check_imperative", return_value=imperative_result + ) sys.argv = [CMD, "run"] assert main() == expected def test_unknown_check_type_ignored(self, mocker): - mocker.patch("commit_check.main.validate_config", return_value={"checks": [{"check": "totally_unknown"}]}) + mocker.patch( + "commit_check.main.validate_config", + return_value={"checks": [{"check": "totally_unknown"}]}, + ) # no dispatcher functions patched intentionally sys.argv = [CMD, "run"] assert main() == PASS diff --git a/tests/util_test.py b/tests/util_test.py index 89370055..0505eb93 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -19,53 +19,46 @@ class TestGetBranchName: def test_get_branch_name(self, mocker): # Must call cmd_output with given argument. m_cmd_output = mocker.patch( - "commit_check.util.cmd_output", - return_value=" fake_branch_name " + "commit_check.util.cmd_output", return_value=" fake_branch_name " ) retval = get_branch_name() assert m_cmd_output.call_count == 1 - assert m_cmd_output.call_args[0][0] == [ - "git", "branch", "--show-current" - ] + assert m_cmd_output.call_args[0][0] == ["git", "branch", "--show-current"] assert retval == "fake_branch_name" @pytest.mark.benchmark def test_get_branch_name_with_exception(self, mocker): # Must return empty string when exception raises in cmd_output. m_cmd_output = mocker.patch( - "commit_check.util.cmd_output", - return_value=" fake_branch_name " + "commit_check.util.cmd_output", return_value=" fake_branch_name " ) # CalledProcessError's args also dummy dummy_ret_code, dummy_cmd_name = 1, "dcmd" m_cmd_output.side_effect = CalledProcessError( - dummy_ret_code, - dummy_cmd_name + dummy_ret_code, dummy_cmd_name ) retval = get_branch_name() assert m_cmd_output.call_count == 1 - assert m_cmd_output.call_args[0][0] == [ - "git", "branch", "--show-current" - ] + assert m_cmd_output.call_args[0][0] == ["git", "branch", "--show-current"] assert retval == "" class TestHasCommits: @pytest.mark.benchmark def test_has_commits_true(self, mocker): # Must return True when git rev-parse HEAD succeeds - m_subprocess_run = mocker.patch( - "subprocess.run", - return_value=None - ) + m_subprocess_run = mocker.patch("subprocess.run", return_value=None) retval = has_commits() assert m_subprocess_run.call_count == 1 assert m_subprocess_run.call_args[0][0] == [ - "git", "rev-parse", "--verify", "HEAD" + "git", + "rev-parse", + "--verify", + "HEAD", ] assert m_subprocess_run.call_args[1] == { - 'stdout': subprocess.DEVNULL, - 'stderr': subprocess.DEVNULL, - 'check': True + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "check": True, } assert retval is True @@ -74,27 +67,33 @@ def test_has_commits_false(self, mocker): # Must return False when git rev-parse HEAD fails m_subprocess_run = mocker.patch( "subprocess.run", - side_effect=subprocess.CalledProcessError(128, "git rev-parse") + side_effect=subprocess.CalledProcessError(128, "git rev-parse"), ) retval = has_commits() assert m_subprocess_run.call_count == 1 assert m_subprocess_run.call_args[0][0] == [ - "git", "rev-parse", "--verify", "HEAD" + "git", + "rev-parse", + "--verify", + "HEAD", ] assert m_subprocess_run.call_args[1] == { - 'stdout': subprocess.DEVNULL, - 'stderr': subprocess.DEVNULL, - 'check': True + "stdout": subprocess.DEVNULL, + "stderr": subprocess.DEVNULL, + "check": True, } assert retval is False class TestGitMergeBase: @pytest.mark.benchmark - @pytest.mark.parametrize("returncode,expected", [ - (0, 0), # ancestor exists - (1, 1), # no ancestor - (128, 128), # error case - ]) + @pytest.mark.parametrize( + "returncode,expected", + [ + (0, 0), # ancestor exists + (1, 1), # no ancestor + (128, 128), # error case + ], + ) def test_git_merge_base(self, mocker, returncode, expected): mock_run = mocker.patch("subprocess.run") if returncode == 128: @@ -108,74 +107,74 @@ def test_git_merge_base(self, mocker, returncode, expected): mock_run.assert_called_once_with( ["git", "merge-base", "--is-ancestor", "main", "feature"], - stdout=subprocess.PIPE, stderr=subprocess.PIPE, encoding='utf-8' + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + encoding="utf-8", ) assert result == expected class TestGetCommitInfo: @pytest.mark.benchmark - @pytest.mark.parametrize("format_string", [ - ("s"), - ("an"), - ("ae"), - ] + @pytest.mark.parametrize( + "format_string", + [ + ("s"), + ("an"), + ("ae"), + ], ) def test_get_commit_info(self, mocker, format_string): # Must call get_commit_info with given argument when there are commits. - mocker.patch( - "commit_check.util.has_commits", - return_value=True - ) + mocker.patch("commit_check.util.has_commits", return_value=True) m_cmd_output = mocker.patch( - "commit_check.util.cmd_output", - return_value=" fake commit message " + "commit_check.util.cmd_output", return_value=" fake commit message " ) retval = get_commit_info(format_string) assert m_cmd_output.call_count == 1 assert m_cmd_output.call_args[0][0] == [ - "git", "log", "-n", "1", f"--pretty=format:%{format_string}", "HEAD" + "git", + "log", + "-n", + "1", + f"--pretty=format:%{format_string}", + "HEAD", ] assert retval == " fake commit message " @pytest.mark.benchmark def test_get_commit_info_no_commits(self, mocker): # Must return 'Repo has no commits yet.' when there are no commits. + mocker.patch("commit_check.util.has_commits", return_value=False) mocker.patch( - "commit_check.util.has_commits", - return_value=False - ) - mocker.patch( - "commit_check.util.cmd_output", - return_value=" fake commit message " + "commit_check.util.cmd_output", return_value=" fake commit message " ) format_string = "s" retval = get_commit_info(format_string) assert retval == " fake commit message " - @pytest.mark.benchmark def test_get_commit_info_with_exception(self, mocker): # Must return empty string when exception raises in cmd_output. - mocker.patch( - "commit_check.util.has_commits", - return_value=True - ) + mocker.patch("commit_check.util.has_commits", return_value=True) m_cmd_output = mocker.patch( - "commit_check.util.cmd_output", - return_value=" fake commit message " + "commit_check.util.cmd_output", return_value=" fake commit message " ) # CalledProcessError's args also dummy dummy_ret_code, dummy_cmd_name = 1, "dcmd" m_cmd_output.side_effect = CalledProcessError( - dummy_ret_code, - dummy_cmd_name + dummy_ret_code, dummy_cmd_name ) format_string = "s" retval = get_commit_info(format_string) assert m_cmd_output.call_count == 1 assert m_cmd_output.call_args[0][0] == [ - "git", "log", "-n", "1", f"--pretty=format:%{format_string}", "HEAD" + "git", + "log", + "-n", + "1", + f"--pretty=format:%{format_string}", + "HEAD", ] assert retval == "" @@ -191,26 +190,26 @@ def __init__(self, returncode, stdout, stderr): def test_cmd_output(self, mocker): # Must subprocess.run with given argument. m_subprocess_run = mocker.patch( - "subprocess.run", - return_value=self.DummyProcessResult(0, "ok", "") + "subprocess.run", return_value=self.DummyProcessResult(0, "ok", "") ) retval = cmd_output(["dummy_cmd"]) assert m_subprocess_run.call_count == 1 assert retval == "ok" @pytest.mark.benchmark - @pytest.mark.parametrize("returncode, stdout, stderr", [ - (1, "ok", "err"), - (0, None, "err"), - (1, None, "err"), - ] + @pytest.mark.parametrize( + "returncode, stdout, stderr", + [ + (1, "ok", "err"), + (0, None, "err"), + (1, None, "err"), + ], ) def test_cmd_output_err(self, mocker, returncode, stdout, stderr): # Must return stderr when subprocess.run returns not empty stderr. m_subprocess_run = mocker.patch( "subprocess.run", - return_value=self.DummyProcessResult( - returncode, stdout, stderr) + return_value=self.DummyProcessResult(returncode, stdout, stderr), ) dummy_cmd = ["dummy_cmd"] retval = cmd_output(dummy_cmd) @@ -218,24 +217,27 @@ def test_cmd_output_err(self, mocker, returncode, stdout, stderr): assert retval == stderr assert m_subprocess_run.call_args[0][0] == dummy_cmd assert m_subprocess_run.call_args[1] == { - 'encoding': 'utf-8', - 'stderr': PIPE, - "stdout": PIPE + "encoding": "utf-8", + "stderr": PIPE, + "stdout": PIPE, } @pytest.mark.benchmark - @pytest.mark.parametrize("returncode, stdout, stderr", [ - (1, "ok", ""), - (0, None, ""), - (1, None, ""), - ] + @pytest.mark.parametrize( + "returncode, stdout, stderr", + [ + (1, "ok", ""), + (0, None, ""), + (1, None, ""), + ], ) - def test_cmd_output_err_with_len0_stderr(self, mocker, returncode, stdout, stderr): + def test_cmd_output_err_with_len0_stderr( + self, mocker, returncode, stdout, stderr + ): # Must return empty string when subprocess.run returns empty stderr. m_subprocess_run = mocker.patch( "subprocess.run", - return_value=self.DummyProcessResult( - returncode, stdout, stderr) + return_value=self.DummyProcessResult(returncode, stdout, stderr), ) dummy_cmd = ["dummy_cmd"] retval = cmd_output(dummy_cmd) @@ -243,9 +245,9 @@ def test_cmd_output_err_with_len0_stderr(self, mocker, returncode, stdout, stder assert retval == "" assert m_subprocess_run.call_args[0][0] == dummy_cmd assert m_subprocess_run.call_args[1] == { - 'encoding': 'utf-8', - 'stderr': PIPE, - "stdout": PIPE + "encoding": "utf-8", + "stderr": PIPE, + "stdout": PIPE, } class TestValidateConfig: @@ -254,10 +256,7 @@ def test_validate_config(self, mocker): # Must call yaml.safe_load. mocker.patch("builtins.open") dummy_resp = {"key": "value"} - m_yaml_safe_load = mocker.patch( - "yaml.safe_load", - return_value=dummy_resp - ) + m_yaml_safe_load = mocker.patch("yaml.safe_load", return_value=dummy_resp) retval = validate_config("dummy_path") assert m_yaml_safe_load.call_count == 1 assert retval == dummy_resp @@ -281,24 +280,22 @@ def test_print_error_header(self, capfd): assert "Commit rejected." in stdout @pytest.mark.benchmark - @pytest.mark.parametrize("check_type, type_failed_msg", [ - ("message", "check failed =>"), - ("branch", "check failed =>"), - ("author_name", "check failed =>"), - ("author_email", "check failed =>"), - ("commit_signoff", "check failed =>"), - ]) + @pytest.mark.parametrize( + "check_type, type_failed_msg", + [ + ("message", "check failed =>"), + ("branch", "check failed =>"), + ("author_name", "check failed =>"), + ("author_email", "check failed =>"), + ("commit_signoff", "check failed =>"), + ], + ) def test_print_error_message(self, capfd, check_type, type_failed_msg): # Must print on stdout with given argument. dummy_regex = "dummy regex" dummy_reason = "failure reason" dummy_error = "dummy error" - print_error_message( - check_type, - dummy_regex, - dummy_error, - dummy_reason - ) + print_error_message(check_type, dummy_regex, dummy_error, dummy_reason) stdout, _ = capfd.readouterr() assert check_type in stdout assert type_failed_msg in stdout From 485bc2897d6e9eda3d96e1be738b471aa3d43194 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 13 Sep 2025 09:49:17 +0300 Subject: [PATCH 11/37] chore: change error output style --- commit_check/util.py | 2 +- tests/util_test.py | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/commit_check/util.py b/commit_check/util.py index 709f4610..6809a453 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -236,7 +236,7 @@ def print_error_message(check_type: str, regex: str, error: str, reason: str): :returns: Give error messages to user """ print( - f"Type {YELLOW}{check_type}{RESET_COLOR} check failed => {RED}{reason}{RESET_COLOR} ", + f"Type {YELLOW}{check_type}{RESET_COLOR} check failed ==> {RED}{reason}{RESET_COLOR} ", end="", ) print("") diff --git a/tests/util_test.py b/tests/util_test.py index 0505eb93..ae0ad016 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -283,11 +283,11 @@ def test_print_error_header(self, capfd): @pytest.mark.parametrize( "check_type, type_failed_msg", [ - ("message", "check failed =>"), - ("branch", "check failed =>"), - ("author_name", "check failed =>"), - ("author_email", "check failed =>"), - ("commit_signoff", "check failed =>"), + ("message", "check failed ==>"), + ("branch", "check failed ==>"), + ("author_name", "check failed ==>"), + ("author_email", "check failed ==>"), + ("commit_signoff", "check failed ==>"), ], ) def test_print_error_message(self, capfd, check_type, type_failed_msg): From 6d4cc18be9e61d89cccc5993392cd79a3b14649f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 13 Sep 2025 09:55:56 +0300 Subject: [PATCH 12/37] fix: skip if merge commit --- commit_check/commit.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/commit_check/commit.py b/commit_check/commit.py index 24627666..ace349a7 100644 --- a/commit_check/commit.py +++ b/commit_check/commit.py @@ -191,6 +191,9 @@ def check_subject_max_length( return PASS max_len = int(check.get("value", 0) or 0) subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) + # Skip if merge commit + if subject.startswith("Merge"): + return PASS if not max_len or len(subject) <= max_len: return PASS _print_failure(check, f"max_length={max_len}", subject) From 0e3e604342993f361bb6b0be0736a1eb20955e37 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 13 Sep 2025 15:19:52 +0300 Subject: [PATCH 13/37] fix: bypass when branch is HEAD --- commit_check/branch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/commit_check/branch.py b/commit_check/branch.py index 062c2a7d..46729f1b 100644 --- a/commit_check/branch.py +++ b/commit_check/branch.py @@ -58,6 +58,9 @@ def check_merge_base(checks: list) -> int: result = git_merge_base(target_branch, current_branch) if result == 0: return PASS + # Treat missing target (128) as skip only when detached HEAD (cannot verify ancestry reliably) + if result == 128 and current_branch == "HEAD": + return PASS _print_failure(check, regex, current_branch) return FAIL From bc89a4a0864292e003218c95e941424f6d2e3000 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sat, 13 Sep 2025 18:58:03 +0300 Subject: [PATCH 14/37] fix: update test --- tests/branch_test.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/tests/branch_test.py b/tests/branch_test.py index e24423a2..5898c3e6 100644 --- a/tests/branch_test.py +++ b/tests/branch_test.py @@ -122,7 +122,10 @@ def test_check_merge_base_fail_with_messages(self, mocker, capfd): "suggest": "Please rebase", } ] - mocker.patch(f"{LOCATION}.check_merge_base", return_value=1) + # Simulate a normal named branch so skip logic doesn't apply + mocker.patch(f"{LOCATION}.get_branch_name", return_value="feature/something") + # Force git merge-base to report not ancestor (return code 1) + mocker.patch(f"{LOCATION}.git_merge_base", return_value=1) m_print_error = mocker.patch("commit_check.util.print_error_message") m_print_suggest = mocker.patch("commit_check.util.print_suggestion") From fd7e1234962d8c17e98c5c3da5973d2069c62b15 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 14 Sep 2025 18:56:40 +0300 Subject: [PATCH 15/37] feat: add original commands back --- cchk.toml | 2 +- commit_check/main.py | 229 ++++++++++++++++++++++++------------------- tests/main_test.py | 51 ++++------ 3 files changed, 144 insertions(+), 138 deletions(-) diff --git a/cchk.toml b/cchk.toml index 3982299b..311599b7 100644 --- a/cchk.toml +++ b/cchk.toml @@ -12,9 +12,9 @@ allow_empty_commits = false allow_fixup_commits = true allow_wip_commits = false require_body = false +require_signed_off_by = false allow_authors = [] ignore_authors = ["dependabot[bot]", "copilot[bot]"] -require_signed_off_by = false [branch] # https://conventional-branch.github.io/ diff --git a/commit_check/main.py b/commit_check/main.py index e1fb34c4..4221da34 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -12,12 +12,12 @@ from __future__ import annotations import sys import argparse -from typing import Optional, Dict, Callable +from typing import Optional from commit_check import branch, commit, author from commit_check.error import error_handler from commit_check.util import validate_config -from . import PASS, FAIL, __version__, DEFAULT_CONFIG +from . import CONFIG_FILE, PASS, FAIL, __version__, DEFAULT_CONFIG class LogLevel: @@ -61,121 +61,144 @@ def _read_stdin() -> Optional[str]: # read commit message content if piped return None -def _dispatch_checks_full(checks: list, stdin_text: Optional[str]) -> int: - """Execute ALL configured checks once (used by future run mode).""" - dispatcher: Dict[str, Callable[[], int]] = { - "message": lambda: commit.check_commit_msg(checks, stdin_text=stdin_text), - "imperative": lambda: commit.check_imperative(checks, stdin_text=stdin_text), - "subject_capitalized": lambda: commit.check_subject_capitalized( - checks, stdin_text=stdin_text - ), - "subject_max_length": lambda: commit.check_subject_max_length( - checks, stdin_text=stdin_text - ), - "subject_min_length": lambda: commit.check_subject_min_length( - checks, stdin_text=stdin_text - ), - "allow_commit_types": lambda: commit.check_allow_commit_types( - checks, stdin_text=stdin_text - ), - "allow_merge_commits": lambda: commit.check_allow_merge_commits( - checks, stdin_text=stdin_text - ), - "allow_revert_commits": lambda: commit.check_allow_revert_commits( - checks, stdin_text=stdin_text - ), - "allow_empty_commits": lambda: commit.check_allow_empty_commits( - checks, stdin_text=stdin_text - ), - "allow_fixup_commits": lambda: commit.check_allow_fixup_commits( - checks, stdin_text=stdin_text - ), - "allow_wip_commits": lambda: commit.check_allow_wip_commits( - checks, stdin_text=stdin_text - ), - "commit_signoff": lambda: commit.check_commit_signoff( - checks, stdin_text=stdin_text - ), - "require_body": lambda: commit.check_require_body( - checks, stdin_text=stdin_text - ), - "branch": lambda: branch.check_branch(checks, stdin_text=stdin_text), - "merge_base": lambda: branch.check_merge_base(checks), - "author_name": lambda: author.check_author( - checks, "author_name", stdin_text=stdin_text - ), - "author_email": lambda: author.check_author( - checks, "author_email", stdin_text=stdin_text - ), - "allow_authors": lambda: author.check_allow_authors( - checks, "author_name", stdin_text=stdin_text - ), - "ignore_authors": lambda: author.check_ignore_authors( - checks, "author_name", stdin_text=stdin_text - ), - "commit_signoff_details": lambda: author.check_required_signoff_details( - checks, stdin_text=stdin_text - ), - } - seen = set() - results: list[int] = [] - for chk in checks: - ctype = chk.get("check") - if ctype in seen: - continue - seen.add(ctype) - func = dispatcher.get(ctype) - if func: - res = func() - results.append(res) - if LOG_LEVEL == LogLevel.VERBOSE: - log(f"[commit-check] {ctype} => {'OK' if res == PASS else 'FAIL'}") - return PASS if not results else (PASS if all(r == PASS for r in results) else FAIL) - - -def main() -> int: - argv = sys.argv[1:] +def _get_parser() -> argparse.ArgumentParser: + """Get and parser to interpret CLI args.""" parser = argparse.ArgumentParser( prog="commit-check", - description="check commit message, branch naming, committer name/email, commit signoff and more.", + description="Check commit message, branch naming, committer name, email, and more.", ) - parser.add_argument("command", choices=["run"], help="Only supported command: run") + parser.add_argument( - "path", - nargs="?", - default=".", - help="Repository path (default: current directory)", + "-v", + "--version", + action="version", + version=f"%(prog)s {__version__}", ) + parser.add_argument( + "-c", "--config", - type=str, - default=None, - help="Path to TOML configuration file (commit-check.toml or cchk.toml)", + default=CONFIG_FILE, + help="path to config file. default is . (current directory)", + ) + + parser.add_argument( + "-m", + "--message", + help="check commit message", + action="store_true", + required=False, + ) + + parser.add_argument( + "-b", + "--branch", + help="check branch naming", + action="store_true", + required=False, + ) + + parser.add_argument( + "-n", + "--author-name", + help="check committer's name", + action="store_true", + required=False, + ) + + parser.add_argument( + "-e", + "--author-email", + help="check committer's email", + action="store_true", + required=False, + ) + + parser.add_argument( + "-s", + "--commit-signoff", + help="check committer's signature", + action="store_true", + required=False, + ) + + parser.add_argument( + "-mb", + "--merge-base", + help="check branch is rebased onto target branch", + action="store_true", + required=False, ) - parser.add_argument("-v", "--verbose", action="store_true", help="Verbose logging") - parser.add_argument("-q", "--quiet", action="store_true", help="Quiet logging") - parser.add_argument("-s", "--silent", action="store_true", help="Silent mode") + + parser.add_argument( + "-d", + "--dry-run", + help="run checks without failing", + action="store_true", + required=False, + ) + parser.add_argument( - "-V", "--version", action="store_true", help="Show version and exit" + "-i", + "--imperative", + help="check commit message uses imperative mood", + action="store_true", + required=False, ) - args = parser.parse_args(argv) - if args.version: - print(__version__) - raise SystemExit(0) + return parser + + +def main() -> int: + """The main entrypoint of commit-check program.""" + parser = _get_parser() + args = parser.parse_args() + + if args.dry_run: + return PASS + + # Capture stdin (if piped) once and pass to checks. + stdin_text = None + try: + if not sys.stdin.isatty(): + data = sys.stdin.read() + stdin_text = data or None + except Exception: + stdin_text = None - # Command guard (argparse choices already enforce) - if args.command != "run": # pragma: no cover - parser.error("only 'run' is supported") + check_results: list[int] = [] - set_log_level(args.verbose, args.quiet, args.silent) - cfg_path = args.config or args.path - stdin_text = _read_stdin() with error_handler(): - cfg = validate_config(cfg_path) or DEFAULT_CONFIG - checks = cfg.get("checks", []) - status = _dispatch_checks_full(checks, stdin_text=stdin_text) - return status + config = ( + validate_config(args.config) + if validate_config( + args.config, + ) + else DEFAULT_CONFIG + ) + checks = config["checks"] + if args.message: + check_results.append(commit.check_commit_msg(checks, stdin_text=stdin_text)) + if args.branch: + check_results.append(branch.check_branch(checks, stdin_text=stdin_text)) + if args.author_name: + check_results.append( + author.check_author(checks, "author_name", stdin_text=stdin_text) + ) + if args.author_email: + check_results.append( + author.check_author(checks, "author_email", stdin_text=stdin_text) + ) + if args.commit_signoff: + check_results.append( + commit.check_commit_signoff(checks, stdin_text=stdin_text) + ) + if args.merge_base: + check_results.append(branch.check_merge_base(checks)) + if args.imperative: + check_results.append(commit.check_imperative(checks, stdin_text=stdin_text)) + + return PASS if all(val == PASS for val in check_results) else FAIL if __name__ == "__main__": # pragma: no cover diff --git a/tests/main_test.py b/tests/main_test.py index 20478e6b..cf607b6e 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -7,90 +7,78 @@ class TestMain: - def test_full_run_invokes_each_check_once(self, mocker): + def test_commit_invokes_expected_checks(self, mocker): """Given a config with several check types, ensure each dispatcher target is invoked exactly once.""" mocker.patch( "commit_check.main.validate_config", return_value={ "checks": [ {"check": "message"}, - {"check": "branch"}, {"check": "author_name"}, {"check": "author_email"}, {"check": "commit_signoff"}, - {"check": "merge_base"}, {"check": "imperative"}, ] }, ) m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) - m_branch = mocker.patch("commit_check.branch.check_branch", return_value=PASS) m_author = mocker.patch("commit_check.author.check_author", return_value=PASS) m_signoff = mocker.patch( "commit_check.commit.check_commit_signoff", return_value=PASS ) - m_merge = mocker.patch( - "commit_check.branch.check_merge_base", return_value=PASS - ) m_imperative = mocker.patch( "commit_check.commit.check_imperative", return_value=PASS ) - sys.argv = [CMD, "run"] + sys.argv = [CMD, "commit"] assert main() == PASS assert m_msg.call_count == 1 - assert m_branch.call_count == 1 # author_name + author_email => 2 invocations assert m_author.call_count == 2 assert m_signoff.call_count == 1 - assert m_merge.call_count == 1 assert m_imperative.call_count == 1 def test_help(self, capfd): - sys.argv = [CMD, "run", "--help"] + sys.argv = [CMD, "commit", "--help"] with pytest.raises(SystemExit): main() out, _ = capfd.readouterr() assert "usage:" in out def test_version(self): - sys.argv = [CMD, "run", "-V"] + sys.argv = [CMD, "commit", "-V"] with pytest.raises(SystemExit): main() def test_default_config_used_when_validate_returns_empty(self, mocker): mocker.patch("commit_check.main.validate_config", return_value={}) m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) - mocker.patch("commit_check.branch.check_branch", return_value=PASS) + mocker.patch("commit_check.author.check_author", return_value=PASS) mocker.patch("commit_check.commit.check_commit_signoff", return_value=PASS) - mocker.patch("commit_check.branch.check_merge_base", return_value=PASS) mocker.patch("commit_check.commit.check_imperative", return_value=PASS) - sys.argv = [CMD, "run"] + sys.argv = [CMD, "commit"] main() # first positional arg to check_commit_msg is the list of checks assert m_msg.call_args[0][0] == DEFAULT_CONFIG["checks"] @pytest.mark.parametrize( - "message_result, branch_result, author_name_result, author_email_result, signoff_result, merge_base_result, imperative_result, expected", + "message_result, author_name_result, author_email_result, signoff_result, imperative_result, expected", [ - (PASS, PASS, PASS, PASS, PASS, PASS, PASS, PASS), - (FAIL, PASS, PASS, PASS, PASS, PASS, PASS, FAIL), - (PASS, PASS, FAIL, PASS, PASS, PASS, PASS, FAIL), - (PASS, PASS, PASS, FAIL, PASS, PASS, PASS, FAIL), - (PASS, PASS, PASS, PASS, FAIL, PASS, PASS, FAIL), - (PASS, PASS, PASS, PASS, PASS, FAIL, PASS, FAIL), - (PASS, PASS, PASS, PASS, PASS, PASS, FAIL, FAIL), + (PASS, PASS, PASS, PASS, PASS, PASS), + (FAIL, PASS, PASS, PASS, PASS, FAIL), + (PASS, FAIL, PASS, PASS, PASS, FAIL), + (PASS, PASS, FAIL, PASS, PASS, FAIL), + (PASS, PASS, PASS, FAIL, PASS, FAIL), + (PASS, PASS, PASS, PASS, FAIL, FAIL), ], ) def test_exit_code_aggregation( self, mocker, message_result, - branch_result, author_name_result, author_email_result, signoff_result, - merge_base_result, imperative_result, expected, ): @@ -100,11 +88,9 @@ def test_exit_code_aggregation( return_value={ "checks": [ {"check": "message"}, - {"check": "branch"}, {"check": "author_name"}, - {"check": "author_email"}, + {"check": ""}, {"check": "commit_signoff"}, - {"check": "merge_base"}, {"check": "imperative"}, ] }, @@ -113,7 +99,6 @@ def test_exit_code_aggregation( mocker.patch( "commit_check.commit.check_commit_msg", return_value=message_result ) - mocker.patch("commit_check.branch.check_branch", return_value=branch_result) def author_side_effect(_, which, **_kw): # type: ignore[return] return author_name_result if which == "author_name" else author_email_result @@ -122,13 +107,10 @@ def author_side_effect(_, which, **_kw): # type: ignore[return] mocker.patch( "commit_check.commit.check_commit_signoff", return_value=signoff_result ) - mocker.patch( - "commit_check.branch.check_merge_base", return_value=merge_base_result - ) mocker.patch( "commit_check.commit.check_imperative", return_value=imperative_result ) - sys.argv = [CMD, "run"] + sys.argv = [CMD, "commit"] assert main() == expected def test_unknown_check_type_ignored(self, mocker): @@ -137,5 +119,6 @@ def test_unknown_check_type_ignored(self, mocker): return_value={"checks": [{"check": "totally_unknown"}]}, ) # no dispatcher functions patched intentionally - sys.argv = [CMD, "run"] + + sys.argv = [CMD, "commit"] assert main() == PASS From f484cbb2c72e0ee75e5cada8e392154130d90044 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 14 Sep 2025 19:09:37 +0300 Subject: [PATCH 16/37] fix: update main_test.py --- tests/main_test.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/tests/main_test.py b/tests/main_test.py index cf607b6e..04956b40 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -29,7 +29,8 @@ def test_commit_invokes_expected_checks(self, mocker): m_imperative = mocker.patch( "commit_check.commit.check_imperative", return_value=PASS ) - sys.argv = [CMD, "commit"] + # Use flags for each check instead of deprecated 'commit' subcommand + sys.argv = [CMD, "-m", "-n", "-e", "-s", "-i"] assert main() == PASS assert m_msg.call_count == 1 # author_name + author_email => 2 invocations @@ -38,14 +39,15 @@ def test_commit_invokes_expected_checks(self, mocker): assert m_imperative.call_count == 1 def test_help(self, capfd): - sys.argv = [CMD, "commit", "--help"] + sys.argv = [CMD, "--help"] with pytest.raises(SystemExit): main() out, _ = capfd.readouterr() assert "usage:" in out def test_version(self): - sys.argv = [CMD, "commit", "-V"] + # argparse defines --version + sys.argv = [CMD, "--version"] with pytest.raises(SystemExit): main() @@ -56,7 +58,7 @@ def test_default_config_used_when_validate_returns_empty(self, mocker): mocker.patch("commit_check.author.check_author", return_value=PASS) mocker.patch("commit_check.commit.check_commit_signoff", return_value=PASS) mocker.patch("commit_check.commit.check_imperative", return_value=PASS) - sys.argv = [CMD, "commit"] + sys.argv = [CMD, "-m", "-n", "-e", "-s", "-i"] main() # first positional arg to check_commit_msg is the list of checks assert m_msg.call_args[0][0] == DEFAULT_CONFIG["checks"] @@ -89,7 +91,7 @@ def test_exit_code_aggregation( "checks": [ {"check": "message"}, {"check": "author_name"}, - {"check": ""}, + {"check": "author_email"}, {"check": "commit_signoff"}, {"check": "imperative"}, ] @@ -110,7 +112,7 @@ def author_side_effect(_, which, **_kw): # type: ignore[return] mocker.patch( "commit_check.commit.check_imperative", return_value=imperative_result ) - sys.argv = [CMD, "commit"] + sys.argv = [CMD, "-m", "-n", "-e", "-s", "-i"] assert main() == expected def test_unknown_check_type_ignored(self, mocker): @@ -119,6 +121,6 @@ def test_unknown_check_type_ignored(self, mocker): return_value={"checks": [{"check": "totally_unknown"}]}, ) # no dispatcher functions patched intentionally - - sys.argv = [CMD, "commit"] + # No flags: unknown check type should simply be ignored, resulting in PASS (no executed checks) + sys.argv = [CMD] assert main() == PASS From 834c394c185f772e6f6b619366e50492866b8c83 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 25 Sep 2025 02:36:06 +0300 Subject: [PATCH 17/37] fix: update args and tests --- README.rst | 2 +- commit_check/author.py | 2 +- commit_check/commit.py | 8 +++----- commit_check/main.py | 39 ++++++++++++++++++--------------------- commit_check/rules.py | 2 +- tests/commit_test.py | 38 ++++++++++++++++++-------------------- tests/main_test.py | 14 +++++--------- tests/util_test.py | 2 +- 8 files changed, 48 insertions(+), 59 deletions(-) diff --git a/README.rst b/README.rst index a51df27a..a01f75ab 100644 --- a/README.rst +++ b/README.rst @@ -193,7 +193,7 @@ Check Commit Signature Failed Commit rejected. - Type commit_signoff check failed => c92ce259ff041c91859c7fb61afdbb391e769d0f + Type signoff check failed => c92ce259ff041c91859c7fb61afdbb391e769d0f It doesn't match regex: Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+> Signed-off-by not found in latest commit Suggest: run command `git commit -m "conventional commit message" --signoff` diff --git a/commit_check/author.py b/commit_check/author.py index 4a108897..ec1d356e 100644 --- a/commit_check/author.py +++ b/commit_check/author.py @@ -92,7 +92,7 @@ def check_required_signoff_details( ) -> int: """If configured, ensure signoff includes specific name/email.""" # Reuse existing signoff check result; only apply extra constraints if present - base = _find_check(checks, "commit_signoff") + base = _find_check(checks, "signoff") if not base: return PASS required_name = base.get("required_name") diff --git a/commit_check/commit.py b/commit_check/commit.py index ace349a7..09ba1a0c 100644 --- a/commit_check/commit.py +++ b/commit_check/commit.py @@ -77,21 +77,19 @@ def check_commit_msg( return FAIL -def check_commit_signoff( +def check_signoff( checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None ) -> int: if stdin_text is None and has_commits() is False: return PASS # pragma: no cover - check = _find_check(checks, "commit_signoff") + check = _find_check(checks, "signoff") if not check: return PASS # pragma: no cover regex = check.get("regex", "") if regex == "": - print( - f"{YELLOW}Not found regex for commit signoff. skip checking.{RESET_COLOR}" - ) + print(f"{YELLOW}Not found regex for signoff. skip checking.{RESET_COLOR}") return PASS if stdin_text is not None: diff --git a/commit_check/main.py b/commit_check/main.py index 4221da34..7227f0e5 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -65,7 +65,7 @@ def _get_parser() -> argparse.ArgumentParser: """Get and parser to interpret CLI args.""" parser = argparse.ArgumentParser( prog="commit-check", - description="Check commit message, branch naming, committer name, email, and more.", + description="Check commit message, branch name, author name, email, and more.", ) parser.add_argument( @@ -90,10 +90,18 @@ def _get_parser() -> argparse.ArgumentParser: required=False, ) + parser.add_argument( + "-i", + "--imperative", + help="check commit message starts with imperative verb", + action="store_true", + required=False, + ) + parser.add_argument( "-b", "--branch", - help="check branch naming", + help="check branch name", action="store_true", required=False, ) @@ -101,7 +109,7 @@ def _get_parser() -> argparse.ArgumentParser: parser.add_argument( "-n", "--author-name", - help="check committer's name", + help="check author name", action="store_true", required=False, ) @@ -109,23 +117,22 @@ def _get_parser() -> argparse.ArgumentParser: parser.add_argument( "-e", "--author-email", - help="check committer's email", + help="check author email", action="store_true", required=False, ) parser.add_argument( "-s", - "--commit-signoff", - help="check committer's signature", + "--signoff", + help="check author signoff", action="store_true", required=False, ) parser.add_argument( - "-mb", "--merge-base", - help="check branch is rebased onto target branch", + help="check if branch is ahead of main branch", action="store_true", required=False, ) @@ -133,15 +140,7 @@ def _get_parser() -> argparse.ArgumentParser: parser.add_argument( "-d", "--dry-run", - help="run checks without failing", - action="store_true", - required=False, - ) - - parser.add_argument( - "-i", - "--imperative", - help="check commit message uses imperative mood", + help="perform a dry run without failing (always returns 0)", action="store_true", required=False, ) @@ -189,10 +188,8 @@ def main() -> int: check_results.append( author.check_author(checks, "author_email", stdin_text=stdin_text) ) - if args.commit_signoff: - check_results.append( - commit.check_commit_signoff(checks, stdin_text=stdin_text) - ) + if args.signoff: + check_results.append(commit.check_signoff(checks, stdin_text=stdin_text)) if args.merge_base: check_results.append(branch.check_merge_base(checks)) if args.imperative: diff --git a/commit_check/rules.py b/commit_check/rules.py index 976516eb..ca1fbe7e 100644 --- a/commit_check/rules.py +++ b/commit_check/rules.py @@ -230,7 +230,7 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any sign_name = author_cfg.get("required_signoff_name") sign_email = author_cfg.get("required_signoff_email") rule: Dict[str, Any] = { - "check": "commit_signoff", + "check": "signoff", "regex": r"Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>", "error": "Signed-off-by not found in latest commit", "suggest": "git commit --amend --signoff or use --signoff on commit", diff --git a/tests/commit_test.py b/tests/commit_test.py index 4c681432..bc824bdb 100644 --- a/tests/commit_test.py +++ b/tests/commit_test.py @@ -2,9 +2,9 @@ from commit_check import PASS, FAIL from commit_check.commit import ( check_commit_msg, + check_signoff, get_default_commit_msg_file, read_commit_msg, - check_commit_signoff, check_imperative, ) @@ -111,10 +111,10 @@ def test_check_commit_with_result_none(mocker): @pytest.mark.benchmark -def test_check_commit_signoff(mocker): +def test_check_signoff(mocker): checks = [ { - "check": "commit_signoff", + "check": "signoff", "regex": "dummy_regex", "error": "error", "suggest": "suggest", @@ -127,7 +127,7 @@ def test_check_commit_signoff(mocker): mocker.patch( "commit_check.commit.read_commit_msg", return_value="feat: add new feature" ) - retval = check_commit_signoff(checks) + retval = check_signoff(checks) assert retval == FAIL assert m_re_search.call_count == 1 assert m_print_error_message.call_count == 1 @@ -135,31 +135,29 @@ def test_check_commit_signoff(mocker): @pytest.mark.benchmark -def test_check_commit_signoff_with_empty_regex(mocker): - checks = [ - {"check": "commit_signoff", "regex": "", "error": "error", "suggest": "suggest"} - ] +def test_check_signoff_with_empty_regex(mocker): + checks = [{"check": "signoff", "regex": "", "error": "error", "suggest": "suggest"}] m_re_match = mocker.patch("re.match", return_value="fake_commits_info") - retval = check_commit_signoff(checks) + retval = check_signoff(checks) assert retval == PASS assert m_re_match.call_count == 0 @pytest.mark.benchmark -def test_check_commit_signoff_with_empty_checks(mocker): +def test_check_signoff_with_empty_checks(mocker): checks = [] m_re_match = mocker.patch("re.match", return_value="fake_commits_info") - retval = check_commit_signoff(checks) + retval = check_signoff(checks) assert retval == PASS assert m_re_match.call_count == 0 @pytest.mark.benchmark -def test_check_commit_signoff_skip_merge_commit(mocker): +def test_check_signoff_skip_merge_commit(mocker): """Test commit signoff check skips merge commits.""" checks = [ { - "check": "commit_signoff", + "check": "signoff", "regex": "Signed-off-by:", "error": "Signed-off-by not found", "suggest": "Use --signoff", @@ -171,16 +169,16 @@ def test_check_commit_signoff_skip_merge_commit(mocker): return_value="Merge branch 'feature/test' into main", ) - retval = check_commit_signoff(checks, MSG_FILE) + retval = check_signoff(checks, MSG_FILE) assert retval == PASS @pytest.mark.benchmark -def test_check_commit_signoff_skip_merge_pr_commit(mocker): +def test_check_signoff_skip_merge_pr_commit(mocker): """Test commit signoff check skips GitHub merge PR commits.""" checks = [ { - "check": "commit_signoff", + "check": "signoff", "regex": "Signed-off-by:", "error": "Signed-off-by not found", "suggest": "Use --signoff", @@ -192,16 +190,16 @@ def test_check_commit_signoff_skip_merge_pr_commit(mocker): return_value="Merge pull request #123 from user/feature\n\nAdd new feature", ) - retval = check_commit_signoff(checks, MSG_FILE) + retval = check_signoff(checks, MSG_FILE) assert retval == PASS @pytest.mark.benchmark -def test_check_commit_signoff_still_fails_non_merge_without_signoff(mocker): +def test_check_signoff_still_fails_non_merge_without_signoff(mocker): """Test commit signoff check still fails for non-merge commits without signoff.""" checks = [ { - "check": "commit_signoff", + "check": "signoff", "regex": "Signed-off-by:", "error": "Signed-off-by not found", "suggest": "Use --signoff", @@ -216,7 +214,7 @@ def test_check_commit_signoff_still_fails_non_merge_without_signoff(mocker): m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") - retval = check_commit_signoff(checks, MSG_FILE) + retval = check_signoff(checks, MSG_FILE) assert retval == FAIL assert m_print_error_message.call_count == 1 assert m_print_suggestion.call_count == 1 diff --git a/tests/main_test.py b/tests/main_test.py index 04956b40..c53647b6 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -16,16 +16,14 @@ def test_commit_invokes_expected_checks(self, mocker): {"check": "message"}, {"check": "author_name"}, {"check": "author_email"}, - {"check": "commit_signoff"}, + {"check": "signoff"}, {"check": "imperative"}, ] }, ) m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) m_author = mocker.patch("commit_check.author.check_author", return_value=PASS) - m_signoff = mocker.patch( - "commit_check.commit.check_commit_signoff", return_value=PASS - ) + m_signoff = mocker.patch("commit_check.commit.check_signoff", return_value=PASS) m_imperative = mocker.patch( "commit_check.commit.check_imperative", return_value=PASS ) @@ -56,7 +54,7 @@ def test_default_config_used_when_validate_returns_empty(self, mocker): m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) mocker.patch("commit_check.author.check_author", return_value=PASS) - mocker.patch("commit_check.commit.check_commit_signoff", return_value=PASS) + mocker.patch("commit_check.commit.check_signoff", return_value=PASS) mocker.patch("commit_check.commit.check_imperative", return_value=PASS) sys.argv = [CMD, "-m", "-n", "-e", "-s", "-i"] main() @@ -92,7 +90,7 @@ def test_exit_code_aggregation( {"check": "message"}, {"check": "author_name"}, {"check": "author_email"}, - {"check": "commit_signoff"}, + {"check": "signoff"}, {"check": "imperative"}, ] }, @@ -106,9 +104,7 @@ def author_side_effect(_, which, **_kw): # type: ignore[return] return author_name_result if which == "author_name" else author_email_result mocker.patch("commit_check.author.check_author", side_effect=author_side_effect) - mocker.patch( - "commit_check.commit.check_commit_signoff", return_value=signoff_result - ) + mocker.patch("commit_check.commit.check_signoff", return_value=signoff_result) mocker.patch( "commit_check.commit.check_imperative", return_value=imperative_result ) diff --git a/tests/util_test.py b/tests/util_test.py index ae0ad016..9011911d 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -287,7 +287,7 @@ def test_print_error_header(self, capfd): ("branch", "check failed ==>"), ("author_name", "check failed ==>"), ("author_email", "check failed ==>"), - ("commit_signoff", "check failed ==>"), + ("signoff", "check failed ==>"), ], ) def test_print_error_message(self, capfd, check_type, type_failed_msg): From 050beaef1340e24b4cd3e133ff0a1b2ff1514f5f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 25 Sep 2025 03:38:28 +0300 Subject: [PATCH 18/37] fix: revert noxfile.py to commit-check session --- commit_check/author.py | 2 +- noxfile.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/commit_check/author.py b/commit_check/author.py index ec1d356e..d791daac 100644 --- a/commit_check/author.py +++ b/commit_check/author.py @@ -88,7 +88,7 @@ def check_ignore_authors( def check_required_signoff_details( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None + checks: list, stdin_text: Optional[str] = None ) -> int: """If configured, ensure signoff includes specific name/email.""" # Reuse existing signoff check result; only apply extra constraints if present diff --git a/noxfile.py b/noxfile.py index c5fdae61..55872c06 100644 --- a/noxfile.py +++ b/noxfile.py @@ -39,7 +39,7 @@ def install_wheel(session): @nox.session(name="commit-check") def commit_check(session): session.install(".") - session.run("commit-check", "run") + session.run("commit-check", "--message", "--branch", "--author-email") @nox.session() From da60df4c77b8c7fc3b7dc970012a5268fbd3cce3 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 25 Sep 2025 03:46:08 +0300 Subject: [PATCH 19/37] chore: remove author section --- commit_check/rules.py | 96 +++++++++++++++++++++---------------------- 1 file changed, 47 insertions(+), 49 deletions(-) diff --git a/commit_check/rules.py b/commit_check/rules.py index ca1fbe7e..e58e082e 100644 --- a/commit_check/rules.py +++ b/commit_check/rules.py @@ -14,7 +14,6 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any commit_cfg = conf.get("commit", {}) or {} branch_cfg = conf.get("branch", {}) or {} - author_cfg = conf.get("author", {}) or {} # --- commit section --- if commit_cfg.get("conventional_commits", True): @@ -143,49 +142,6 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any } ) - # --- branch section --- - if branch_cfg.get("conventional_branch", True): - branch_allowed = branch_cfg.get("allow_branch_types") or [ - "feature", - "bugfix", - "hotfix", - "release", - "chore", - "feat", - "fix", - ] - # Preserve order while de-duplicating - seen_b = set() - ordered_branch_allowed: List[str] = [] - for t in branch_allowed: - if t not in seen_b: - seen_b.add(t) - ordered_branch_allowed.append(t) - allowed_re = "|".join(ordered_branch_allowed) - regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" - checks.append( - { - "check": "branch", - "regex": regex, - "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", - "suggest": "git checkout -b /", - "allowed": ordered_branch_allowed, - "allowed_types": ordered_branch_allowed, - } - ) - - target = branch_cfg.get("require_rebase_target") - if isinstance(target, str) and target: - checks.append( - { - "check": "merge_base", - "regex": target, - "error": "Current branch is not rebased onto target branch", - "suggest": "Rebase or merge with the target branch", - } - ) - - # --- author section --- checks.append( { "check": "author_name", @@ -203,7 +159,7 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any } ) - allow_authors = author_cfg.get("allow_authors") + allow_authors = commit_cfg.get("allow_authors") if isinstance(allow_authors, list) and allow_authors: checks.append( { @@ -214,7 +170,7 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any "allowed": allow_authors, } ) - ignore_authors = author_cfg.get("ignore_authors") + ignore_authors = commit_cfg.get("ignore_authors") if isinstance(ignore_authors, list) and ignore_authors: checks.append( { @@ -226,9 +182,9 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any } ) - if author_cfg.get("require_signed_off_by", False): - sign_name = author_cfg.get("required_signoff_name") - sign_email = author_cfg.get("required_signoff_email") + if commit_cfg.get("require_signed_off_by", False): + sign_name = commit_cfg.get("required_signoff_name") + sign_email = commit_cfg.get("required_signoff_email") rule: Dict[str, Any] = { "check": "signoff", "regex": r"Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>", @@ -241,4 +197,46 @@ def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any rule["required_email"] = sign_email checks.append(rule) + # --- branch section --- + if branch_cfg.get("conventional_branch", True): + branch_allowed = branch_cfg.get("allow_branch_types") or [ + "feature", + "bugfix", + "hotfix", + "release", + "chore", + "feat", + "fix", + ] + # Preserve order while de-duplicating + seen_b = set() + ordered_branch_allowed: List[str] = [] + for t in branch_allowed: + if t not in seen_b: + seen_b.add(t) + ordered_branch_allowed.append(t) + allowed_re = "|".join(ordered_branch_allowed) + regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" + checks.append( + { + "check": "branch", + "regex": regex, + "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", + "suggest": "git checkout -b /", + "allowed": ordered_branch_allowed, + "allowed_types": ordered_branch_allowed, + } + ) + + target = branch_cfg.get("require_rebase_target") + if isinstance(target, str) and target: + checks.append( + { + "check": "merge_base", + "regex": target, + "error": "Current branch is not rebased onto target branch", + "suggest": "Rebase or merge with the target branch", + } + ) + return {"checks": checks} From 16c82da4ab0cf62374fbf25f5b7157beb17eecf1 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 25 Sep 2025 03:49:27 +0300 Subject: [PATCH 20/37] refactor: rename rules.py to _rules.py --- commit_check/__init__.py | 2 +- commit_check/{rules.py => _rules.py} | 0 commit_check/util.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename commit_check/{rules.py => _rules.py} (100%) diff --git a/commit_check/__init__.py b/commit_check/__init__.py index dd1abf73..f38579d7 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -8,7 +8,7 @@ """ from importlib.metadata import version -from commit_check.rules import build_checks_from_toml as _build_checks_from_toml +from commit_check._rules import build_checks_from_toml as _build_checks_from_toml # Exit codes used across the package PASS = 0 diff --git a/commit_check/rules.py b/commit_check/_rules.py similarity index 100% rename from commit_check/rules.py rename to commit_check/_rules.py diff --git a/commit_check/util.py b/commit_check/util.py index 6809a453..b92ce64e 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -11,7 +11,7 @@ from typing import Any, Dict, Optional from subprocess import CalledProcessError from commit_check import RED, GREEN, YELLOW, RESET_COLOR -from commit_check.rules import build_checks_from_toml +from commit_check._rules import build_checks_from_toml # Prefer stdlib tomllib (3.11+); fall back to tomli if available; else disabled try: # pragma: no cover - import paths differ by Python version From de94d3fd8d49b50201979c04a901314e4ff85bd3 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Thu, 25 Sep 2025 23:35:51 +0300 Subject: [PATCH 21/37] refactor: save progress --- commit_check/main.py | 234 +++++++++++++++++++++---------------------- 1 file changed, 115 insertions(+), 119 deletions(-) diff --git a/commit_check/main.py b/commit_check/main.py index 7227f0e5..2c00e7fb 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -1,68 +1,33 @@ -"""Minimal argparse CLI. - -Only command: run - -Usage: - commit-check run [PATH] [--config FILE] [-v|-q|-s] [--version] - -Behavior: loads config and executes every defined check exactly once. -Exit codes: 0 all pass, 1 any fail. -""" +"""Modern commit-check CLI with clean architecture and TOML support.""" from __future__ import annotations import sys import argparse from typing import Optional -from commit_check import branch, commit, author -from commit_check.error import error_handler -from commit_check.util import validate_config -from . import CONFIG_FILE, PASS, FAIL, __version__, DEFAULT_CONFIG - - -class LogLevel: - VERBOSE = 3 - QUIET = 1 - SILENT = 0 - NORMAL = 2 - +from commit_check.config import load_config +from commit_check.rule_builder import RuleBuilder +from commit_check.engine import ValidationEngine, ValidationContext, ValidationResult +from . import __version__ -LOG_LEVEL = LogLevel.NORMAL +class StdinReader: + """Handles stdin reading with proper error handling.""" -def set_log_level(verbose: bool, quiet: bool, silent: bool) -> None: - global LOG_LEVEL - # Mutual exclusivity: priority silent > verbose > quiet > normal - if silent: - LOG_LEVEL = LogLevel.SILENT - elif verbose: - LOG_LEVEL = LogLevel.VERBOSE - elif quiet: - LOG_LEVEL = LogLevel.QUIET - else: - LOG_LEVEL = LogLevel.NORMAL - - -def log(msg: str, level: int = LogLevel.NORMAL) -> None: - if LOG_LEVEL == LogLevel.SILENT: - return - if LOG_LEVEL == LogLevel.QUIET and level > LogLevel.QUIET: - return - print(msg) - - -def _read_stdin() -> Optional[str]: # read commit message content if piped - try: - if not sys.stdin.isatty(): - data = sys.stdin.read() - return data or None - except Exception: + @staticmethod + def read_piped_input() -> Optional[str]: + """Read commit message content if piped, with proper error handling.""" + try: + if not sys.stdin.isatty(): + data = sys.stdin.read() + return data.strip() if data else None + except (OSError, IOError): + return None return None - return None def _get_parser() -> argparse.ArgumentParser: - """Get and parser to interpret CLI args.""" + """Get parser to interpret CLI args.""" parser = argparse.ArgumentParser( prog="commit-check", description="Check commit message, branch name, author name, email, and more.", @@ -78,30 +43,21 @@ def _get_parser() -> argparse.ArgumentParser: parser.add_argument( "-c", "--config", - default=CONFIG_FILE, - help="path to config file. default is . (current directory)", + help="path to config file (cchk.toml). If not specified, searches for cchk.toml in current directory", ) parser.add_argument( "-m", "--message", - help="check commit message", - action="store_true", - required=False, - ) - - parser.add_argument( - "-i", - "--imperative", - help="check commit message starts with imperative verb", - action="store_true", - required=False, + nargs="?", + const="", + help="validate commit message. Optionally specify file path, otherwise reads from stdin if available", ) parser.add_argument( "-b", "--branch", - help="check branch name", + help="check current git branch name", action="store_true", required=False, ) @@ -109,7 +65,7 @@ def _get_parser() -> argparse.ArgumentParser: parser.add_argument( "-n", "--author-name", - help="check author name", + help="check git author name", action="store_true", required=False, ) @@ -117,22 +73,7 @@ def _get_parser() -> argparse.ArgumentParser: parser.add_argument( "-e", "--author-email", - help="check author email", - action="store_true", - required=False, - ) - - parser.add_argument( - "-s", - "--signoff", - help="check author signoff", - action="store_true", - required=False, - ) - - parser.add_argument( - "--merge-base", - help="check if branch is ahead of main branch", + help="check git author email", action="store_true", required=False, ) @@ -148,54 +89,109 @@ def _get_parser() -> argparse.ArgumentParser: return parser +def _get_message_content( + message_arg: Optional[str], stdin_reader: StdinReader +) -> Optional[str]: + """Get commit message content from argument, file, or stdin.""" + if message_arg is None: + return None + + # If message_arg is empty string (from nargs="?", const=""), try stdin first, then git + if message_arg == "": + # Try reading from stdin if available + stdin_content = stdin_reader.read_piped_input() + if stdin_content: + return stdin_content + + # Fallback to latest git commit message + try: + from commit_check.util import get_commit_info + + return get_commit_info("B") # Full commit message + except Exception: + print( + "Error: No commit message provided and unable to read from git", + file=sys.stderr, + ) + return None + + # If message_arg is a file path, read from file + try: + with open(message_arg, "r", encoding="utf-8") as f: + return f.read().strip() + except (OSError, IOError) as e: + print(f"Error reading message file '{message_arg}': {e}", file=sys.stderr) + return None + + def main() -> int: """The main entrypoint of commit-check program.""" parser = _get_parser() args = parser.parse_args() if args.dry_run: - return PASS + return 0 + + stdin_reader = StdinReader() - # Capture stdin (if piped) once and pass to checks. - stdin_text = None try: - if not sys.stdin.isatty(): - data = sys.stdin.read() - stdin_text = data or None - except Exception: - stdin_text = None - - check_results: list[int] = [] - - with error_handler(): - config = ( - validate_config(args.config) - if validate_config( - args.config, + # Load configuration + config_data = load_config(args.config) + + # Build validation rules from config + rule_builder = RuleBuilder(config_data) + all_rules = rule_builder.build_all_rules() + + # Filter rules based on CLI arguments + requested_checks = [] + if ( + args.message is not None + ): # Check for None explicitly since empty string is valid + # Add commit message related checks + requested_checks.extend( + ["message", "imperative", "subject_max_length", "subject_min_length"] ) - else DEFAULT_CONFIG - ) - checks = config["checks"] - if args.message: - check_results.append(commit.check_commit_msg(checks, stdin_text=stdin_text)) if args.branch: - check_results.append(branch.check_branch(checks, stdin_text=stdin_text)) + requested_checks.extend(["branch", "merge_base"]) if args.author_name: - check_results.append( - author.check_author(checks, "author_name", stdin_text=stdin_text) - ) + requested_checks.append("author_name") if args.author_email: - check_results.append( - author.check_author(checks, "author_email", stdin_text=stdin_text) - ) - if args.signoff: - check_results.append(commit.check_signoff(checks, stdin_text=stdin_text)) - if args.merge_base: - check_results.append(branch.check_merge_base(checks)) - if args.imperative: - check_results.append(commit.check_imperative(checks, stdin_text=stdin_text)) - - return PASS if all(val == PASS for val in check_results) else FAIL + requested_checks.append("author_email") + + # If no specific checks requested, show help + if not requested_checks: + parser.print_help() + return 0 + + # Filter rules to only include requested checks + filtered_rules = [rule for rule in all_rules if rule.check in requested_checks] + + # Create validation engine with filtered rules + engine = ValidationEngine(filtered_rules) + + # Create validation context + message_content = None + if ( + args.message is not None + ): # Check explicitly for None since empty string is valid + message_content = _get_message_content(args.message, stdin_reader) + if not message_content: + return 1 # Error message already printed in _get_message_content + + context = ValidationContext( + stdin_text=message_content, + commit_file=args.message if args.message and args.message != "-" else None, + ) + + # Run validation + result = engine.validate_all(context) + + # Return appropriate exit code + return 0 if result == ValidationResult.PASS else 1 + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + return 1 if __name__ == "__main__": # pragma: no cover From 74b7ef81164ae712d7c020cc8a78b156d7b8fc0f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 26 Sep 2025 00:09:50 +0300 Subject: [PATCH 22/37] refactor: save progress --- .pre-commit-config.yaml | 10 +- commit_check/config.py | 32 +++ commit_check/engine.py | 376 ++++++++++++++++++++++++++++++++++ commit_check/main.py | 9 +- commit_check/rule_builder.py | 260 +++++++++++++++++++++++ commit_check/rules_catalog.py | 129 ++++++++++++ 6 files changed, 809 insertions(+), 7 deletions(-) create mode 100644 commit_check/config.py create mode 100644 commit_check/engine.py create mode 100644 commit_check/rule_builder.py create mode 100644 commit_check/rules_catalog.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 4fe2ced0..f950a224 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -17,13 +17,11 @@ repos: - id: trailing-whitespace - id: name-tests-test - repo: https://github.com/astral-sh/ruff-pre-commit - # Ruff version. - rev: v0.13.0 + rev: v0.13.2 hooks: - # Run the linter. - - id: ruff-check - # Run the formatter. - - id: ruff-format + - id: ruff-check + args: [ --fix ] + - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy rev: v1.17.1 hooks: diff --git a/commit_check/config.py b/commit_check/config.py new file mode 100644 index 00000000..5cd5c959 --- /dev/null +++ b/commit_check/config.py @@ -0,0 +1,32 @@ +"""TOML config loader and schema for commit-check.""" + +from typing import Any, Dict +from pathlib import Path + +try: + import tomllib + + toml_load = tomllib.load +except ImportError: + import tomli # type: ignore + + toml_load = tomli.load + +DEFAULT_CONFIG_PATHS = [ + Path("cchk.toml"), + Path("commit-check.toml"), +] + + +def load_config(path_hint: str = "") -> Dict[str, Any]: + """Load and validate config from TOML file.""" + if path_hint: + p = Path(path_hint) + if p.exists(): + with open(p, "rb") as f: + return toml_load(f) + for candidate in DEFAULT_CONFIG_PATHS: + if candidate.exists(): + with open(candidate, "rb") as f: + return toml_load(f) + raise FileNotFoundError("No config file found (cchk.toml or commit-check.toml)") diff --git a/commit_check/engine.py b/commit_check/engine.py new file mode 100644 index 00000000..b305b177 --- /dev/null +++ b/commit_check/engine.py @@ -0,0 +1,376 @@ +"""Clean validation engine following SOLID principles.""" + +from typing import List, Optional, Dict, Type +from abc import ABC, abstractmethod +from dataclasses import dataclass +from enum import IntEnum + +from commit_check.rule_builder import ValidationRule +from commit_check.util import ( + get_commit_info, + get_branch_name, + has_commits, + git_merge_base, +) +from commit_check.imperatives import IMPERATIVES + + +class ValidationResult(IntEnum): + """Validation result codes.""" + + PASS = 0 + FAIL = 1 + + +@dataclass(frozen=True) +class ValidationContext: + """Context for validation operations.""" + + stdin_text: Optional[str] = None + commit_file: Optional[str] = None + + +class BaseValidator(ABC): + """Abstract base validator.""" + + def __init__(self, rule: ValidationRule): + self.rule = rule + + @abstractmethod + def validate(self, context: ValidationContext) -> ValidationResult: + """Perform validation and return result.""" + pass + + def _should_skip_validation(self, context: ValidationContext) -> bool: + """Determine if validation should be skipped.""" + 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 + + rule_dict = self.rule.to_dict() + constraint = regex_or_constraint or rule_dict.get("regex", "") + _print_failure(rule_dict, constraint, actual_value) + + +class CommitMessageValidator(BaseValidator): + """Validates commit messages against conventional commit standards.""" + + def validate(self, context: ValidationContext) -> ValidationResult: + if self._should_skip_validation(context): + return ValidationResult.PASS + + message = self._get_commit_message(context) + if not message: + return ValidationResult.PASS + + import re + + if self.rule.regex and re.match(self.rule.regex, message): + return ValidationResult.PASS + + self._print_failure(message) + return ValidationResult.FAIL + + def _get_commit_message(self, context: ValidationContext) -> str: + """Get commit message from context or git.""" + if context.stdin_text: + return context.stdin_text.strip() + + if context.commit_file: + try: + with open(context.commit_file, "r") as f: + return f.read().strip() + except FileNotFoundError: + pass + + # Fallback to git log + subject = get_commit_info("s") + body = get_commit_info("b") + return f"{subject}\n\n{body}".strip() + + +class SubjectValidator(BaseValidator): + """Validates commit subject lines.""" + + def validate(self, context: ValidationContext) -> ValidationResult: + if self._should_skip_validation(context): + return ValidationResult.PASS + + subject = self._get_subject(context) + if not subject: + return ValidationResult.PASS + + return self._validate_subject(subject) + + def _get_subject(self, context: ValidationContext) -> str: + """Extract subject from commit message.""" + if context.stdin_text: + return context.stdin_text.strip().split("\n")[0] + return get_commit_info("s") + + def _validate_subject(self, subject: str) -> ValidationResult: + """Override in subclasses for specific validation logic.""" + return ValidationResult.PASS + + +class SubjectCapitalizationValidator(SubjectValidator): + """Validates that subject starts with capital letter.""" + + def _validate_subject(self, subject: str) -> ValidationResult: + # Skip merge commits + if subject.lower().startswith("merge"): + return ValidationResult.PASS + + # For conventional commits, check the description part after the colon + import re + + match = re.match(r"^(?:\w+(?:\([^)]*\))?[!:]?\s*)(.*)", subject) + if match: + description = match.group(1).strip() + if description and description[0].isupper(): + return ValidationResult.PASS + else: + # For non-conventional commits, check the first character + if subject and subject[0].isupper(): + return ValidationResult.PASS + + self._print_failure(subject) + return ValidationResult.FAIL + + +class SubjectImperativeValidator(SubjectValidator): + """Validates that subject uses imperative mood.""" + + def _validate_subject(self, subject: str) -> ValidationResult: + # Skip merge commits and fixup commits + if subject.lower().startswith(("merge", "fixup!")): + return ValidationResult.PASS + + # Extract first word (ignore conventional commit prefixes) + import re + + match = re.match(r"^(?:\w+(?:\([^)]*\))?[!:]?\s*)?(\w+)", subject) + if not match: + return ValidationResult.PASS + + first_word = match.group(1).lower() + if first_word in IMPERATIVES: + return ValidationResult.PASS + + self._print_failure(subject) + return ValidationResult.FAIL + + +class SubjectLengthValidator(SubjectValidator): + """Validates subject line length constraints.""" + + def _validate_subject(self, subject: str) -> ValidationResult: + # Skip merge commits for length checks + if subject.lower().startswith("merge"): + return ValidationResult.PASS + + length = len(subject) + constraint_value = self.rule.value + + if self.rule.check == "subject_max_length" and length <= constraint_value: + return ValidationResult.PASS + elif self.rule.check == "subject_min_length" and length >= constraint_value: + return ValidationResult.PASS + elif self.rule.check not in ["subject_max_length", "subject_min_length"]: + return ValidationResult.PASS + + self._print_failure(subject, f"length={length}, constraint={constraint_value}") + return ValidationResult.FAIL + + +class AuthorValidator(BaseValidator): + """Validates author information.""" + + def validate(self, context: ValidationContext) -> ValidationResult: + if self._should_skip_validation(context): + return ValidationResult.PASS + + author_value = self._get_author_value(context) + if not author_value: + return ValidationResult.PASS + + return self._validate_author(author_value) + + def _get_author_value(self, context: ValidationContext) -> str: + """Get author value based on rule type.""" + if context.stdin_text: + return context.stdin_text.strip() + + format_map = { + "author_name": "an", + "author_email": "ae", + } + format_str = format_map.get(self.rule.check, "") + return get_commit_info(format_str) if format_str else "" + + def _validate_author(self, author_value: str) -> ValidationResult: + """Validate author against rule constraints.""" + if self.rule.regex: + import re + + if re.match(self.rule.regex, author_value): + return ValidationResult.PASS + self._print_failure(author_value) + return ValidationResult.FAIL + + if self.rule.allowed and author_value not in self.rule.allowed: + self._print_failure(author_value, f"allowed={sorted(self.rule.allowed)}") + return ValidationResult.FAIL + + if self.rule.ignored and author_value in self.rule.ignored: + return ValidationResult.PASS # Ignored authors pass silently + + return ValidationResult.PASS + + +class BranchValidator(BaseValidator): + """Validates branch names.""" + + def validate(self, context: ValidationContext) -> ValidationResult: + branch_name = ( + context.stdin_text.strip() if context.stdin_text else get_branch_name() + ) + + if not self.rule.regex: + return ValidationResult.PASS + + import re + + if re.match(self.rule.regex, branch_name): + return ValidationResult.PASS + + self._print_failure(branch_name) + return ValidationResult.FAIL + + +class MergeBaseValidator(BaseValidator): + """Validates merge base ancestry.""" + + def validate(self, context: ValidationContext) -> ValidationResult: + if not has_commits(): + return ValidationResult.PASS + + current_branch = get_branch_name() + target_pattern = self.rule.regex + + if not target_pattern: + return ValidationResult.PASS + + # Find target branch matching the pattern + target_branch = self._find_target_branch(target_pattern) + if not target_branch: + return ValidationResult.PASS + + result = git_merge_base(target_branch, current_branch) + if result == 0: + return ValidationResult.PASS + + self._print_failure(current_branch, f"target={target_branch}") + return ValidationResult.FAIL + + def _find_target_branch(self, pattern: str) -> Optional[str]: + """Find target branch matching the pattern.""" + import subprocess + import re + + try: + all_branches = subprocess.check_output( + ["git", "branch", "-a"], encoding="utf-8" + ).splitlines() + + for branch in all_branches: + clean_branch = ( + branch.strip().replace("* ", "").replace("remotes/origin/", "") + ) + if re.match(pattern, clean_branch): + return clean_branch + except subprocess.CalledProcessError: + pass + + return None + + +class SignoffValidator(BaseValidator): + """Validates that commit messages contain required signoff trailer.""" + + def validate(self, context: ValidationContext) -> ValidationResult: + if self._should_skip_validation(context): + return ValidationResult.PASS + + message = self._get_commit_message(context) + if not message: + return ValidationResult.PASS + + import re + + if self.rule.regex and re.search(self.rule.regex, message): + return ValidationResult.PASS + + self._print_failure(message) + return ValidationResult.FAIL + + def _get_commit_message(self, context: ValidationContext) -> str: + """Get commit message from context or git.""" + if context.stdin_text: + return context.stdin_text.strip() + + if context.commit_file: + try: + with open(context.commit_file, "r") as f: + return f.read().strip() + except FileNotFoundError: + pass + + # Fallback to git log + subject = get_commit_info("s") + body = get_commit_info("b") + return f"{subject}\n\n{body}".strip() + + +class ValidationEngine: + """Main validation engine that orchestrates all validations.""" + + VALIDATOR_MAP: Dict[str, Type[BaseValidator]] = { + "message": CommitMessageValidator, + "subject_capitalized": SubjectCapitalizationValidator, + "imperative": SubjectImperativeValidator, + "subject_max_length": SubjectLengthValidator, + "subject_min_length": SubjectLengthValidator, + "author_name": AuthorValidator, + "author_email": AuthorValidator, + "allow_authors": AuthorValidator, + "ignore_authors": AuthorValidator, + "branch": BranchValidator, + "merge_base": MergeBaseValidator, + "require_signed_off_by": SignoffValidator, + } + + def __init__(self, rules: List[ValidationRule]): + self.rules = rules + + def validate_all(self, context: ValidationContext) -> ValidationResult: + """Run all validations and return overall result.""" + results = [] + + for rule in self.rules: + validator_class = self.VALIDATOR_MAP.get(rule.check) + if not validator_class: + continue # Skip unknown validators + + validator: BaseValidator = validator_class(rule) + result = validator.validate(context) + results.append(result) + + # Return FAIL if any validation failed + return ( + ValidationResult.FAIL + if ValidationResult.FAIL in results + else ValidationResult.PASS + ) diff --git a/commit_check/main.py b/commit_check/main.py index 2c00e7fb..2d9703d0 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -149,7 +149,14 @@ def main() -> int: ): # Check for None explicitly since empty string is valid # Add commit message related checks requested_checks.extend( - ["message", "imperative", "subject_max_length", "subject_min_length"] + [ + "message", + "imperative", + "subject_max_length", + "subject_min_length", + "require_signed_off_by", + "subject_capitalized", + ] ) if args.branch: requested_checks.extend(["branch", "merge_base"]) diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py new file mode 100644 index 00000000..85dc8a07 --- /dev/null +++ b/commit_check/rule_builder.py @@ -0,0 +1,260 @@ +"""Rule builder that creates validation rules from config and catalog.""" + +from typing import Dict, Any, List, Optional +from dataclasses import dataclass +from commit_check.rules_catalog import COMMIT_RULES, BRANCH_RULES, RuleCatalogEntry + + +@dataclass(frozen=True) +class ValidationRule: + """A complete validation rule with all necessary information.""" + + check: str + regex: Optional[str] = None + error: Optional[str] = None + suggest: Optional[str] = None + value: Any = None + allowed: Optional[List[str]] = None + ignored: Optional[List[str]] = None + + def to_dict(self) -> Dict[str, Any]: + """Convert to dictionary for backward compatibility.""" + result: Dict[str, Any] = { + "check": self.check, + "regex": self.regex or "", + "error": self.error or "", + "suggest": self.suggest or "", + } + if self.value is not None: + result["value"] = self.value + if self.allowed: + result["allowed"] = self.allowed + result["allowed_types"] = self.allowed # Backward compatibility + if self.ignored: + result["ignored"] = self.ignored + return result + + +class RuleBuilder: + """Builds validation rules from config and catalog entries.""" + + DEFAULT_COMMIT_TYPES = ["feat", "fix", "docs", "style", "refactor", "test", "chore"] + DEFAULT_BRANCH_TYPES = [ + "feature", + "bugfix", + "hotfix", + "release", + "chore", + "feat", + "fix", + ] + + def __init__(self, config: Dict[str, Any]): + self.config = config + self.commit_config = config.get("commit", {}) + self.branch_config = config.get("branch", {}) + + def build_all_rules(self) -> List[ValidationRule]: + """Build all validation rules from config.""" + rules = [] + rules.extend(self._build_commit_rules()) + rules.extend(self._build_branch_rules()) + return rules + + def _build_commit_rules(self) -> List[ValidationRule]: + """Build commit-related validation rules.""" + rules = [] + + for catalog_entry in COMMIT_RULES: + rule = self._build_single_rule(catalog_entry, self.commit_config) + if rule: + rules.append(rule) + + return rules + + def _build_branch_rules(self) -> List[ValidationRule]: + """Build branch-related validation rules.""" + rules = [] + + for catalog_entry in BRANCH_RULES: + rule = self._build_single_rule(catalog_entry, self.branch_config) + if rule: + rules.append(rule) + + return rules + + def _build_single_rule( + self, catalog_entry: RuleCatalogEntry, section_config: Dict[str, Any] + ) -> Optional[ValidationRule]: + """Build a single validation rule from catalog entry and config.""" + check = catalog_entry.check + + # Handle special cases that need custom logic + if check == "message": + return self._build_conventional_commit_rule(catalog_entry) + elif check == "branch": + return self._build_conventional_branch_rule(catalog_entry) + elif check == "subject_max_length": + return self._build_length_rule(catalog_entry, "subject_max_length") + elif check == "subject_min_length": + return self._build_length_rule(catalog_entry, "subject_min_length") + elif check == "allow_authors": + return self._build_author_list_rule(catalog_entry, "allow_authors") + elif check == "ignore_authors": + return self._build_author_list_rule(catalog_entry, "ignore_authors") + elif check == "merge_base": + return self._build_merge_base_rule(catalog_entry) + else: + return self._build_boolean_rule(catalog_entry, section_config) + + def _build_conventional_commit_rule( + self, catalog_entry: RuleCatalogEntry + ) -> Optional[ValidationRule]: + """Build conventional commit message rule.""" + if not self.commit_config.get("conventional_commits", True): + return None + + allowed_types = self._get_allowed_commit_types() + regex = self._build_conventional_commit_regex(allowed_types) + + return ValidationRule( + check=catalog_entry.check, + regex=regex, + error=catalog_entry.error, + suggest=catalog_entry.suggest, + allowed=allowed_types, + ) + + def _build_conventional_branch_rule( + self, catalog_entry: RuleCatalogEntry + ) -> Optional[ValidationRule]: + """Build conventional branch naming rule.""" + if not self.branch_config.get("conventional_branch", True): + return None + + allowed_types = self._get_allowed_branch_types() + regex = self._build_conventional_branch_regex(allowed_types) + + return ValidationRule( + check=catalog_entry.check, + regex=regex, + error=catalog_entry.error, + suggest=catalog_entry.suggest, + allowed=allowed_types, + ) + + def _build_length_rule( + self, catalog_entry: RuleCatalogEntry, config_key: str + ) -> Optional[ValidationRule]: + """Build subject length validation rule.""" + length = self.commit_config.get(config_key) + if not isinstance(length, int): + return None + + error = ( + catalog_entry.error.format(max_len=length, min_len=length) + if catalog_entry.error + else None + ) + + return ValidationRule( + check=catalog_entry.check, + error=error, + suggest=catalog_entry.suggest, + value=length, + ) + + def _build_author_list_rule( + self, catalog_entry: RuleCatalogEntry, config_key: str + ) -> Optional[ValidationRule]: + """Build author allow/ignore list rule.""" + author_list = self.commit_config.get(config_key) + if not isinstance(author_list, list) or not author_list: + return None + + if config_key == "allow_authors": + return ValidationRule( + check=catalog_entry.check, + error=catalog_entry.error, + suggest=catalog_entry.suggest, + allowed=author_list, + ) + else: # ignore_authors + return ValidationRule(check=catalog_entry.check, ignored=author_list) + + def _build_merge_base_rule( + self, catalog_entry: RuleCatalogEntry + ) -> Optional[ValidationRule]: + """Build merge base validation rule.""" + target = self.branch_config.get("require_rebase_target") + if not isinstance(target, str) or not target: + return None + + return ValidationRule( + check=catalog_entry.check, + regex=target, + error=catalog_entry.error, + suggest=catalog_entry.suggest, + ) + + def _build_boolean_rule( + self, catalog_entry: RuleCatalogEntry, section_config: Dict[str, Any] + ) -> Optional[ValidationRule]: + """Build boolean-based validation rule.""" + check = catalog_entry.check + + # Handle different default values for different rules + defaults = { + "subject_capitalized": True, + "subject_imperative": True, + "allow_merge_commits": True, + "allow_revert_commits": True, + "allow_empty_commits": False, + "allow_fixup_commits": True, + "allow_wip_commits": False, + "require_body": False, + "require_signed_off_by": False, + } + + default_value = defaults.get(check, True) + config_value = section_config.get(check, default_value) + + # For "allow_*" rules, only create rule if they're disabled (False) + # For "require_*" rules, only create rule if they're enabled (True) + if check.startswith("allow_") and config_value is True: + return None + elif check.startswith("require_") and config_value is False: + return None + elif ( + check in ["subject_capitalized", "subject_imperative"] + and config_value is False + ): + return None + + return ValidationRule( + check=catalog_entry.check, + regex=catalog_entry.regex, + error=catalog_entry.error, + suggest=catalog_entry.suggest, + value=config_value, + ) + + def _get_allowed_commit_types(self) -> List[str]: + """Get deduplicated list of allowed commit types.""" + types = self.commit_config.get("allow_commit_types", self.DEFAULT_COMMIT_TYPES) + return list(dict.fromkeys(types)) # Preserve order, remove duplicates + + def _get_allowed_branch_types(self) -> List[str]: + """Get deduplicated list of allowed branch types.""" + types = self.branch_config.get("allow_branch_types", self.DEFAULT_BRANCH_TYPES) + return list(dict.fromkeys(types)) # Preserve order, remove duplicates + + def _build_conventional_commit_regex(self, allowed_types: List[str]) -> str: + """Build regex for conventional commit messages.""" + types_pattern = "|".join(sorted(set(allowed_types))) + return rf"^({types_pattern}){{1}}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)" + + def _build_conventional_branch_regex(self, allowed_types: List[str]) -> str: + """Build regex for conventional branch names.""" + types_pattern = "|".join(allowed_types) + return rf"^({types_pattern})\/.+|(master)|(main)|(HEAD)|(PR-.+)" diff --git a/commit_check/rules_catalog.py b/commit_check/rules_catalog.py new file mode 100644 index 00000000..fd960d80 --- /dev/null +++ b/commit_check/rules_catalog.py @@ -0,0 +1,129 @@ +"""Centralized catalog of all commit-check rules, regexes, and error messages.""" + +from dataclasses import dataclass +from typing import Optional + + +@dataclass(frozen=True) +class RuleCatalogEntry: + check: str + regex: Optional[str] = None + error: Optional[str] = None + suggest: Optional[str] = None + + +# Commit message rules +COMMIT_RULES = [ + RuleCatalogEntry( + check="message", + regex=None, # Built dynamically from config + error="The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", + suggest="Use (): with allowed types", + ), + RuleCatalogEntry( + check="subject_capitalized", + regex=None, + error="Subject must start with a capital letter", + suggest="Capitalize the first word of the subject", + ), + RuleCatalogEntry( + check="imperative", + regex=None, + error="Commit message should use imperative mood (e.g., 'Add feature' not 'Added feature')", + suggest="Use imperative mood in the subject line", + ), + RuleCatalogEntry( + check="subject_max_length", + regex=None, + error="Subject must be at most {max_len} characters", + suggest="Keep the subject concise (<= configured max)", + ), + RuleCatalogEntry( + check="subject_min_length", + regex=None, + error="Subject must be at least {min_len} characters", + suggest="Provide a meaningful subject (>= configured min)", + ), + RuleCatalogEntry( + check="allow_merge_commits", + regex=None, + error="Merge commits are not allowed", + suggest="Rebase or squash your changes instead of merging", + ), + RuleCatalogEntry( + check="allow_revert_commits", + regex=None, + error="Revert commits are not allowed", + suggest="Avoid using 'revert' commits; rewrite history if necessary", + ), + RuleCatalogEntry( + check="allow_empty_commits", + regex=None, + error="Empty commit messages are not allowed", + suggest="Provide a non-empty subject", + ), + RuleCatalogEntry( + check="allow_fixup_commits", + regex=None, + error="Fixup commits are not allowed", + suggest="Use interactive rebase to clean up fixup commits", + ), + RuleCatalogEntry( + check="allow_wip_commits", + regex=None, + error="WIP commits are not allowed", + suggest="Complete the work before committing or remove 'WIP'", + ), + RuleCatalogEntry( + check="require_body", + regex=None, + error="Commit body is required", + suggest="Add a body explaining the change", + ), + RuleCatalogEntry( + check="author_name", + regex=r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", + error="The committer name seems invalid", + suggest="git config user.name 'Your Name'", + ), + RuleCatalogEntry( + check="author_email", + regex=r"^.+@.+$", + error="The committer's email seems invalid", + suggest="git config user.email yourname@example.com", + ), + RuleCatalogEntry( + check="allow_authors", + regex=None, + error="Author is not allowed", + suggest="Use a configured author or adjust configuration", + ), + RuleCatalogEntry( + check="ignore_authors", + regex=None, + error=None, + suggest=None, + ), + RuleCatalogEntry( + check="require_signed_off_by", + regex=r"Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>", + error="Signed-off-by not found in latest commit", + suggest="git commit --amend --signoff or use --signoff on commit", + ), +] + +# Branch rules +BRANCH_RULES = [ + RuleCatalogEntry( + check="branch", + regex=None, # Built dynamically from config + error="Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", + suggest="git checkout -b /", + ), + RuleCatalogEntry( + check="merge_base", + regex=None, # Provided by config + error="Current branch is not rebased onto target branch", + suggest="Rebase or merge with the target branch", + ), +] From d19a915620e0b1bda735cd49ae413603334574ee Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 26 Sep 2025 00:16:48 +0300 Subject: [PATCH 23/37] refactor: save progress --- commit_check/engine.py | 49 ++++++++++++++++++++++++++++++++++++++++++ commit_check/main.py | 1 + 2 files changed, 50 insertions(+) diff --git a/commit_check/engine.py b/commit_check/engine.py index b305b177..4f62e6e9 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -334,6 +334,54 @@ def _get_commit_message(self, context: ValidationContext) -> str: return f"{subject}\n\n{body}".strip() +class BodyValidator(BaseValidator): + """Validates that commit messages contain a body when required.""" + + def validate(self, context: ValidationContext) -> ValidationResult: + if self._should_skip_validation(context): + return ValidationResult.PASS + + message = self._get_commit_message(context) + if not message: + return ValidationResult.PASS + + # Split message into lines and check if there's content after the subject + lines = message.strip().split("\n") + + # Filter out empty lines + non_empty_lines = [line.strip() for line in lines if line.strip()] + + # If there's more than just the subject line, we have a body + if len(non_empty_lines) > 1: + return ValidationResult.PASS + + # Check if there's content after the first line (even if separated by empty lines) + if len(lines) > 1: + body_content = "\n".join(lines[1:]).strip() + if body_content: + return ValidationResult.PASS + + self._print_failure(message) + return ValidationResult.FAIL + + def _get_commit_message(self, context: ValidationContext) -> str: + """Get commit message from context or git.""" + if context.stdin_text: + return context.stdin_text.strip() + + if context.commit_file: + try: + with open(context.commit_file, "r") as f: + return f.read().strip() + except FileNotFoundError: + pass + + # Fallback to git log + subject = get_commit_info("s") + body = get_commit_info("b") + return f"{subject}\n\n{body}".strip() + + class ValidationEngine: """Main validation engine that orchestrates all validations.""" @@ -350,6 +398,7 @@ class ValidationEngine: "branch": BranchValidator, "merge_base": MergeBaseValidator, "require_signed_off_by": SignoffValidator, + "require_body": BodyValidator, } def __init__(self, rules: List[ValidationRule]): diff --git a/commit_check/main.py b/commit_check/main.py index 2d9703d0..2f793394 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -156,6 +156,7 @@ def main() -> int: "subject_min_length", "require_signed_off_by", "subject_capitalized", + "require_body", ] ) if args.branch: From 8f5639680e73c072d2efeef465c12dba7bf9d7e3 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 26 Sep 2025 01:00:12 +0300 Subject: [PATCH 24/37] refactor: save progress --- commit_check/engine.py | 86 ++++++++++++++++++++++++++++++++++++++++++ commit_check/main.py | 5 +++ 2 files changed, 91 insertions(+) diff --git a/commit_check/engine.py b/commit_check/engine.py index 4f62e6e9..08c9f0ab 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -382,6 +382,87 @@ def _get_commit_message(self, context: ValidationContext) -> str: return f"{subject}\n\n{body}".strip() +class CommitTypeValidator(BaseValidator): + """Base validator for special commit types (merge, revert, fixup, WIP, empty).""" + + def validate(self, context: ValidationContext) -> ValidationResult: + if self._should_skip_validation(context): + return ValidationResult.PASS + + message = self._get_commit_message(context) + if not message: + return ValidationResult.PASS + + # Check if this commit type is allowed based on rule configuration + is_allowed = self._is_commit_type_allowed(message) + + if not is_allowed: + self._print_failure(message) + return ValidationResult.FAIL + + return ValidationResult.PASS + + def _is_commit_type_allowed(self, message: str) -> bool: + """Check if the commit type is allowed based on the rule check.""" + check = self.rule.check + + if check == "allow_merge_commits": + return self._is_merge_commit_allowed(message) + elif check == "allow_revert_commits": + return self._is_revert_commit_allowed(message) + elif check == "allow_empty_commits": + return self._is_empty_commit_allowed(message) + elif check == "allow_fixup_commits": + return self._is_fixup_commit_allowed(message) + elif check == "allow_wip_commits": + return self._is_wip_commit_allowed(message) + + return True + + def _is_merge_commit_allowed(self, message: str) -> bool: + """Check if merge commits are allowed.""" + is_merge = message.startswith("Merge ") + # If rule value is True, allow merge commits. If False, reject them. + return not is_merge or self.rule.value + + def _is_revert_commit_allowed(self, message: str) -> bool: + """Check if revert commits are allowed.""" + is_revert = message.lower().startswith("revert ") + return not is_revert or self.rule.value + + def _is_empty_commit_allowed(self, message: str) -> bool: + """Check if empty commits are allowed.""" + is_empty = not message.strip() + return not is_empty or self.rule.value + + def _is_fixup_commit_allowed(self, message: str) -> bool: + """Check if fixup commits are allowed.""" + is_fixup = message.startswith("fixup!") + return not is_fixup or self.rule.value + + def _is_wip_commit_allowed(self, message: str) -> bool: + """Check if WIP commits are allowed.""" + is_wip = message.upper().startswith("WIP:") + return not is_wip or self.rule.value + + def _get_commit_message(self, context: ValidationContext) -> str: + """Get commit message from context or git.""" + if context.stdin_text: + return context.stdin_text.strip() + + if context.commit_file: + try: + with open(context.commit_file, "r") as f: + return f.read().strip() + except FileNotFoundError: + pass + + # Fallback to git log + subject = get_commit_info("s") + body = get_commit_info("b") + return f"{subject}\n\n{body}".strip() + + class ValidationEngine: """Main validation engine that orchestrates all validations.""" @@ -399,6 +480,11 @@ class ValidationEngine: "merge_base": MergeBaseValidator, "require_signed_off_by": SignoffValidator, "require_body": BodyValidator, + "allow_merge_commits": CommitTypeValidator, + "allow_revert_commits": CommitTypeValidator, + "allow_empty_commits": CommitTypeValidator, + "allow_fixup_commits": CommitTypeValidator, + "allow_wip_commits": CommitTypeValidator, } def __init__(self, rules: List[ValidationRule]): diff --git a/commit_check/main.py b/commit_check/main.py index 2f793394..91d42795 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -157,6 +157,11 @@ def main() -> int: "require_signed_off_by", "subject_capitalized", "require_body", + "allow_merge_commits", + "allow_revert_commits", + "allow_empty_commits", + "allow_fixup_commits", + "allow_wip_commits", ] ) if args.branch: From 6ca70f3a319e4a348d0a5f29e8d79c1b436134a2 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 26 Sep 2025 01:03:14 +0300 Subject: [PATCH 25/37] refactor: save progress --- commit_check/rules_catalog.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/commit_check/rules_catalog.py b/commit_check/rules_catalog.py index fd960d80..56b99b41 100644 --- a/commit_check/rules_catalog.py +++ b/commit_check/rules_catalog.py @@ -117,7 +117,7 @@ class RuleCatalogEntry: RuleCatalogEntry( check="branch", regex=None, # Built dynamically from config - error="Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", + error="The branch should follow Conventional Branch. See https://conventional-branches.github.io/", suggest="git checkout -b /", ), RuleCatalogEntry( From 76159a7d023a899d7f275f0de387789c3558f28f Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 26 Sep 2025 01:09:50 +0300 Subject: [PATCH 26/37] refactor: save progress --- commit_check/__init__.py | 6 +- commit_check/_rules.py | 242 --------------------------------------- commit_check/util.py | 7 +- 3 files changed, 9 insertions(+), 246 deletions(-) delete mode 100644 commit_check/_rules.py diff --git a/commit_check/__init__.py b/commit_check/__init__.py index f38579d7..2abb3a1c 100644 --- a/commit_check/__init__.py +++ b/commit_check/__init__.py @@ -8,7 +8,7 @@ """ from importlib.metadata import version -from commit_check._rules import build_checks_from_toml as _build_checks_from_toml +from commit_check.rule_builder import RuleBuilder # Exit codes used across the package PASS = 0 @@ -21,7 +21,9 @@ RESET_COLOR = "\033[0m" # Default (empty) configuration translated into internal checks structure -DEFAULT_CONFIG = _build_checks_from_toml({}) +_rule_builder = RuleBuilder({}) +_default_rules = _rule_builder.build_all_rules() +DEFAULT_CONFIG = {"checks": [rule.to_dict() for rule in _default_rules]} CONFIG_FILE = "." # Search current directory for commit-check.toml or cchk.toml __version__ = version("commit-check") diff --git a/commit_check/_rules.py b/commit_check/_rules.py deleted file mode 100644 index e58e082e..00000000 --- a/commit_check/_rules.py +++ /dev/null @@ -1,242 +0,0 @@ -"""Centralized built-in rules and TOML translation for commit-check.""" - -from __future__ import annotations -from typing import Any, Dict, List - - -def build_checks_from_toml(conf: Dict[str, Any]) -> Dict[str, List[Dict[str, Any]]]: - """Translate high-level TOML options into internal checks list. - - Each documented option in docs/configuration.rst yields a corresponding - rule here. Regex remains internal; users do not provide regex. - """ - checks: List[Dict[str, Any]] = [] - - commit_cfg = conf.get("commit", {}) or {} - branch_cfg = conf.get("branch", {}) or {} - - # --- commit section --- - if commit_cfg.get("conventional_commits", True): - allowed_types = commit_cfg.get("allow_commit_types") or [ - "feat", - "fix", - "docs", - "style", - "refactor", - "test", - "chore", - ] - allowed_re = "|".join(sorted(set(allowed_types))) - conv_regex = rf"^({allowed_re}){{1}}(\([\w\-\.]+\))?(!)?: ([\w ])+([\s\S]*)|(Merge).*|(fixup!.*)" - checks.append( - { - "check": "message", - "regex": conv_regex, - "error": "The commit message should follow Conventional Commits. See https://www.conventionalcommits.org", - "suggest": "Use (): with allowed types", - "allowed_types": allowed_types, - } - ) - - if commit_cfg.get("subject_capitalized", True): - checks.append( - { - "check": "subject_capitalized", - "regex": "", - "error": "Subject must start with a capital letter", - "suggest": "Capitalize the first word of the subject", - } - ) - - if commit_cfg.get("subject_imperative", True): - checks.append( - { - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood (e.g., 'Add feature' not 'Added feature')", - "suggest": "Use imperative mood in the subject line", - } - ) - - max_len = commit_cfg.get("subject_max_length") - if isinstance(max_len, int): - checks.append( - { - "check": "subject_max_length", - "regex": "", - "error": f"Subject must be at most {max_len} characters", - "suggest": "Keep the subject concise (<= configured max)", - "value": max_len, - } - ) - min_len = commit_cfg.get("subject_min_length") - if isinstance(min_len, int): - checks.append( - { - "check": "subject_min_length", - "regex": "", - "error": f"Subject must be at least {min_len} characters", - "suggest": "Provide a meaningful subject (>= configured min)", - "value": min_len, - } - ) - - if commit_cfg.get("allow_merge_commits", True) is False: - checks.append( - { - "check": "allow_merge_commits", - "regex": "", - "error": "Merge commits are not allowed", - "suggest": "Rebase or squash your changes instead of merging", - "value": False, - } - ) - if commit_cfg.get("allow_revert_commits", True) is False: - checks.append( - { - "check": "allow_revert_commits", - "regex": "", - "error": "Revert commits are not allowed", - "suggest": "Avoid using 'revert' commits; rewrite history if necessary", - "value": False, - } - ) - if commit_cfg.get("allow_empty_commits", False) is False: - checks.append( - { - "check": "allow_empty_commits", - "regex": "", - "error": "Empty commit messages are not allowed", - "suggest": "Provide a non-empty subject", - "value": False, - } - ) - if commit_cfg.get("allow_fixup_commits", True) is False: - checks.append( - { - "check": "allow_fixup_commits", - "regex": "", - "error": "Fixup commits are not allowed", - "suggest": "Use interactive rebase to clean up fixup commits", - "value": False, - } - ) - if commit_cfg.get("allow_wip_commits", False) is False: - checks.append( - { - "check": "allow_wip_commits", - "regex": "", - "error": "WIP commits are not allowed", - "suggest": "Complete the work before committing or remove 'WIP'", - "value": False, - } - ) - if commit_cfg.get("require_body", False): - checks.append( - { - "check": "require_body", - "regex": "", - "error": "Commit body is required", - "suggest": "Add a body explaining the change", - "value": True, - } - ) - - checks.append( - { - "check": "author_name", - "regex": r"^[A-Za-zÀ-ÖØ-öø-ÿ\u0100-\u017F\u0180-\u024F ,.'\-]+$|.*(\[bot])", - "error": "The committer name seems invalid", - "suggest": "git config user.name 'Your Name'", - } - ) - checks.append( - { - "check": "author_email", - "regex": r"^.+@.+$", - "error": "The committer's email seems invalid", - "suggest": "git config user.email yourname@example.com", - } - ) - - allow_authors = commit_cfg.get("allow_authors") - if isinstance(allow_authors, list) and allow_authors: - checks.append( - { - "check": "allow_authors", - "regex": "", - "error": "Author is not allowed", - "suggest": "Use a configured author or adjust configuration", - "allowed": allow_authors, - } - ) - ignore_authors = commit_cfg.get("ignore_authors") - if isinstance(ignore_authors, list) and ignore_authors: - checks.append( - { - "check": "ignore_authors", - "regex": "", - "error": "", - "suggest": "", - "ignored": ignore_authors, - } - ) - - if commit_cfg.get("require_signed_off_by", False): - sign_name = commit_cfg.get("required_signoff_name") - sign_email = commit_cfg.get("required_signoff_email") - rule: Dict[str, Any] = { - "check": "signoff", - "regex": r"Signed-off-by:.*[A-Za-z0-9]\s+<.+@.+>", - "error": "Signed-off-by not found in latest commit", - "suggest": "git commit --amend --signoff or use --signoff on commit", - } - if sign_name: - rule["required_name"] = sign_name - if sign_email: - rule["required_email"] = sign_email - checks.append(rule) - - # --- branch section --- - if branch_cfg.get("conventional_branch", True): - branch_allowed = branch_cfg.get("allow_branch_types") or [ - "feature", - "bugfix", - "hotfix", - "release", - "chore", - "feat", - "fix", - ] - # Preserve order while de-duplicating - seen_b = set() - ordered_branch_allowed: List[str] = [] - for t in branch_allowed: - if t not in seen_b: - seen_b.add(t) - ordered_branch_allowed.append(t) - allowed_re = "|".join(ordered_branch_allowed) - regex = rf"^({allowed_re})\/.+|(master)|(main)|(HEAD)|(PR-.+)" - checks.append( - { - "check": "branch", - "regex": regex, - "error": "Branches must begin with allowed types (e.g., feature/, bugfix/) or be main/master/PR-*.", - "suggest": "git checkout -b /", - "allowed": ordered_branch_allowed, - "allowed_types": ordered_branch_allowed, - } - ) - - target = branch_cfg.get("require_rebase_target") - if isinstance(target, str) and target: - checks.append( - { - "check": "merge_base", - "regex": target, - "error": "Current branch is not rebased onto target branch", - "suggest": "Rebase or merge with the target branch", - } - ) - - return {"checks": checks} diff --git a/commit_check/util.py b/commit_check/util.py index b92ce64e..ac813c03 100644 --- a/commit_check/util.py +++ b/commit_check/util.py @@ -11,7 +11,7 @@ from typing import Any, Dict, Optional from subprocess import CalledProcessError from commit_check import RED, GREEN, YELLOW, RESET_COLOR -from commit_check._rules import build_checks_from_toml +from commit_check.rule_builder import RuleBuilder # Prefer stdlib tomllib (3.11+); fall back to tomli if available; else disabled try: # pragma: no cover - import paths differ by Python version @@ -184,7 +184,10 @@ def validate_config(path_hint: str) -> dict: raw = _load_toml(cfg_path) if not raw: return {} - return build_checks_from_toml(raw) + # Use new rule builder system + rule_builder = RuleBuilder(raw) + rules = rule_builder.build_all_rules() + return {"checks": [rule.to_dict() for rule in rules]} # Legacy YAML fallback (maintained for test compatibility) try: From 5616cb3377812e413166d0e04e0d92cdca407885 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 26 Sep 2025 01:13:56 +0300 Subject: [PATCH 27/37] refactor: remove legacy files --- commit_check/author.py | 117 ------------ commit_check/branch.py | 79 -------- commit_check/commit.py | 407 ----------------------------------------- commit_check/error.py | 81 -------- 4 files changed, 684 deletions(-) delete mode 100644 commit_check/author.py delete mode 100644 commit_check/branch.py delete mode 100644 commit_check/commit.py delete mode 100644 commit_check/error.py diff --git a/commit_check/author.py b/commit_check/author.py deleted file mode 100644 index d791daac..00000000 --- a/commit_check/author.py +++ /dev/null @@ -1,117 +0,0 @@ -"""Check git author name and email""" - -import re -from typing import Optional -from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import ( - get_commit_info, - has_commits, - _find_check, - _print_failure, -) - - -_AUTHOR_FORMAT_MAP = { - "author_name": "an", - "author_email": "ae", -} - - -def _get_author_value(check_type: str) -> str: - """Fetch the author value from git for the given check type.""" - format_str = _AUTHOR_FORMAT_MAP.get(check_type, "") - return str(get_commit_info(format_str)) - - -def check_author( - checks: list, check_type: str, stdin_text: Optional[str] = None -) -> int: - # If an explicit value is provided (stdin), validate it even if there are no commits - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - - check = _find_check(checks, check_type) - if not check: - return PASS - - # If regex is empty, skip without fetching author info - regex = check.get("regex", "") - if regex == "": - print(f"{YELLOW}Not found regex for {check_type}. skip checking.{RESET_COLOR}") - return PASS - - if stdin_text is not None: - value = stdin_text - else: - value = _get_author_value(check_type) - - if re.match(regex, value): - return PASS - - _print_failure(check, regex, value) - - return FAIL - - -# --- Additional per-option checks --- - - -def check_allow_authors( - checks: list, check_type: str, stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "allow_authors") - if not check: - return PASS - allowed = set(check.get("allowed") or []) - value = stdin_text if stdin_text is not None else _get_author_value(check_type) - if value in allowed: - return PASS - _print_failure(check, f"allowed={sorted(allowed)}", value) - return FAIL - - -def check_ignore_authors( - checks: list, check_type: str, stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - rule = _find_check(checks, "ignore_authors") - if not rule: - return PASS - ignored = set(rule.get("ignored") or []) - value = stdin_text if stdin_text is not None else _get_author_value(check_type) - if value in ignored: - return PASS - return PASS # ignore list only whitelists, no failure - - -def check_required_signoff_details( - checks: list, stdin_text: Optional[str] = None -) -> int: - """If configured, ensure signoff includes specific name/email.""" - # Reuse existing signoff check result; only apply extra constraints if present - base = _find_check(checks, "signoff") - if not base: - return PASS - required_name = base.get("required_name") - required_email = base.get("required_email") - if not (required_name or required_email): - return PASS - # Read commit message (stdin_text here is the full commit message) - msg = stdin_text if stdin_text is not None else get_commit_info("b") - trailer = "Signed-off-by:" in msg - if not trailer: - return PASS # let the main signoff check handle failure - ok = True - if required_name and required_name not in msg: - ok = False - if required_email and required_email not in msg: - ok = False - if ok: - return PASS - _print_failure( - base, "required signoff details", required_name or required_email or "" - ) - return FAIL diff --git a/commit_check/branch.py b/commit_check/branch.py deleted file mode 100644 index 46729f1b..00000000 --- a/commit_check/branch.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Check git branch naming convention.""" - -import re -from typing import Optional -from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import ( - _find_check, - _print_failure, - get_branch_name, - git_merge_base, - has_commits, -) - - -def check_branch(checks: list, stdin_text: Optional[str] = None) -> int: - check = _find_check(checks, "branch") - if not check: - return PASS - - regex = check.get("regex", "") - if regex == "": - print( - f"{YELLOW}Not found regex for branch naming. skip checking.{RESET_COLOR}", - ) - return PASS - - branch_name = stdin_text.strip() if stdin_text is not None else get_branch_name() - if re.match(regex, branch_name): - return PASS - - _print_failure(check, regex, branch_name) - return FAIL - - -def check_merge_base(checks: list) -> int: - """Check if the current branch is based on the latest target branch. - params checks: List of check configurations containing merge_base rules - - :returns PASS(0) if merge base check succeeds, FAIL(1) otherwise - """ - if has_commits() is False: - return PASS # pragma: no cover - - # locate merge_base rule, if any - check = _find_check(checks, "merge_base") - if not check: - return PASS - - regex = check.get("regex", "") - if regex == "": - print( - f"{YELLOW}Not found target branch for checking merge base. skip checking.{RESET_COLOR}", - ) - return PASS - - target_branch = regex if "origin/" in regex else f"origin/{regex}" - current_branch = get_branch_name() - result = git_merge_base(target_branch, current_branch) - if result == 0: - return PASS - # Treat missing target (128) as skip only when detached HEAD (cannot verify ancestry reliably) - if result == 128 and current_branch == "HEAD": - return PASS - - _print_failure(check, regex, current_branch) - return FAIL - - -# --- Additional per-option checks (aliases to existing ones) --- - - -def check_conventional_branch(checks: list, stdin_text: Optional[str] = None) -> int: - """Alias to check_branch for explicit rule mapping.""" - return check_branch(checks, stdin_text=stdin_text) - - -def check_require_rebase_target(checks: list) -> int: - """Alias to check_merge_base for explicit rule mapping.""" - return check_merge_base(checks) diff --git a/commit_check/commit.py b/commit_check/commit.py deleted file mode 100644 index 09ba1a0c..00000000 --- a/commit_check/commit.py +++ /dev/null @@ -1,407 +0,0 @@ -"""Check git commit message formatting""" - -from typing import Optional -import re -from pathlib import PurePath -from commit_check import YELLOW, RESET_COLOR, PASS, FAIL -from commit_check.util import ( - _find_check, - _print_failure, - cmd_output, - get_commit_info, - has_commits, -) -from commit_check.imperatives import IMPERATIVES - - -def _load_imperatives() -> set: - """Load imperative verbs from imperatives module.""" - return IMPERATIVES - - -def _ensure_msg_file(commit_msg_file: str | None) -> str: - """Return a commit message file path, falling back to the default when empty.""" - if not commit_msg_file: - return get_default_commit_msg_file() - return commit_msg_file - - -def get_default_commit_msg_file() -> str: - """Get the default commit message file.""" - git_dir = cmd_output(["git", "rev-parse", "--git-dir"]).strip() - return str(PurePath(git_dir, "COMMIT_EDITMSG")) - - -def read_commit_msg(commit_msg_file) -> str: - """Read the commit message from the specified file.""" - try: - with open(commit_msg_file, "r") as f: - return f.read() - except FileNotFoundError: - # Commit message is composed by subject and body - return str(get_commit_info("s") + "\n\n" + get_commit_info("b")) - - -def check_commit_msg( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - """Check commit message against the provided checks. - - If stdin_text is provided, use it directly (stdin override) and do not - require a git repository state. Otherwise, fall back to reading from file/Git. - """ - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - - check = _find_check(checks, "message") - if not check: - return PASS # pragma: no cover - - regex = check.get("regex", "") - if regex == "": - print( - f"{YELLOW}Not found regex for commit message. skip checking.{RESET_COLOR}" - ) - return PASS - - if stdin_text is not None: - commit_msg = stdin_text - else: - path = _ensure_msg_file(commit_msg_file) - commit_msg = read_commit_msg(path) - - if re.match(regex, commit_msg): - return PASS - - _print_failure(check, regex, commit_msg) - return FAIL - - -def check_signoff( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - - check = _find_check(checks, "signoff") - if not check: - return PASS # pragma: no cover - - regex = check.get("regex", "") - if regex == "": - print(f"{YELLOW}Not found regex for signoff. skip checking.{RESET_COLOR}") - return PASS - - if stdin_text is not None: - commit_msg = stdin_text - else: - path = _ensure_msg_file(commit_msg_file) - commit_msg = read_commit_msg(path) - - # Extract the subject line (first line of commit message) - subject = commit_msg.split("\n")[0].strip() - - # Skip if merge commit - if subject.startswith("Merge"): - return PASS - - commit_hash = get_commit_info("H") - if re.search(regex, commit_msg): - return PASS - - _print_failure(check, regex, commit_hash) - return FAIL - - -def check_imperative( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - """Check if commit message uses imperative mood.""" - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - - check = _find_check(checks, "imperative") - if not check: - return PASS - - if stdin_text is not None: - commit_msg = stdin_text - else: - path = _ensure_msg_file(commit_msg_file) - commit_msg = read_commit_msg(path) - - # Extract the subject line (first line of commit message) - subject = commit_msg.split("\n")[0].strip() - - # Skip if empty or merge commit - if not subject or subject.startswith("Merge"): - return PASS - - # For conventional commits, extract description after the colon - description = subject.split(":", 1)[1].strip() if ":" in subject else subject - - # Check if the description uses imperative mood - if _is_imperative(description): - return PASS - - _print_failure(check, "imperative mood pattern", subject) - return FAIL - - -# --- Additional per-option checks (not yet wired into CLI) --- - - -def _get_subject_and_body( - stdin_text: Optional[str], commit_msg_file: str -) -> tuple[str, str]: - if stdin_text is not None: - commit_msg = stdin_text - else: - path = _ensure_msg_file(commit_msg_file) - commit_msg = read_commit_msg(path) - subject = commit_msg.split("\n")[0].strip() - body = "\n".join(commit_msg.split("\n")[1:]).strip() - return subject, body - - -def check_subject_capitalized( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "subject_capitalized") - if not check: - return PASS - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if not subject or subject[0].isupper(): - return PASS - _print_failure(check, "capitalized first letter", subject) - return FAIL - - -def check_subject_max_length( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "subject_max_length") - if not check: - return PASS - max_len = int(check.get("value", 0) or 0) - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - # Skip if merge commit - if subject.startswith("Merge"): - return PASS - if not max_len or len(subject) <= max_len: - return PASS - _print_failure(check, f"max_length={max_len}", subject) - return FAIL - - -def check_subject_min_length( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "subject_min_length") - if not check: - return PASS - min_len = int(check.get("value", 0) or 0) - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if len(subject) >= min_len: - return PASS - _print_failure(check, f"min_length={min_len}", subject) - return FAIL - - -def check_allow_commit_types( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "allow_commit_types") - if not check: - return PASS - allowed = set(check.get("allowed") or []) - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - ctype = ( - subject.split(":", 1)[0].split("(")[0].strip() - if ":" in subject - else subject.split("(")[0].strip() - ) - if ctype in allowed: - return PASS - _print_failure(check, f"allowed={sorted(allowed)}", subject) - return FAIL - - -def check_allow_merge_commits( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "allow_merge_commits") - if not check: - return PASS - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject.startswith("Merge"): - _print_failure(check, "no merge commits", subject) - return FAIL - return PASS - - -def check_allow_revert_commits( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "allow_revert_commits") - if not check: - return PASS - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject.lower().startswith("revert"): - _print_failure(check, "no revert commits", subject) - return FAIL - return PASS - - -def check_allow_empty_commits( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "allow_empty_commits") - if not check: - return PASS - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject: - return PASS - _print_failure(check, "non-empty subject required", subject) - return FAIL - - -def check_allow_fixup_commits( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "allow_fixup_commits") - if not check: - return PASS - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject.startswith("fixup!"): - _print_failure(check, "no fixup commits", subject) - return FAIL - return PASS - - -def check_allow_wip_commits( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "allow_wip_commits") - if not check: - return PASS - subject, _ = _get_subject_and_body(stdin_text, commit_msg_file) - if subject.startswith("WIP") or subject.upper().startswith("WIP:"): - _print_failure(check, "no WIP commits", subject) - return FAIL - return PASS - - -def check_require_body( - checks: list, commit_msg_file: str = "", stdin_text: Optional[str] = None -) -> int: - if stdin_text is None and has_commits() is False: - return PASS # pragma: no cover - check = _find_check(checks, "require_body") - if not check: - return PASS - _, body = _get_subject_and_body(stdin_text, commit_msg_file) - if body: - return PASS - _print_failure(check, "body required", "") - return FAIL - - -def _is_imperative(description: str) -> bool: - """Check if a description uses imperative mood.""" - if not description: - return True - - # Get the first word of the description - first_word = description.split()[0].lower() - - # Load imperative verbs from file - imperatives = _load_imperatives() - - # Check for common past tense pattern (-ed ending) but be more specific - if ( - first_word.endswith("ed") - and len(first_word) > 3 - and first_word not in {"red", "bed", "fed", "led", "wed", "shed", "fled"} - ): - return False - - # Check for present continuous pattern (-ing ending) but be more specific - if ( - first_word.endswith("ing") - and len(first_word) > 4 - and first_word - not in {"ring", "sing", "king", "wing", "thing", "string", "bring"} - ): - return False - - # Check for third person singular (-s ending) but be more specific - # Only flag if it's clearly a verb in third person singular form - if first_word.endswith("s") and len(first_word) > 3: - # Common nouns ending in 's' that should be allowed - common_nouns_ending_s = { - "process", - "access", - "address", - "progress", - "express", - "stress", - "success", - "class", - "pass", - "mass", - "loss", - "cross", - "gross", - "boss", - "toss", - "less", - "mess", - "dress", - "press", - "bless", - "guess", - "chess", - "glass", - "grass", - "brass", - } - - # Words ending in 'ss' or 'us' are usually not third person singular verbs - if first_word.endswith("ss") or first_word.endswith("us"): - return True # Allow these - - # If it's a common noun, allow it - if first_word in common_nouns_ending_s: - return True - - # Otherwise, it's likely a third person singular verb - return False - - # If we have imperatives loaded, check if the first word is imperative - if imperatives: - # Check if the first word is in our imperative list - if first_word in imperatives: - return True - - # If word is not in imperatives list, apply some heuristics - # If it passes all the negative checks above, it's likely imperative - return True diff --git a/commit_check/error.py b/commit_check/error.py deleted file mode 100644 index 639c373c..00000000 --- a/commit_check/error.py +++ /dev/null @@ -1,81 +0,0 @@ -""" -``commit_check.error`` ---------------------- - -A module containing error handler functions. -""" - -import contextlib -import os -import sys -import traceback -from typing import Generator -from commit_check.util import cmd_output - - -@contextlib.contextmanager -def error_handler() -> Generator[None, None, None]: - try: - yield - except (Exception, KeyboardInterrupt) as e: - if isinstance(e, RuntimeError): - msg, ret_code = "An error has occurred", 1 - elif isinstance(e, KeyboardInterrupt): - msg, ret_code = "Interrupted (^C)", 130 - else: - msg, ret_code = "An unexpected error has occurred", 3 - log_and_exit(msg, ret_code, e, traceback.format_exc()) - - -def log_and_exit(msg: str, ret_code: int, exc: BaseException, formatted: str) -> None: - error_msg = f"{msg}: {type(exc).__name__}: {exc}" - commit_check_version = cmd_output(["commit-check", "--version"]) - git_version = cmd_output(["git", "--version"]) - - store_dir = os.environ.get("COMMIT_CHECK_HOME") or os.path.join( - os.environ.get("XDG_CACHE_HOME") or os.path.expanduser("~/.cache"), - "commit-check", - ) - log_path = os.path.join(store_dir, "commit-check.log") - if not os.path.exists(store_dir): - os.makedirs(store_dir, exist_ok=True) - with open(os.path.join(store_dir, "README"), "w") as f: - f.write( - "This directory is maintained by the commit-check project.\n" - "Learn more: https://github.com/commit-check/commit-check\n", - ) - - def write_line(log_ctx=""): - with open(log_path, "a") as file: - file.write(f"{log_ctx}\n") - - if os.access(store_dir, os.W_OK): - open(log_path, "w").close() - write_line("### version information") - write_line("```") - write_line(f"commit-check --version: {commit_check_version}") - write_line(f"git --version: {git_version}") - write_line("sys.version:") - for line in sys.version.splitlines(): - write_line(f" {line}") - write_line(f"sys.executable: {sys.executable}") - write_line(f"os.name: {os.name}") - write_line(f"sys.platform: {sys.platform}") - write_line("```") - write_line() - write_line("### error information") - write_line() - write_line("```") - write_line(error_msg) - write_line("```") - write_line() - write_line("```") - write_line(formatted.rstrip()) - write_line("```") - else: - write_line(f"Failed to write to log at {log_path}") - - print(error_msg) - print(f"Check the log at {log_path}") - - raise SystemExit(ret_code) From 4f82871b94aa5bcd7f765b00470e642f7cda5362 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 26 Sep 2025 01:55:08 +0300 Subject: [PATCH 28/37] test: add more test --- .pre-commit-hooks.yaml | 25 +- noxfile.py | 2 +- tests/author_test.py | 192 ------------- tests/branch_test.py | 135 --------- tests/commit_test.py | 436 ----------------------------- tests/config_edge_test.py | 77 +++++ tests/config_fallback_test.py | 63 +++++ tests/config_import_test.py | 91 ++++++ tests/config_test.py | 194 +++++++++++++ tests/engine_comprehensive_test.py | 308 ++++++++++++++++++++ tests/engine_test.py | 391 ++++++++++++++++++++++++++ tests/error_test.py | 78 ------ tests/main_test.py | 165 +++++------ tests/rule_builder_test.py | 237 ++++++++++++++++ 14 files changed, 1438 insertions(+), 956 deletions(-) delete mode 100644 tests/author_test.py delete mode 100644 tests/branch_test.py delete mode 100644 tests/commit_test.py create mode 100644 tests/config_edge_test.py create mode 100644 tests/config_fallback_test.py create mode 100644 tests/config_import_test.py create mode 100644 tests/config_test.py create mode 100644 tests/engine_comprehensive_test.py create mode 100644 tests/engine_test.py delete mode 100644 tests/error_test.py create mode 100644 tests/rule_builder_test.py diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index 30b133f9..e13812ba 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -7,8 +7,8 @@ language: python stages: [commit-msg] - id: check-branch - name: check branch naming - description: ensures branch naming to match regex + name: check branch name + description: ensures branch name to match regex entry: commit-check args: [--branch] pass_filenames: false @@ -27,24 +27,3 @@ args: [--author-email] pass_filenames: false language: python -- id: check-commit-signoff - name: check committer signoff - description: ensures committer to add a Signed-off-by trailer - entry: commit-check - args: [--commit-signoff] - pass_filenames: false - language: python -- id: check-merge-base - name: check merge base - description: ensures current branch is rebased onto target branch - entry: commit-check - args: [--merge-base] - pass_filenames: false - language: python -- id: check-imperative - name: check imperative mood - description: ensures commit message uses imperative mood - entry: commit-check - args: [--imperative] - pass_filenames: true - language: python diff --git a/noxfile.py b/noxfile.py index 55872c06..0cb42d58 100644 --- a/noxfile.py +++ b/noxfile.py @@ -29,7 +29,7 @@ def build(session): session.run("python3", "-m", "pip", "wheel", "--no-deps", "-w", "dist", ".") -@nox.session(name="install-wheel", requires=["build"]) +@nox.session(name="install", requires=["build"]) def install_wheel(session): session.run("python3", "-m", "pip", "wheel", "--no-deps", "-w", "dist", ".") whl_file = glob.glob("dist/*.whl") diff --git a/tests/author_test.py b/tests/author_test.py deleted file mode 100644 index 37097a84..00000000 --- a/tests/author_test.py +++ /dev/null @@ -1,192 +0,0 @@ -import pytest -from commit_check import PASS, FAIL -from commit_check.author import check_author - -# The location of check_author() -LOCATION = "commit_check.author" - - -class TestAuthor: - class TestAuthorName: - # used by get_commit_info mock - fake_author_value_an = "fake_author_name" - fake_accented_author_value_an = "fáké_áúthór_námé" - - @pytest.mark.benchmark - def test_check_author(self, mocker): - # Must call get_commit_info, re.match. - checks = [{"check": "author_name", "regex": "dummy_regex"}] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") - retval = check_author(checks, "author_name") - assert retval == PASS - assert m_get_commit_info.call_count == 1 - assert m_re_match.call_count == 1 - - @pytest.mark.benchmark - def test_check_author_with_accented_letters(self, mocker): - # Must call get_commit_info, re.match. - checks = [{"check": "author_name", "regex": "dummy_regex"}] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", - return_value=self.fake_accented_author_value_an, - ) - m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") - retval = check_author(checks, "author_name") - assert retval == PASS - assert m_get_commit_info.call_count == 1 - assert m_re_match.call_count == 1 - - @pytest.mark.benchmark - def test_check_author_with_empty_checks(self, mocker): - # Must NOT call get_commit_info, re.match. with `checks` param with length 0. - checks = [] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch("re.match", return_value="fake_author_name") - retval = check_author(checks, "author_name") - assert retval == PASS - assert m_get_commit_info.call_count == 0 - assert m_re_match.call_count == 0 - - @pytest.mark.benchmark - def test_check_author_with_different_check(self, mocker): - # Must NOT call get_commit_info, re.match with not `author_name`. - checks = [{"check": "message", "regex": "dummy_regex"}] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch("re.match", return_value="fake_author_name") - retval = check_author(checks, "author_name") - assert retval == PASS - assert m_get_commit_info.call_count == 0 - assert m_re_match.call_count == 0 - - @pytest.mark.benchmark - def test_check_author_with_len0_regex(self, mocker, capfd): - # Must NOT call get_commit_info, re.match with `regex` with length 0. - checks = [{"check": "author_name", "regex": ""}] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") - retval = check_author(checks, "author_name") - assert retval == PASS - assert m_get_commit_info.call_count == 0 - assert m_re_match.call_count == 0 - out, _ = capfd.readouterr() - assert "Not found regex for author_name." in out - - @pytest.mark.benchmark - def test_check_author_with_result_none(self, mocker): - # Must call print_error_message, print_suggestion when re.match returns NONE. - checks = [ - { - "check": "author_name", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest", - } - ] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_an - ) - m_re_match = mocker.patch("re.match", return_value=None) - m_print_error_message = mocker.patch( - "commit_check.util.print_error_message" - ) - m_print_suggestion = mocker.patch("commit_check.util.print_suggestion") - retval = check_author(checks, "author_name") - assert retval == FAIL - assert m_get_commit_info.call_count == 1 - assert m_re_match.call_count == 1 - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 - - class TestAuthorEmail: - # used by get_commit_info mock - fake_author_value_ae = "fake_author_email" - - @pytest.mark.benchmark - def test_check_author(self, mocker): - # Must call get_commit_info, re.match. - checks = [{"check": "author_email", "regex": "dummy_regex"}] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") - retval = check_author(checks, "author_email") - assert retval == PASS - assert m_get_commit_info.call_count == 1 - assert m_re_match.call_count == 1 - - @pytest.mark.benchmark - def test_check_author_with_empty_checks(self, mocker): - # Must NOT call get_commit_info, re.match. with `checks` param with length 0. - checks = [] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch("re.match", return_value="fake_author_email") - retval = check_author(checks, "author_email") - assert retval == PASS - assert m_get_commit_info.call_count == 0 - assert m_re_match.call_count == 0 - - @pytest.mark.benchmark - def test_check_author_with_different_check(self, mocker): - # Must NOT call get_commit_info, re.match with not `author_email`. - checks = [{"check": "message", "regex": "dummy_regex"}] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch("re.match", return_value="fake_author_email") - retval = check_author(checks, "author_email") - assert retval == PASS - assert m_get_commit_info.call_count == 0 - assert m_re_match.call_count == 0 - - @pytest.mark.benchmark - def test_check_author_with_len0_regex(self, mocker, capfd): - # Must NOT call get_commit_info, re.match with `regex` with length 0. - checks = [{"check": "author_email", "regex": ""}] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") - retval = check_author(checks, "author_email") - assert retval == PASS - assert m_get_commit_info.call_count == 0 - assert m_re_match.call_count == 0 - out, _ = capfd.readouterr() - assert "Not found regex for author_email." in out - - @pytest.mark.benchmark - def test_check_author_with_result_none(self, mocker): - # Must call print_error_message, print_suggestion when re.match returns NONE. - checks = [ - { - "check": "author_email", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest", - } - ] - m_get_commit_info = mocker.patch( - f"{LOCATION}.get_commit_info", return_value=self.fake_author_value_ae - ) - m_re_match = mocker.patch("re.match", return_value=None) - m_print_error_message = mocker.patch( - "commit_check.util.print_error_message" - ) - m_print_suggestion = mocker.patch("commit_check.util.print_suggestion") - retval = check_author(checks, "author_email") - assert retval == FAIL - assert m_get_commit_info.call_count == 1 - assert m_re_match.call_count == 1 - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 - assert m_print_suggestion.call_count == 1 diff --git a/tests/branch_test.py b/tests/branch_test.py deleted file mode 100644 index 5898c3e6..00000000 --- a/tests/branch_test.py +++ /dev/null @@ -1,135 +0,0 @@ -import pytest -from commit_check import PASS, FAIL -from commit_check.branch import check_branch, check_merge_base - -# used by get_branch_name mock -FAKE_BRANCH_NAME = "fake_branch_name" -LOCATION = "commit_check.branch" - - -class TestCheckBranch: - @pytest.mark.benchmark - def test_check_branch(self, mocker): - # Must call get_branch_name, re.match at once. - checks = [{"check": "branch", "regex": "dummy_regex"}] - m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") - retval = check_branch(checks) - assert retval == PASS - assert m_get_branch_name.call_count == 1 - assert m_re_match.call_count == 1 - - @pytest.mark.benchmark - def test_check_branch_with_empty_checks(self, mocker): - # Must NOT call get_branch_name, re.match with `checks` param with length 0. - checks = [] - m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch("re.match", return_value="fake_branch_name") - retval = check_branch(checks) - assert retval == PASS - assert m_get_branch_name.call_count == 0 - assert m_re_match.call_count == 0 - - @pytest.mark.benchmark - def test_check_branch_with_different_check(self, mocker): - # Must NOT call get_branch_name, re.match with not `branch`. - checks = [{"check": "message", "regex": "dummy_regex"}] - m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch("re.match", return_value="fake_branch_name") - retval = check_branch(checks) - assert retval == PASS - assert m_get_branch_name.call_count == 0 - assert m_re_match.call_count == 0 - - @pytest.mark.benchmark - def test_check_branch_with_len0_regex(self, mocker, capfd): - # Must NOT call get_branch_name, re.match with `regex` with length 0. - checks = [{"check": "branch", "regex": ""}] - m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") - retval = check_branch(checks) - assert retval == PASS - assert m_get_branch_name.call_count == 0 - assert m_re_match.call_count == 0 - out, _ = capfd.readouterr() - assert "Not found regex for branch naming." in out - - @pytest.mark.benchmark - def test_check_branch_with_result_none(self, mocker): - # Must call print_error_message, print_suggestion when re.match returns NONE. - checks = [ - { - "check": "branch", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest", - } - ] - m_get_branch_name = mocker.patch( - f"{LOCATION}.get_branch_name", return_value=FAKE_BRANCH_NAME - ) - m_re_match = mocker.patch("re.match", return_value=None) - m_print_error_message = mocker.patch("commit_check.util.print_error_message") - m_print_suggestion = mocker.patch("commit_check.util.print_suggestion") - retval = check_branch(checks) - assert retval == FAIL - assert m_get_branch_name.call_count == 1 - assert m_re_match.call_count == 1 - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 - - -class TestCheckMergeBase: - @pytest.mark.benchmark - def test_check_merge_base_with_empty_checks(self, mocker): - checks = [] - m_check_merge = mocker.patch(f"{LOCATION}.check_merge_base") - retval = check_merge_base(checks) - assert retval == PASS - assert m_check_merge.call_count == 0 - - @pytest.mark.benchmark - def test_check_merge_base_with_empty_regex(self, mocker): - checks = [{"check": "merge_base", "regex": ""}] - m_check_merge = mocker.patch(f"{LOCATION}.check_merge_base") - retval = check_merge_base(checks) - assert retval == PASS - assert m_check_merge.call_count == 0 - - @pytest.mark.benchmark - def test_check_merge_base_with_different_check(self, mocker): - checks = [{"check": "branch", "regex": "main"}] - m_check_merge = mocker.patch(f"{LOCATION}.check_merge_base") - retval = check_merge_base(checks) - assert retval == PASS - assert m_check_merge.call_count == 0 - - @pytest.mark.benchmark - def test_check_merge_base_fail_with_messages(self, mocker, capfd): - checks = [ - { - "check": "merge_base", - "regex": "develop", - "error": "Current branch is not", - "suggest": "Please rebase", - } - ] - # Simulate a normal named branch so skip logic doesn't apply - mocker.patch(f"{LOCATION}.get_branch_name", return_value="feature/something") - # Force git merge-base to report not ancestor (return code 1) - mocker.patch(f"{LOCATION}.git_merge_base", return_value=1) - m_print_error = mocker.patch("commit_check.util.print_error_message") - m_print_suggest = mocker.patch("commit_check.util.print_suggestion") - - retval = check_merge_base(checks) - assert retval == FAIL - assert "Current branch is not" in m_print_error.call_args[0][2] - assert "Please rebase" in m_print_suggest.call_args[0][0] diff --git a/tests/commit_test.py b/tests/commit_test.py deleted file mode 100644 index bc824bdb..00000000 --- a/tests/commit_test.py +++ /dev/null @@ -1,436 +0,0 @@ -import pytest -from commit_check import PASS, FAIL -from commit_check.commit import ( - check_commit_msg, - check_signoff, - get_default_commit_msg_file, - read_commit_msg, - check_imperative, -) - -# used by get_commit_info mock -FAKE_BRANCH_NAME = "fake_commits_info" -# The location of check_commit_msg() -LOCATION = "commit_check.util" -# Commit message file -MSG_FILE = ".git/COMMIT_EDITMSG" - - -@pytest.mark.benchmark -def test_get_default_commit_msg_file(mocker): - retval = get_default_commit_msg_file() - assert retval == ".git/COMMIT_EDITMSG" - - -@pytest.mark.benchmark -def test_read_commit_msg_from_existing_file(tmp_path): - # Create a temporary file with a known content - commit_msg_content = "Test commit message content." - commit_msg_file = tmp_path / "test_commit_msg.txt" - commit_msg_file.write_text(commit_msg_content) - - result = read_commit_msg(commit_msg_file) - assert result == commit_msg_content - - -@pytest.mark.benchmark -def test_read_commit_msg_file_not_found(mocker): - m_commits_info = mocker.patch( - "commit_check.util.get_commit_info", return_value="mocked_commits_info" - ) - read_commit_msg("non_existent_file.txt") - assert m_commits_info.call_count == 0 - - -@pytest.mark.benchmark -def test_check_commit_msg_no_commit_msg_file(mocker): - mock_get_default_commit_msg_file = mocker.patch( - "commit_check.commit.get_default_commit_msg_file", - return_value=".git/COMMIT_EDITMSG", - ) - mock_read_commit_msg = mocker.patch( - "commit_check.commit.read_commit_msg", return_value="Sample commit message" - ) - - checks = [{"regex": ".*", "check": "message", "error": "Invalid", "suggest": None}] - - result = check_commit_msg(checks, commit_msg_file="") - - mock_get_default_commit_msg_file.assert_called_once() - mock_read_commit_msg.assert_called_once_with(".git/COMMIT_EDITMSG") - assert result == 0 - - -@pytest.mark.benchmark -def test_check_commit_with_empty_checks(mocker): - checks = [] - m_re_match = mocker.patch("re.match", return_value="fake_commits_info") - retval = check_commit_msg(checks, MSG_FILE) - assert retval == PASS - assert m_re_match.call_count == 0 - - -@pytest.mark.benchmark -def test_check_commit_with_different_check(mocker): - checks = [{"check": "branch", "regex": "dummy_regex"}] - m_re_match = mocker.patch("re.match", return_value="fake_commits_info") - retval = check_commit_msg(checks, MSG_FILE) - assert retval == PASS - assert m_re_match.call_count == 0 - - -@pytest.mark.benchmark -def test_check_commit_with_len0_regex(mocker, capfd): - checks = [{"check": "message", "regex": ""}] - m_re_match = mocker.patch("re.match", return_value="fake_rematch_resp") - retval = check_commit_msg(checks, MSG_FILE) - assert retval == PASS - assert m_re_match.call_count == 0 - out, _ = capfd.readouterr() - assert "Not found regex for commit message." in out - - -@pytest.mark.benchmark -def test_check_commit_with_result_none(mocker): - checks = [ - { - "check": "message", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest", - } - ] - m_re_match = mocker.patch("re.match", return_value=None) - m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") - m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") - retval = check_commit_msg(checks, MSG_FILE) - assert retval == FAIL - assert m_re_match.call_count == 1 - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 - - -@pytest.mark.benchmark -def test_check_signoff(mocker): - checks = [ - { - "check": "signoff", - "regex": "dummy_regex", - "error": "error", - "suggest": "suggest", - } - ] - m_re_search = mocker.patch("re.search", return_value=None) - m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") - m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") - # Ensure commit message is NOT a merge commit - mocker.patch( - "commit_check.commit.read_commit_msg", return_value="feat: add new feature" - ) - retval = check_signoff(checks) - assert retval == FAIL - assert m_re_search.call_count == 1 - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 - - -@pytest.mark.benchmark -def test_check_signoff_with_empty_regex(mocker): - checks = [{"check": "signoff", "regex": "", "error": "error", "suggest": "suggest"}] - m_re_match = mocker.patch("re.match", return_value="fake_commits_info") - retval = check_signoff(checks) - assert retval == PASS - assert m_re_match.call_count == 0 - - -@pytest.mark.benchmark -def test_check_signoff_with_empty_checks(mocker): - checks = [] - m_re_match = mocker.patch("re.match", return_value="fake_commits_info") - retval = check_signoff(checks) - assert retval == PASS - assert m_re_match.call_count == 0 - - -@pytest.mark.benchmark -def test_check_signoff_skip_merge_commit(mocker): - """Test commit signoff check skips merge commits.""" - checks = [ - { - "check": "signoff", - "regex": "Signed-off-by:", - "error": "Signed-off-by not found", - "suggest": "Use --signoff", - } - ] - - mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="Merge branch 'feature/test' into main", - ) - - retval = check_signoff(checks, MSG_FILE) - assert retval == PASS - - -@pytest.mark.benchmark -def test_check_signoff_skip_merge_pr_commit(mocker): - """Test commit signoff check skips GitHub merge PR commits.""" - checks = [ - { - "check": "signoff", - "regex": "Signed-off-by:", - "error": "Signed-off-by not found", - "suggest": "Use --signoff", - } - ] - - mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="Merge pull request #123 from user/feature\n\nAdd new feature", - ) - - retval = check_signoff(checks, MSG_FILE) - assert retval == PASS - - -@pytest.mark.benchmark -def test_check_signoff_still_fails_non_merge_without_signoff(mocker): - """Test commit signoff check still fails for non-merge commits without signoff.""" - checks = [ - { - "check": "signoff", - "regex": "Signed-off-by:", - "error": "Signed-off-by not found", - "suggest": "Use --signoff", - } - ] - - mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="feat: add new feature\n\nThis adds a new feature", - ) - - m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") - m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") - - retval = check_signoff(checks, MSG_FILE) - assert retval == FAIL - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 - - -@pytest.mark.benchmark -def test_check_imperative_pass(mocker): - """Test imperative mood check passes for valid imperative mood.""" - checks = [ - { - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood", - } - ] - - mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="feat: Add new feature\n\nThis adds a new feature to the application.", - ) - - retval = check_imperative(checks, MSG_FILE) - assert retval == PASS - - -@pytest.mark.benchmark -def test_check_imperative_fail_past_tense(mocker): - """Test imperative mood check fails for past tense.""" - checks = [ - { - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood", - } - ] - - mocker.patch( - "commit_check.commit.read_commit_msg", return_value="feat: Added new feature" - ) - - m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") - m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") - - retval = check_imperative(checks, MSG_FILE) - assert retval == FAIL - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 - - -@pytest.mark.benchmark -def test_check_imperative_fail_present_continuous(mocker): - """Test imperative mood check fails for present continuous.""" - checks = [ - { - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood", - } - ] - - mocker.patch( - "commit_check.commit.read_commit_msg", return_value="feat: Adding new feature" - ) - - m_print_error_message = mocker.patch(f"{LOCATION}.print_error_message") - m_print_suggestion = mocker.patch(f"{LOCATION}.print_suggestion") - - retval = check_imperative(checks, MSG_FILE) - assert retval == FAIL - assert m_print_error_message.call_count == 1 - assert m_print_suggestion.call_count == 1 - - -@pytest.mark.benchmark -def test_check_imperative_skip_merge_commit(mocker): - """Test imperative mood check skips merge commits.""" - checks = [ - { - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood", - } - ] - - mocker.patch( - "commit_check.commit.read_commit_msg", - return_value="Merge branch 'feature/test' into main", - ) - - retval = check_imperative(checks, MSG_FILE, stdin_text=None) - assert retval == PASS - - -@pytest.mark.benchmark -def test_check_imperative_different_check_type(mocker): - """Test imperative mood check skips different check types.""" - checks = [{"check": "message", "regex": "dummy_regex"}] - - m_read_commit_msg = mocker.patch( - "commit_check.commit.read_commit_msg", return_value="feat: Added new feature" - ) - - retval = check_imperative(checks, MSG_FILE, stdin_text="feat: Added new feature") - assert retval == PASS - assert m_read_commit_msg.call_count == 0 - - -@pytest.mark.benchmark -def test_check_imperative_no_commits(mocker): - """Test imperative mood check passes when there are no commits.""" - checks = [ - { - "check": "imperative", - "regex": "", - "error": "Commit message should use imperative mood", - "suggest": "Use imperative mood", - } - ] - - mocker.patch("commit_check.commit.has_commits", return_value=False) - - retval = check_imperative(checks, MSG_FILE) - assert retval == PASS - - -@pytest.mark.benchmark -def test_check_imperative_empty_checks(mocker): - """Test imperative mood check with empty checks list.""" - checks = [] - - m_read_commit_msg = mocker.patch( - "commit_check.commit.read_commit_msg", return_value="feat: Added new feature" - ) - - retval = check_imperative(checks, MSG_FILE, stdin_text=None) - assert retval == PASS - assert m_read_commit_msg.call_count == 0 - - -@pytest.mark.benchmark -def test_is_imperative_valid_cases(): - """Test _is_imperative function with valid imperative mood cases.""" - from commit_check.commit import _is_imperative - - valid_cases = [ - "Add new feature", - "Fix bug in authentication", - "Update documentation", - "Remove deprecated code", - "Refactor user service", - "Optimize database queries", - "Create new component", - "Delete unused files", - "Improve error handling", - "Enhance user experience", - "Implement new API", - "Configure CI/CD pipeline", - "Setup testing framework", - "Handle edge cases", - "Process user input", - "Validate form data", - "Transform data format", - "Initialize application", - "Load configuration", - "Save user preferences", - "", # Empty description should pass - ] - - for case in valid_cases: - assert _is_imperative(case), f"'{case}' should be imperative mood" - - -@pytest.mark.benchmark -def test_is_imperative_invalid_cases(): - """Test _is_imperative function with invalid imperative mood cases.""" - from commit_check.commit import _is_imperative - - invalid_cases = [ - "Added new feature", - "Fixed bug in authentication", - "Updated documentation", - "Removed deprecated code", - "Refactored user service", - "Optimized database queries", - "Created new component", - "Deleted unused files", - "Improved error handling", - "Enhanced user experience", - "Implemented new API", - "Adding new feature", - "Fixing bug in authentication", - "Updating documentation", - "Removing deprecated code", - "Refactoring user service", - "Optimizing database queries", - "Creating new component", - "Deleting unused files", - "Improving error handling", - "Enhancing user experience", - "Implementing new API", - "Adds new feature", - "Fixes bug in authentication", - "Updates documentation", - "Removes deprecated code", - "Refactors user service", - "Optimizes database queries", - "Creates new component", - "Deletes unused files", - "Improves error handling", - "Enhances user experience", - "Implements new API", - ] - - for case in invalid_cases: - assert not _is_imperative(case), f"'{case}' should not be imperative mood" diff --git a/tests/config_edge_test.py b/tests/config_edge_test.py new file mode 100644 index 00000000..817ec8df --- /dev/null +++ b/tests/config_edge_test.py @@ -0,0 +1,77 @@ +"""Test for TOML parsing errors and exception handling.""" + +import pytest +import tempfile +import os +from commit_check.config import load_config + + +def test_load_config_invalid_toml(): + """Test handling of invalid TOML syntax.""" + invalid_toml = b""" +[incomplete +missing closing bracket +""" + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(invalid_toml) + f.flush() + + try: + with pytest.raises(Exception): # Should raise a TOML parsing error + load_config(f.name) + finally: + os.unlink(f.name) + + +def test_load_config_file_permission_error(): + """Test handling of file permission errors.""" + config_content = b""" +[checks] +test = true +""" + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(config_content) + f.flush() + + try: + # Remove read permissions to simulate permission error + os.chmod(f.name, 0o000) + + with pytest.raises(PermissionError): + load_config(f.name) + finally: + # Restore permissions and clean up + os.chmod(f.name, 0o644) + os.unlink(f.name) + + +def test_tomli_import_fallback(): + """Test the tomli import fallback when tomllib is not available.""" + # We need to test the import fallback behavior + # This is complex because the imports happen at module load time + + # Create a minimal test by importing the config module's behavior + config_content = b""" +[test] +fallback = true +""" + + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(config_content) + f.flush() + + try: + # Test that we can load config regardless of which TOML library is used + config = load_config(f.name) + assert config["test"]["fallback"] is True + + # Since we can't easily test the import fallback on Python 3.11+, + # let's at least verify that the toml_load function works + from commit_check.config import toml_load + + with open(f.name, "rb") as config_file: + result = toml_load(config_file) + assert result["test"]["fallback"] is True + + finally: + os.unlink(f.name) diff --git a/tests/config_fallback_test.py b/tests/config_fallback_test.py new file mode 100644 index 00000000..6991f812 --- /dev/null +++ b/tests/config_fallback_test.py @@ -0,0 +1,63 @@ +"""Direct test of the config import fallback using module manipulation.""" + +import sys +import tempfile +import os +from unittest.mock import patch + + +def test_config_tomli_fallback_direct(): + """Test config.py fallback to tomli by manipulating imports.""" + + # Save original state + original_modules = sys.modules.copy() + + try: + # Remove config module if already imported + if "commit_check.config" in sys.modules: + del sys.modules["commit_check.config"] + + # Make tomllib unavailable by raising ImportError + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): + if name == "tomllib": + raise ImportError("No module named 'tomllib'") + # For tomli, return a working mock + if name == "tomli": + + class MockTomli: + @staticmethod + def load(f): + content = f.read().decode("utf-8") + # Simple parser for test + if 'test_key = "test_value"' in content: + return {"test_key": "test_value"} + return {} + + return MockTomli() + return original_import(name, globals, locals, fromlist, level) + + original_import = __builtins__["__import__"] + + with patch.object(__builtins__, "__import__", mock_import): + # Now import config - should use tomli fallback + import commit_check.config as config + + # Test that it works + config_content = b'test_key = "test_value"' + with tempfile.NamedTemporaryFile( + mode="wb", suffix=".toml", delete=False + ) as f: + f.write(config_content) + f.flush() + + try: + with open(f.name, "rb") as config_file: + result = config.toml_load(config_file) + assert result == {"test_key": "test_value"} + finally: + os.unlink(f.name) + + finally: + # Restore original modules + sys.modules.clear() + sys.modules.update(original_modules) diff --git a/tests/config_import_test.py b/tests/config_import_test.py new file mode 100644 index 00000000..1bfc06f0 --- /dev/null +++ b/tests/config_import_test.py @@ -0,0 +1,91 @@ +"""Test import fallback by creating a test version of config.py.""" + +import tempfile +import os +from unittest.mock import patch + + +def test_tomli_import_fallback_simulation(): + """Test tomli import fallback by simulating the ImportError condition.""" + + # Create test code that simulates the config.py import logic + test_code = """ +try: + import tomllib + toml_load = tomllib.load + used_tomllib = True +except ImportError: + import tomli + toml_load = tomli.load + used_tomllib = False +""" + + # Test case 1: Normal case (tomllib available) + namespace1 = {} + exec(test_code, namespace1) + assert namespace1["used_tomllib"] is True + assert callable(namespace1["toml_load"]) + + # Test case 2: Simulate ImportError for tomllib + with patch.dict("sys.modules", {"tomllib": None}): + with patch( + "builtins.__import__", + side_effect=lambda name, *args, **kwargs: _mock_import_error( + name, *args, **kwargs + ), + ): + namespace2 = {} + exec(test_code, namespace2) + assert namespace2["used_tomllib"] is False + assert callable(namespace2["toml_load"]) + + +def _mock_import_error(name, *args, **kwargs): + """Mock import function that raises ImportError for tomllib.""" + if name == "tomllib": + raise ImportError("No module named 'tomllib'") + + # For tomli, we need to mock it since it might not be installed + if name == "tomli": + # Create a mock tomli module + class MockTomli: + @staticmethod + def load(f): + # Simple TOML parser for testing + content = f.read().decode("utf-8") + if "[test]" in content and 'value = "test"' in content: + return {"test": {"value": "test"}} + return {} + + return MockTomli() + + # For all other imports, use the real import + return __import__(name, *args, **kwargs) + + +def test_import_paths_coverage(): + """Ensure both import paths are conceptually tested.""" + # This test verifies that both the tomllib and tomli code paths + # would work in their respective environments + + # Test the function signature matches expectation + config_content = b""" +[test] +value = "test" +""" + + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(config_content) + f.flush() + + try: + # Test using the actual config module (uses tomllib on Python 3.11+) + from commit_check.config import toml_load + + with open(f.name, "rb") as config_file: + result = toml_load(config_file) + + assert result == {"test": {"value": "test"}} + + finally: + os.unlink(f.name) diff --git a/tests/config_test.py b/tests/config_test.py new file mode 100644 index 00000000..5b3e55e5 --- /dev/null +++ b/tests/config_test.py @@ -0,0 +1,194 @@ +"""Tests for commit_check.config module.""" + +import pytest +import tempfile +import os +from pathlib import Path +from unittest.mock import patch +from commit_check.config import load_config, DEFAULT_CONFIG_PATHS + + +class TestConfig: + def test_load_config_with_path_hint(self): + """Test loading config with explicit path hint.""" + config_content = b""" +[checks] +message = true +branch = true +""" + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(config_content) + f.flush() + + try: + config = load_config(f.name) + assert "checks" in config + assert config["checks"]["message"] is True + assert config["checks"]["branch"] is True + finally: + os.unlink(f.name) + + def test_load_config_with_nonexistent_path_hint(self): + """Test loading config when path hint doesn't exist, falls back to default paths.""" + # Create a temporary cchk.toml in current directory + config_content = b""" +[checks] +fallback = true +""" + original_cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmpdir: + os.chdir(tmpdir) + try: + # Create cchk.toml in temp directory + with open("cchk.toml", "wb") as f: + f.write(config_content) + + # Try to load with nonexistent path hint + config = load_config("nonexistent.toml") + assert "checks" in config + assert config["checks"]["fallback"] is True + finally: + os.chdir(original_cwd) + + def test_load_config_default_cchk_toml(self): + """Test loading config from default cchk.toml path.""" + config_content = b""" +[checks] +default_cchk = true +""" + original_cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmpdir: + os.chdir(tmpdir) + try: + with open("cchk.toml", "wb") as f: + f.write(config_content) + + config = load_config() + assert "checks" in config + assert config["checks"]["default_cchk"] is True + finally: + os.chdir(original_cwd) + + def test_load_config_default_commit_check_toml(self): + """Test loading config from default commit-check.toml path.""" + config_content = b""" +[checks] +commit_check_toml = true +""" + original_cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmpdir: + os.chdir(tmpdir) + try: + with open("commit-check.toml", "wb") as f: + f.write(config_content) + + config = load_config() + assert "checks" in config + assert config["checks"]["commit_check_toml"] is True + finally: + os.chdir(original_cwd) + + def test_load_config_file_not_found(self): + """Test FileNotFoundError when no config files exist.""" + original_cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmpdir: + os.chdir(tmpdir) + try: + with pytest.raises(FileNotFoundError, match="No config file found"): + load_config() + finally: + os.chdir(original_cwd) + + def test_load_config_file_not_found_with_invalid_path_hint(self): + """Test FileNotFoundError when path hint and default paths don't exist.""" + original_cwd = os.getcwd() + with tempfile.TemporaryDirectory() as tmpdir: + os.chdir(tmpdir) + try: + with pytest.raises(FileNotFoundError, match="No config file found"): + load_config("nonexistent.toml") + finally: + os.chdir(original_cwd) + + def test_default_config_paths_constant(self): + """Test that DEFAULT_CONFIG_PATHS contains expected paths.""" + assert len(DEFAULT_CONFIG_PATHS) == 2 + assert Path("cchk.toml") in DEFAULT_CONFIG_PATHS + assert Path("commit-check.toml") in DEFAULT_CONFIG_PATHS + + def test_toml_load_function_exists(self): + """Test that toml_load function is properly set up.""" + from commit_check.config import toml_load + + assert callable(toml_load) + + # Test that it can actually parse TOML content + config_content = b""" +[test] +value = "works" +""" + with tempfile.NamedTemporaryFile(mode="wb", suffix=".toml", delete=False) as f: + f.write(config_content) + f.flush() + + try: + with open(f.name, "rb") as config_file: + result = toml_load(config_file) + assert result == {"test": {"value": "works"}} + finally: + os.unlink(f.name) + + def test_tomli_import_fallback(self): + """Test that tomli is imported when tomllib is not available (lines 10-13).""" + import sys + + # Save original modules + original_tomllib = sys.modules.get("tomllib") + original_config = sys.modules.get("commit_check.config") + + try: + # Remove modules from cache to force fresh import + if "tomllib" in sys.modules: + del sys.modules["tomllib"] + if "commit_check.config" in sys.modules: + del sys.modules["commit_check.config"] + + # Mock tomllib module to not exist + with patch.dict("sys.modules", {"tomllib": None}): + # Force import error by patching __import__ for tomllib specifically + original_import = __builtins__["__import__"] + + def mock_import(name, *args, **kwargs): + if name == "tomllib": + raise ImportError("No module named 'tomllib'") + return original_import(name, *args, **kwargs) + + with patch("builtins.__import__", side_effect=mock_import): + # Import the config module - should use tomli fallback + import commit_check.config as config_module + + # Verify that the module loaded successfully + assert hasattr(config_module, "toml_load") + assert callable(config_module.toml_load) + + # Test that it can actually parse TOML content + config_content = b'[test]\nvalue = "tomli_works"\n' + with tempfile.NamedTemporaryFile( + mode="wb", suffix=".toml", delete=False + ) as f: + f.write(config_content) + f.flush() + + try: + with open(f.name, "rb") as config_file: + result = config_module.toml_load(config_file) + assert result == {"test": {"value": "tomli_works"}} + finally: + os.unlink(f.name) + + finally: + # Restore original modules + if original_tomllib is not None: + sys.modules["tomllib"] = original_tomllib + if original_config is not None: + sys.modules["commit_check.config"] = original_config diff --git a/tests/engine_comprehensive_test.py b/tests/engine_comprehensive_test.py new file mode 100644 index 00000000..cfb8538f --- /dev/null +++ b/tests/engine_comprehensive_test.py @@ -0,0 +1,308 @@ +"""Comprehensive tests for commit_check.engine module.""" + +from unittest.mock import patch +from commit_check.engine import ( + ValidationResult, + ValidationContext, + ValidationEngine, + CommitMessageValidator, + SubjectCapitalizationValidator, + SubjectImperativeValidator, + SubjectLengthValidator, + AuthorValidator, + BranchValidator, + MergeBaseValidator, + SignoffValidator, + BodyValidator, + CommitTypeValidator, +) +from commit_check.rule_builder import ValidationRule + + +class TestValidationResult: + def test_validation_result_values(self): + """Test ValidationResult enum values.""" + assert ValidationResult.PASS == 0 + assert ValidationResult.FAIL == 1 + + +class TestValidationContext: + def test_validation_context_creation(self): + """Test ValidationContext creation.""" + context = ValidationContext() + assert context.stdin_text is None + assert context.commit_file is None + + context_with_data = ValidationContext( + stdin_text="test commit", commit_file="commit.txt" + ) + assert context_with_data.stdin_text == "test commit" + assert context_with_data.commit_file == "commit.txt" + + +class TestCommitMessageValidator: + def test_commit_message_validator_creation(self): + """Test CommitMessageValidator creation.""" + rule = ValidationRule( + check="message", + regex="^(feat|fix):", + error="Invalid commit message", + suggest="Use conventional format", + ) + validator = CommitMessageValidator(rule) + assert validator.rule == rule + + @patch("commit_check.engine.get_commit_info") + @patch("commit_check.engine.has_commits") + def test_commit_message_validator_with_stdin( + self, mock_has_commits, mock_get_commit_info + ): + """Test CommitMessageValidator with stdin text.""" + mock_has_commits.return_value = True + + rule = ValidationRule( + check="message", + regex="^(feat|fix):", + error="Invalid commit message", + suggest="Use conventional format", + ) + validator = CommitMessageValidator(rule) + context = ValidationContext(stdin_text="feat: add new feature") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + @patch("commit_check.engine.get_commit_info") + @patch("commit_check.engine.has_commits") + def test_commit_message_validator_failure( + self, mock_has_commits, mock_get_commit_info + ): + """Test CommitMessageValidator failure case.""" + mock_has_commits.return_value = True + + rule = ValidationRule( + check="message", + regex="^(feat|fix):", + error="Invalid commit message", + suggest="Use conventional format", + ) + validator = CommitMessageValidator(rule) + context = ValidationContext(stdin_text="bad commit message") + + with patch.object(validator, "_print_failure") as mock_print: + result = validator.validate(context) + assert result == ValidationResult.FAIL + mock_print.assert_called_once() + + @patch("commit_check.engine.has_commits") + def test_commit_message_validator_skip_validation(self, mock_has_commits): + """Test CommitMessageValidator skips when no commits and no stdin.""" + mock_has_commits.return_value = False + + rule = ValidationRule( + check="message", + regex="^(feat|fix):", + error="Invalid commit message", + suggest="Use conventional format", + ) + validator = CommitMessageValidator(rule) + context = ValidationContext() # No stdin_text + + result = validator.validate(context) + assert result == ValidationResult.PASS + + +class TestSubjectCapitalizationValidator: + def test_subject_capitalization_pass(self): + """Test SubjectCapitalizationValidator pass case.""" + rule = ValidationRule( + check="subject_capitalized", + regex="^[A-Z]", + error="Subject must be capitalized", + suggest="Capitalize first letter", + ) + validator = SubjectCapitalizationValidator(rule) + # Use conventional commit format with capitalized description + context = ValidationContext(stdin_text="feat: Add new feature") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_subject_capitalization_fail(self): + """Test SubjectCapitalizationValidator fail case.""" + rule = ValidationRule( + check="subject_capitalized", + regex="^[A-Z]", + error="Subject must be capitalized", + suggest="Capitalize first letter", + ) + validator = SubjectCapitalizationValidator(rule) + # Use conventional commit format with lowercase description + context = ValidationContext(stdin_text="feat: add new feature") + + with patch.object(validator, "_print_failure") as mock_print: + result = validator.validate(context) + assert result == ValidationResult.FAIL + mock_print.assert_called_once() + + +class TestSubjectImperativeValidator: + def test_subject_imperative_pass(self): + """Test SubjectImperativeValidator pass case.""" + rule = ValidationRule( + check="imperative", + regex="", + error="Subject must be imperative", + suggest="Use imperative mood", + ) + validator = SubjectImperativeValidator(rule) + # Use conventional commit with imperative verb "add" + context = ValidationContext(stdin_text="feat: add new feature") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_subject_imperative_fail(self): + """Test SubjectImperativeValidator fail case.""" + rule = ValidationRule( + check="imperative", + regex="", + error="Subject must be imperative", + suggest="Use imperative mood", + ) + validator = SubjectImperativeValidator(rule) + # Use past tense "added" which is not imperative + context = ValidationContext(stdin_text="feat: added new feature") + + with patch.object(validator, "_print_failure") as mock_print: + result = validator.validate(context) + assert result == ValidationResult.FAIL + mock_print.assert_called_once() + + +class TestSubjectLengthValidator: + def test_subject_length_pass(self): + """Test SubjectLengthValidator pass case.""" + rule = ValidationRule( + check="subject_max_length", + regex="", + error="Subject too long", + suggest="Keep subject short", + value=50, + ) + validator = SubjectLengthValidator(rule) + context = ValidationContext(stdin_text="Add feature") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_subject_length_fail(self): + """Test SubjectLengthValidator fail case.""" + rule = ValidationRule( + check="subject_max_length", + regex="", + error="Subject too long: max 10 characters", + suggest="Keep subject short", + value=10, + ) + validator = SubjectLengthValidator(rule) + context = ValidationContext(stdin_text="This is a very long subject line") + + with patch.object(validator, "_print_failure") as mock_print: + result = validator.validate(context) + assert result == ValidationResult.FAIL + mock_print.assert_called_once() + + +class TestValidationEngine: + def test_validation_engine_creation(self): + """Test ValidationEngine creation.""" + rules = [ + ValidationRule( + check="message", + regex="^(feat|fix):", + error="Invalid commit message", + suggest="Use conventional format", + ) + ] + engine = ValidationEngine(rules) + assert engine.rules == rules + + def test_validation_engine_validator_map(self): + """Test ValidationEngine VALIDATOR_MAP contains expected mappings.""" + engine = ValidationEngine([]) + + expected_mappings = { + "message": CommitMessageValidator, + "subject_capitalized": SubjectCapitalizationValidator, + "imperative": SubjectImperativeValidator, + "subject_max_length": SubjectLengthValidator, + "subject_min_length": SubjectLengthValidator, + "author_name": AuthorValidator, + "author_email": AuthorValidator, + "allow_authors": AuthorValidator, + "ignore_authors": AuthorValidator, + "branch": BranchValidator, + "merge_base": MergeBaseValidator, + "require_signed_off_by": SignoffValidator, + "require_body": BodyValidator, + "allow_merge_commits": CommitTypeValidator, + "allow_revert_commits": CommitTypeValidator, + "allow_empty_commits": CommitTypeValidator, + "allow_fixup_commits": CommitTypeValidator, + "allow_wip_commits": CommitTypeValidator, + } + + for check, validator_class in expected_mappings.items(): + assert engine.VALIDATOR_MAP[check] == validator_class + + def test_validation_engine_validate_all_pass(self): + """Test ValidationEngine validate_all with all passing rules.""" + rules = [ + ValidationRule( + check="message", + regex="^(feat|fix):", + error="Invalid commit message", + suggest="Use conventional format", + ) + ] + engine = ValidationEngine(rules) + context = ValidationContext(stdin_text="feat: add new feature") + + with patch("commit_check.engine.has_commits", return_value=True): + result = engine.validate_all(context) + assert result == ValidationResult.PASS + + def test_validation_engine_validate_all_fail(self): + """Test ValidationEngine validate_all with failing rule.""" + rules = [ + ValidationRule( + check="message", + regex="^(feat|fix):", + error="Invalid commit message", + suggest="Use conventional format", + ) + ] + engine = ValidationEngine(rules) + context = ValidationContext(stdin_text="bad commit message") + + with patch("commit_check.engine.has_commits", return_value=True): + result = engine.validate_all(context) + assert result == ValidationResult.FAIL + + def test_validation_engine_unknown_validator(self): + """Test ValidationEngine with unknown validator type.""" + rules = [ + ValidationRule( + check="unknown_check", + regex="", + error="Unknown error", + suggest="Unknown suggest", + ) + ] + engine = ValidationEngine(rules) + context = ValidationContext(stdin_text="test") + + # Should skip unknown validators and continue + result = engine.validate_all(context) + assert result == ValidationResult.PASS diff --git a/tests/engine_test.py b/tests/engine_test.py new file mode 100644 index 00000000..846d9e01 --- /dev/null +++ b/tests/engine_test.py @@ -0,0 +1,391 @@ +"""Tests for commit_check.engine module.""" + +import pytest +import tempfile +import os +from unittest.mock import patch +from commit_check.engine import ( + ValidationResult, + ValidationContext, + BaseValidator, + ValidationEngine, + CommitMessageValidator, + BranchValidator, + AuthorValidator, + CommitTypeValidator, + SubjectImperativeValidator, + SubjectLengthValidator, + SignoffValidator, + SubjectCapitalizationValidator, + BodyValidator, + MergeBaseValidator, +) +from commit_check.rule_builder import ValidationRule + + +class TestValidationResult: + def test_validation_result_enum(self): + """Test ValidationResult enum values.""" + assert ValidationResult.PASS.value == 0 + assert ValidationResult.FAIL.value == 1 + + +class TestValidationContext: + def test_validation_context_creation(self): + """Test ValidationContext creation and properties.""" + context = ValidationContext( + stdin_text="test message", commit_file="/path/to/commit" + ) + assert context.stdin_text == "test message" + assert context.commit_file == "/path/to/commit" + + def test_validation_context_defaults(self): + """Test ValidationContext with default values.""" + context = ValidationContext() + assert context.stdin_text is None + assert context.commit_file is None + + +class TestBaseValidator: + def test_base_validator_is_abstract(self): + """Test that BaseValidator cannot be instantiated directly.""" + with pytest.raises(TypeError): + BaseValidator() + + +class TestCommitMessageValidator: + def test_commit_message_validator_valid_conventional_commit(self): + """Test CommitMessageValidator with valid conventional commit.""" + rule = ValidationRule( + check="message", + pattern=r"^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+", + ) + validator = CommitMessageValidator(rule) + context = ValidationContext(stdin_text="feat: add new feature") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_commit_message_validator_invalid_commit(self): + """Test CommitMessageValidator with invalid commit message.""" + rule = ValidationRule( + check="message", + pattern=r"^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+", + ) + validator = CommitMessageValidator(rule) + context = ValidationContext(stdin_text="invalid commit message") + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + def test_commit_message_validator_with_file(self): + """Test CommitMessageValidator reading from file.""" + rule = ValidationRule(check="message", pattern=r"^(feat|fix):") + validator = CommitMessageValidator(rule) + + with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: + f.write("fix: resolve issue") + f.flush() + + try: + context = ValidationContext(commit_file=f.name) + result = validator.validate(context) + assert result == ValidationResult.PASS + finally: + os.unlink(f.name) + + def test_commit_message_validator_file_not_found(self): + """Test CommitMessageValidator with non-existent file.""" + rule = ValidationRule(check="message", pattern=r"^feat:") + validator = CommitMessageValidator(rule) + context = ValidationContext(commit_file="/nonexistent/file") + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + @patch("commit_check.util.get_commit_info") + def test_commit_message_validator_from_git(self, mock_get_commit_info): + """Test CommitMessageValidator reading from git.""" + mock_get_commit_info.return_value = "feat: add feature from git" + + rule = ValidationRule(check="message", pattern=r"^feat:") + validator = CommitMessageValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.PASS + mock_get_commit_info.assert_called_once_with("s") + + +class TestBranchValidator: + @patch("commit_check.util.get_branch_name") + def test_branch_validator_valid_branch(self, mock_get_branch_name): + """Test BranchValidator with valid branch name.""" + mock_get_branch_name.return_value = "feature/new-feature" + + rule = ValidationRule(check="branch", pattern=r"^(feature|bugfix|hotfix)/.+") + validator = BranchValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.PASS + + @patch("commit_check.util.get_branch_name") + def test_branch_validator_invalid_branch(self, mock_get_branch_name): + """Test BranchValidator with invalid branch name.""" + mock_get_branch_name.return_value = "invalid-branch-name" + + rule = ValidationRule(check="branch", pattern=r"^(feature|bugfix|hotfix)/.+") + validator = BranchValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + +class TestAuthorValidator: + @patch("commit_check.util.get_commit_info") + def test_author_validator_name_valid(self, mock_get_commit_info): + """Test AuthorValidator for author name.""" + mock_get_commit_info.return_value = "John Doe" + + rule = ValidationRule(check="author_name", pattern=r"^[A-Z][a-z]+ [A-Z][a-z]+$") + validator = AuthorValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.PASS + mock_get_commit_info.assert_called_once_with("an") + + @patch("commit_check.util.get_commit_info") + def test_author_validator_email_valid(self, mock_get_commit_info): + """Test AuthorValidator for author email.""" + mock_get_commit_info.return_value = "john.doe@example.com" + + rule = ValidationRule( + check="author_email", + pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + ) + validator = AuthorValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.PASS + mock_get_commit_info.assert_called_once_with("ae") + + +class TestCommitTypeValidator: + def test_commit_type_validator_merge_commits(self): + """Test CommitTypeValidator with merge commits.""" + rule = ValidationRule(check="allow_merge_commits", allow=True) + validator = CommitTypeValidator(rule) + context = ValidationContext(stdin_text="Merge branch 'feature' into main") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_commit_type_validator_revert_commits(self): + """Test CommitTypeValidator with revert commits.""" + rule = ValidationRule(check="allow_revert_commits", allow=True) + validator = CommitTypeValidator(rule) + context = ValidationContext(stdin_text='Revert "feat: add feature"') + + result = validator.validate(context) + assert result == ValidationResult.PASS + + +class TestSubjectImperativeValidator: + def test_imperative_validator_valid_imperative(self): + """Test SubjectImperativeValidator with valid imperative mood.""" + rule = ValidationRule(check="imperative") + validator = SubjectImperativeValidator(rule) + context = ValidationContext(stdin_text="feat: add new feature") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_imperative_validator_invalid_imperative(self): + """Test SubjectImperativeValidator with non-imperative mood.""" + rule = ValidationRule(check="imperative") + validator = SubjectImperativeValidator(rule) + context = ValidationContext(stdin_text="feat: added new feature") + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + +class TestSubjectLengthValidator: + def test_subject_length_validator_max_valid(self): + """Test SubjectLengthValidator with valid max length.""" + rule = ValidationRule(check="subject_max_length", length=50) + validator = SubjectLengthValidator(rule) + context = ValidationContext(stdin_text="feat: short message") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_subject_length_validator_max_too_long(self): + """Test SubjectLengthValidator with message too long.""" + rule = ValidationRule(check="subject_max_length", length=20) + validator = SubjectLengthValidator(rule) + context = ValidationContext( + stdin_text="feat: this is a very long commit message that exceeds the limit" + ) + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + def test_subject_length_validator_min_valid(self): + """Test SubjectLengthValidator with valid min length.""" + rule = ValidationRule(check="subject_min_length", length=10) + validator = SubjectLengthValidator(rule) + context = ValidationContext(stdin_text="feat: add feature") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_subject_length_validator_min_too_short(self): + """Test SubjectLengthValidator with message too short.""" + rule = ValidationRule(check="subject_min_length", length=20) + validator = SubjectLengthValidator(rule) + context = ValidationContext(stdin_text="feat: fix") + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + +class TestSignoffValidator: + def test_signoff_validator_valid(self): + """Test SignoffValidator with valid signoff.""" + rule = ValidationRule(check="require_signed_off_by") + validator = SignoffValidator(rule) + context = ValidationContext( + stdin_text="feat: add feature\n\nSigned-off-by: John Doe " + ) + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_signoff_validator_missing_signoff(self): + """Test SignoffValidator with missing signoff.""" + rule = ValidationRule(check="require_signed_off_by") + validator = SignoffValidator(rule) + context = ValidationContext(stdin_text="feat: add feature") + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + +class TestSubjectCapitalizationValidator: + def test_subject_capitalization_validator_valid(self): + """Test SubjectCapitalizationValidator with capitalized subject.""" + rule = ValidationRule(check="subject_capitalized") + validator = SubjectCapitalizationValidator(rule) + context = ValidationContext(stdin_text="feat: Add new feature") + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_subject_capitalization_validator_not_capitalized(self): + """Test SubjectCapitalizationValidator with non-capitalized subject.""" + rule = ValidationRule(check="subject_capitalized") + validator = SubjectCapitalizationValidator(rule) + context = ValidationContext(stdin_text="feat: add new feature") + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + +class TestBodyValidator: + def test_body_validator_with_body(self): + """Test BodyValidator with commit body.""" + rule = ValidationRule(check="require_body") + validator = BodyValidator(rule) + context = ValidationContext( + stdin_text="feat: add feature\n\nThis is the commit body" + ) + + result = validator.validate(context) + assert result == ValidationResult.PASS + + def test_body_validator_no_body(self): + """Test BodyValidator without commit body.""" + rule = ValidationRule(check="require_body") + validator = BodyValidator(rule) + context = ValidationContext(stdin_text="feat: add feature") + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + +class TestMergeBaseValidator: + @patch("commit_check.util.git_merge_base") + def test_merge_base_validator_valid(self, mock_git_merge_base): + """Test MergeBaseValidator with valid merge base.""" + mock_git_merge_base.return_value = 0 + + rule = ValidationRule(check="merge_base") + validator = MergeBaseValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.PASS + + @patch("commit_check.util.git_merge_base") + def test_merge_base_validator_invalid(self, mock_git_merge_base): + """Test MergeBaseValidator with invalid merge base.""" + mock_git_merge_base.return_value = 1 + + rule = ValidationRule(check="merge_base") + validator = MergeBaseValidator(rule) + context = ValidationContext() + + result = validator.validate(context) + assert result == ValidationResult.FAIL + + +class TestValidationEngine: + def test_validation_engine_creation(self): + """Test ValidationEngine creation.""" + rules = [ + ValidationRule(check="message", pattern=r"^feat:"), + ValidationRule(check="branch", pattern=r"^feature/"), + ] + engine = ValidationEngine(rules) + assert len(engine.validators) == 2 + + def test_validation_engine_validate_all_pass(self): + """Test ValidationEngine with all validations passing.""" + rules = [ValidationRule(check="message", pattern=r"^feat:")] + engine = ValidationEngine(rules) + context = ValidationContext(stdin_text="feat: add feature") + + result = engine.validate_all(context) + assert result == ValidationResult.PASS + + def test_validation_engine_validate_all_fail(self): + """Test ValidationEngine with some validations failing.""" + rules = [ + ValidationRule(check="message", pattern=r"^feat:"), + ValidationRule(check="message", pattern=r"^fix:"), # This will fail + ] + engine = ValidationEngine(rules) + context = ValidationContext(stdin_text="feat: add feature") + + result = engine.validate_all(context) + assert result == ValidationResult.FAIL + + def test_validation_engine_empty_rules(self): + """Test ValidationEngine with no rules.""" + engine = ValidationEngine([]) + context = ValidationContext() + + result = engine.validate_all(context) + assert result == ValidationResult.PASS + + def test_validation_engine_unknown_validator_type(self): + """Test ValidationEngine with unknown validator type.""" + rules = [ValidationRule(check="unknown_check")] + + with pytest.raises(ValueError, match="Unknown validator type: unknown_check"): + ValidationEngine(rules) diff --git a/tests/error_test.py b/tests/error_test.py deleted file mode 100644 index 51da7f62..00000000 --- a/tests/error_test.py +++ /dev/null @@ -1,78 +0,0 @@ -import os -import pytest -from commit_check.error import error_handler, log_and_exit - - -@pytest.mark.benchmark -def test_error_handler_RuntimeError(): - with pytest.raises(SystemExit) as exit_info: - with error_handler(): - raise RuntimeError("Test error") - assert exit_info.value.code == 1 - - -@pytest.mark.benchmark -def test_error_handler_KeyboardInterrupt(): - with pytest.raises(SystemExit) as exit_info: - with error_handler(): - raise KeyboardInterrupt - assert exit_info.value.code == 130 - - -@pytest.mark.benchmark -def test_error_handler_unexpected_error(): - with pytest.raises(SystemExit) as exit_info: - with error_handler(): - raise Exception("Test error") - assert exit_info.value.code == 3 - - -@pytest.mark.benchmark -def test_error_handler_cannot_access(mocker): - with pytest.raises(SystemExit): - store_dir = "/fake/commit-check" - log_path = os.path.join(store_dir, "commit-check.log") - mocker.patch.dict(os.environ, {"COMMIT_CHECK_HOME": store_dir}) - mock_os_access = mocker.patch("os.access", return_value=False) - mocker.patch("os.path.exists", return_value=True) - mocker.patch("os.makedirs") - mock_open = mocker.patch("builtins.open", mocker.mock_open()) - mocker.patch("commit_check.util.cmd_output", return_value="mock_version") - mocker.patch("sys.version", "Mock Python Version") - mocker.patch("sys.executable", "/mock/path/to/python") - - from commit_check.error import log_and_exit - - log_and_exit( - msg="Test error message", - ret_code=1, - exc=ValueError("Test exception"), - formatted="Mocked formatted stack trace", - ) - - mock_os_access.assert_called_once_with(store_dir, os.W_OK) - mock_open.assert_called_with(log_path, "a") - mock_open().write.assert_any_call(f"Failed to write to log at {log_path}\n") - - -@pytest.mark.benchmark -@pytest.mark.xfail -def test_log_and_exit(monkeypatch): - monkeypatch.setenv("COMMIT_CHECK_HOME", "") - monkeypatch.setenv("XDG_CACHE_HOME", "") - - error_msg = "Test error message" - ret_code = 123 - exc = Exception("Test error") - formatted = "Test formatted traceback" - - log_and_exit(error_msg, ret_code, exc, formatted) - - log_path = os.path.expanduser("~/.cache/commit-check/commit-check.log") - with open(log_path) as file: - log_content = file.read() - - assert error_msg in log_content - assert str(ret_code) in log_content - assert str(exc) in log_content - assert formatted in log_content diff --git a/tests/main_test.py b/tests/main_test.py index c53647b6..ace3a6e1 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -1,41 +1,13 @@ import sys import pytest +import tempfile +import os from commit_check.main import main -from commit_check import PASS, FAIL, DEFAULT_CONFIG CMD = "commit-check" class TestMain: - def test_commit_invokes_expected_checks(self, mocker): - """Given a config with several check types, ensure each dispatcher target is invoked exactly once.""" - mocker.patch( - "commit_check.main.validate_config", - return_value={ - "checks": [ - {"check": "message"}, - {"check": "author_name"}, - {"check": "author_email"}, - {"check": "signoff"}, - {"check": "imperative"}, - ] - }, - ) - m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) - m_author = mocker.patch("commit_check.author.check_author", return_value=PASS) - m_signoff = mocker.patch("commit_check.commit.check_signoff", return_value=PASS) - m_imperative = mocker.patch( - "commit_check.commit.check_imperative", return_value=PASS - ) - # Use flags for each check instead of deprecated 'commit' subcommand - sys.argv = [CMD, "-m", "-n", "-e", "-s", "-i"] - assert main() == PASS - assert m_msg.call_count == 1 - # author_name + author_email => 2 invocations - assert m_author.call_count == 2 - assert m_signoff.call_count == 1 - assert m_imperative.call_count == 1 - def test_help(self, capfd): sys.argv = [CMD, "--help"] with pytest.raises(SystemExit): @@ -49,74 +21,85 @@ def test_version(self): with pytest.raises(SystemExit): main() - def test_default_config_used_when_validate_returns_empty(self, mocker): - mocker.patch("commit_check.main.validate_config", return_value={}) - m_msg = mocker.patch("commit_check.commit.check_commit_msg", return_value=PASS) - - mocker.patch("commit_check.author.check_author", return_value=PASS) - mocker.patch("commit_check.commit.check_signoff", return_value=PASS) - mocker.patch("commit_check.commit.check_imperative", return_value=PASS) - sys.argv = [CMD, "-m", "-n", "-e", "-s", "-i"] - main() - # first positional arg to check_commit_msg is the list of checks - assert m_msg.call_args[0][0] == DEFAULT_CONFIG["checks"] - - @pytest.mark.parametrize( - "message_result, author_name_result, author_email_result, signoff_result, imperative_result, expected", - [ - (PASS, PASS, PASS, PASS, PASS, PASS), - (FAIL, PASS, PASS, PASS, PASS, FAIL), - (PASS, FAIL, PASS, PASS, PASS, FAIL), - (PASS, PASS, FAIL, PASS, PASS, FAIL), - (PASS, PASS, PASS, FAIL, PASS, FAIL), - (PASS, PASS, PASS, PASS, FAIL, FAIL), - ], - ) - def test_exit_code_aggregation( - self, - mocker, - message_result, - author_name_result, - author_email_result, - signoff_result, - imperative_result, - expected, - ): - # configure all check types - mocker.patch( - "commit_check.main.validate_config", - return_value={ - "checks": [ - {"check": "message"}, - {"check": "author_name"}, - {"check": "author_email"}, - {"check": "signoff"}, - {"check": "imperative"}, - ] - }, - ) + def test_no_args_shows_help(self, capfd): + """When no arguments are provided, should show help and exit 0.""" + sys.argv = [CMD] + assert main() == 0 + + def test_message_validation_with_valid_commit(self, mocker): + """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"] + assert main() == 0 + def test_message_validation_with_invalid_commit(self, mocker): + """Test that an invalid commit message fails validation.""" + # 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"] + assert main() == 1 + + def test_message_validation_from_file(self): + """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] + assert main() == 0 + finally: + os.unlink(f.name) + + def test_branch_validation(self, mocker): + """Test branch name validation.""" + # Mock git command to return a valid branch name mocker.patch( - "commit_check.commit.check_commit_msg", return_value=message_result + "subprocess.run", + return_value=type( + "MockResult", (), {"stdout": "feature/test-branch", "returncode": 0} + )(), ) - def author_side_effect(_, which, **_kw): # type: ignore[return] - return author_name_result if which == "author_name" else author_email_result + sys.argv = [CMD, "-b"] + assert main() == 0 - mocker.patch("commit_check.author.check_author", side_effect=author_side_effect) - mocker.patch("commit_check.commit.check_signoff", return_value=signoff_result) + def test_author_name_validation(self, mocker): + """Test author name validation.""" + # Mock git command to return a valid author name mocker.patch( - "commit_check.commit.check_imperative", return_value=imperative_result + "subprocess.run", + return_value=type( + "MockResult", (), {"stdout": "John Doe", "returncode": 0} + )(), ) - sys.argv = [CMD, "-m", "-n", "-e", "-s", "-i"] - assert main() == expected - def test_unknown_check_type_ignored(self, mocker): + sys.argv = [CMD, "-n"] + assert main() == 0 + + def test_author_email_validation(self, mocker): + """Test author email validation.""" + # Mock git command to return a valid author email mocker.patch( - "commit_check.main.validate_config", - return_value={"checks": [{"check": "totally_unknown"}]}, + "subprocess.run", + return_value=type( + "MockResult", (), {"stdout": "john.doe@example.com", "returncode": 0} + )(), ) - # no dispatcher functions patched intentionally - # No flags: unknown check type should simply be ignored, resulting in PASS (no executed checks) - sys.argv = [CMD] - assert main() == PASS + + sys.argv = [CMD, "-e"] + assert main() == 0 + + def test_dry_run_always_passes(self, mocker): + """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"] + assert main() == 0 diff --git a/tests/rule_builder_test.py b/tests/rule_builder_test.py new file mode 100644 index 00000000..66da895b --- /dev/null +++ b/tests/rule_builder_test.py @@ -0,0 +1,237 @@ +"""Tests for commit_check.rule_builder module.""" + +from commit_check.rule_builder import ValidationRule, RuleBuilder +from commit_check.rules_catalog import RuleCatalogEntry + + +class TestValidationRule: + def test_validation_rule_creation(self): + """Test ValidationRule creation with all fields.""" + rule = ValidationRule( + check="test_check", + regex=r"^test:", + error="Test error", + suggest="Test suggestion", + value=42, + allowed=["allowed1", "allowed2"], + ignored=["ignored1", "ignored2"], + ) + + assert rule.check == "test_check" + assert rule.regex == r"^test:" + assert rule.error == "Test error" + assert rule.suggest == "Test suggestion" + assert rule.value == 42 + assert rule.allowed == ["allowed1", "allowed2"] + assert rule.ignored == ["ignored1", "ignored2"] + + def test_validation_rule_to_dict_with_allowed(self): + """Test ValidationRule.to_dict with allowed field (line 34).""" + rule = ValidationRule( + check="allow_authors", + regex="", + error="Author not allowed", + suggest="Use allowed author", + allowed=["alice@example.com", "bob@example.com"], + ) + + result = rule.to_dict() + expected = { + "check": "allow_authors", + "regex": "", + "error": "Author not allowed", + "suggest": "Use allowed author", + "allowed": ["alice@example.com", "bob@example.com"], + "allowed_types": [ + "alice@example.com", + "bob@example.com", + ], # Backward compatibility + } + assert result == expected + + def test_validation_rule_to_dict_with_ignored(self): + """Test ValidationRule.to_dict() method with ignored field.""" + rule = ValidationRule(check="test_check", ignored=["ignored1", "ignored2"]) + + result = rule.to_dict() + assert result["ignored"] == ["ignored1", "ignored2"] + + +class TestRuleBuilder: + def test_rule_builder_conventional_commits_disabled(self): + """Test RuleBuilder when conventional_commits is disabled (line 115).""" + config = {"commit": {"conventional_commits": False}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="message", regex="", error="", suggest="" + ) + + # This should return None when conventional_commits is False + rule = builder._build_conventional_commit_rule(catalog_entry) + assert rule is None + + def test_rule_builder_conventional_branch_disabled(self): + """Test RuleBuilder when conventional_branch is disabled (line 133).""" + config = {"branch": {"conventional_branch": False}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry(check="branch", regex="", error="", suggest="") + + # This should return None when conventional_branch is False + rule = builder._build_conventional_branch_rule(catalog_entry) + assert rule is None + + def test_rule_builder_allow_authors_list(self): + """Test RuleBuilder with allow_authors list (line 176).""" + config = {"commit": {"allow_authors": ["alice@example.com", "bob@example.com"]}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="allow_authors", + regex="", + error="Author not allowed", + suggest="Use allowed author", + ) + + # This should create a rule with allowed authors + rule = builder._build_author_list_rule(catalog_entry, "allow_authors") + assert rule is not None + assert rule.check == "allow_authors" + assert rule.allowed == ["alice@example.com", "bob@example.com"] + assert rule.error == "Author not allowed" + assert rule.suggest == "Use allowed author" + + def test_rule_builder_ignore_authors_list(self): + """Test RuleBuilder with ignore_authors list.""" + config = {"commit": {"ignore_authors": ["spam@example.com", "bot@example.com"]}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="ignore_authors", + regex="", + error="Author ignored", + suggest="Use different author", + ) + + # This should create a rule with ignored authors + rule = builder._build_author_list_rule(catalog_entry, "ignore_authors") + assert rule is not None + assert rule.check == "ignore_authors" + assert rule.ignored == ["spam@example.com", "bot@example.com"] + + def test_rule_builder_empty_author_list(self): + """Test RuleBuilder with empty author list returns None.""" + config = {"commit": {"allow_authors": []}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="allow_authors", regex="", error="", suggest="" + ) + + # This should return None for empty list + rule = builder._build_author_list_rule(catalog_entry, "allow_authors") + assert rule is None + + def test_rule_builder_missing_author_list(self): + """Test RuleBuilder with missing author list returns None.""" + config = {"commit": {}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="allow_authors", regex="", error="", suggest="" + ) + + # This should return None for missing config + rule = builder._build_author_list_rule(catalog_entry, "allow_authors") + assert rule is None + + def test_rule_builder_invalid_author_list_type(self): + """Test RuleBuilder with invalid author list type returns None.""" + config = {"commit": {"allow_authors": "not_a_list"}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="allow_authors", regex="", error="", suggest="" + ) + + # This should return None for invalid type + rule = builder._build_author_list_rule(catalog_entry, "allow_authors") + assert rule is None + + def test_rule_builder_length_rule_with_format(self): + """Test RuleBuilder length rule with formatted error message (lines 154-160).""" + config = {"commit": {"max_length": 50}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="max_length", + regex="", + error="Message too long: max {max_len} characters", + suggest="Keep message short", + ) + + # This should create a rule with formatted error + rule = builder._build_length_rule(catalog_entry, "max_length") + assert rule is not None + assert rule.check == "max_length" + assert rule.error == "Message too long: max 50 characters" + assert rule.suggest == "Keep message short" + assert rule.value == 50 + + def test_rule_builder_merge_base_rule_valid_target(self): + """Test RuleBuilder merge base rule with valid target (line 193).""" + config = {"branch": {"require_rebase_target": "main"}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="require_rebase_target", + regex="", + error="Branch must be rebased on target", + suggest="Rebase on target branch", + ) + + # This should create a rule with regex target + rule = builder._build_merge_base_rule(catalog_entry) + assert rule is not None + assert rule.check == "require_rebase_target" + assert rule.regex == "main" + assert rule.error == "Branch must be rebased on target" + assert rule.suggest == "Rebase on target branch" + + def test_rule_builder_boolean_rule_enabled(self): + """Test RuleBuilder boolean rule when enabled (line 236).""" + config = {"commit": {"require_signed_off_by": True}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="require_signed_off_by", + regex="^Signed-off-by:", + error="Missing signoff", + suggest="Add Signed-off-by line", + ) + + # This should create a rule when boolean is True + rule = builder._build_boolean_rule(catalog_entry, builder.commit_config) + assert rule is not None + assert rule.check == "require_signed_off_by" + assert rule.regex == "^Signed-off-by:" + assert rule.error == "Missing signoff" + assert rule.suggest == "Add Signed-off-by line" + assert rule.value is True + + def test_rule_builder_boolean_rule_subject_disabled(self): + """Test RuleBuilder boolean rule for subject checks when disabled (line 232).""" + config = {"commit": {"subject_capitalized": False}} + + builder = RuleBuilder(config) + catalog_entry = RuleCatalogEntry( + check="subject_capitalized", + regex="^[A-Z]", + error="Subject must be capitalized", + suggest="Capitalize first letter", + ) + + # This should return None when subject_capitalized is False (line 232) + rule = builder._build_boolean_rule(catalog_entry, builder.commit_config) + assert rule is None From 699f43ce19243366f004bd2f1fd2b2f88965013e Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 3 Oct 2025 02:30:59 +0300 Subject: [PATCH 29/37] chore: update conf.py --- commit_check/rule_builder.py | 2 ++ docs/conf.py | 25 ++++++++++++++++++++----- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/commit_check/rule_builder.py b/commit_check/rule_builder.py index 85dc8a07..56a115c2 100644 --- a/commit_check/rule_builder.py +++ b/commit_check/rule_builder.py @@ -38,7 +38,9 @@ def to_dict(self) -> Dict[str, Any]: class RuleBuilder: """Builds validation rules from config and catalog entries.""" + # Follow conventional commits DEFAULT_COMMIT_TYPES = ["feat", "fix", "docs", "style", "refactor", "test", "chore"] + # Follow conventional branch DEFAULT_BRANCH_TYPES = [ "feature", "bugfix", diff --git a/docs/conf.py b/docs/conf.py index c2208929..91c9db92 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -109,12 +109,27 @@ def setup(app: Sphinx): encoding="utf-8", ) doc = "commit-check --help\n==============================\n\n" - CLI_OPT_NAME = re.compile(r"^\s*(\-\w)\s?[A-Z_]*,\s(\-\-.*?)\s") + CLI_OPT_NAME = re.compile(r"^\s*(\-\w)(?:\s+[A-Z_\[\]]*)?(?:,\s+(\-\-[a-z\-]+))?") + in_options_section = False + for line in result.stdout.splitlines(): - match = CLI_OPT_NAME.search(line) - if match is not None: - # print(match.groups()) - doc += "\n.. std:option:: " + ", ".join(match.groups()) + "\n\n" + # Start processing options when we see the "options:" line + if line.strip() == "options:": + in_options_section = True + doc += line + "\n" + continue + + # Only process option patterns in the options section + if in_options_section: + match = CLI_OPT_NAME.search(line) + if match is not None: + short_opt = match.group(1) + long_opt = match.group(2) + if short_opt and long_opt: + doc += "\n.. std:option:: " + short_opt + ", " + long_opt + "\n\n" + elif short_opt: + doc += "\n.. std:option:: " + short_opt + "\n\n" + doc += line + "\n" cli_doc = Path(app.srcdir, "cli_args.rst") cli_doc.unlink(missing_ok=True) From 6b6c2e26bbe860156b0a49919dc91dee5ada0e8b Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 3 Oct 2025 03:14:20 +0300 Subject: [PATCH 30/37] fix: update main.py to support stdin for other check --- commit_check/main.py | 23 +++++++++++++++++------ 1 file changed, 17 insertions(+), 6 deletions(-) diff --git a/commit_check/main.py b/commit_check/main.py index 91d42795..5aeeafef 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -183,17 +183,28 @@ def main() -> int: engine = ValidationEngine(filtered_rules) # Create validation context - message_content = None + stdin_content = None + commit_file_path = None + if ( args.message is not None ): # Check explicitly for None since empty string is valid - message_content = _get_message_content(args.message, stdin_reader) - if not message_content: - return 1 # Error message already printed in _get_message_content + if args.message == "": + # Only set stdin_content if there's actual piped input + stdin_content = stdin_reader.read_piped_input() + if not stdin_content: + # No stdin and no file - let validators get data from git themselves + stdin_content = None + else: + # Message is a file path + commit_file_path = args.message + else: + # Even if --message is not specified, check for stdin input for other validations + stdin_content = stdin_reader.read_piped_input() context = ValidationContext( - stdin_text=message_content, - commit_file=args.message if args.message and args.message != "-" else None, + stdin_text=stdin_content, + commit_file=commit_file_path, ) # Run validation From c116654e83015d3311c2649a145f9d41fe9f03c2 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 3 Oct 2025 03:59:26 +0300 Subject: [PATCH 31/37] fix: update tests --- commit_check/engine.py | 9 ++++ commit_check/main.py | 5 +- tests/config_fallback_test.py | 6 +-- tests/config_test.py | 13 ++++++ tests/engine_test.py | 88 +++++++++++++++++++++-------------- 5 files changed, 83 insertions(+), 38 deletions(-) diff --git a/commit_check/engine.py b/commit_check/engine.py index 08c9f0ab..3f17b3e5 100644 --- a/commit_check/engine.py +++ b/commit_check/engine.py @@ -108,6 +108,15 @@ def _get_subject(self, context: ValidationContext) -> str: """Extract subject from commit message.""" if context.stdin_text: return context.stdin_text.strip().split("\n")[0] + + if context.commit_file: + try: + with open(context.commit_file, "r") as f: + message = f.read().strip() + return message.split("\n")[0] + except FileNotFoundError: + pass + return get_commit_info("s") def _validate_subject(self, subject: str) -> ValidationResult: diff --git a/commit_check/main.py b/commit_check/main.py index 5aeeafef..fda2488d 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -198,8 +198,11 @@ def main() -> int: else: # Message is a file path commit_file_path = args.message + elif not any([args.branch, args.author_name, args.author_email]): + # If no specific validation type is requested, don't read stdin + pass else: - # Even if --message is not specified, check for stdin input for other validations + # For non-message validations (branch, author), check for stdin input stdin_content = stdin_reader.read_piped_input() context = ValidationContext( diff --git a/tests/config_fallback_test.py b/tests/config_fallback_test.py index 6991f812..e839bf23 100644 --- a/tests/config_fallback_test.py +++ b/tests/config_fallback_test.py @@ -18,6 +18,8 @@ def test_config_tomli_fallback_direct(): del sys.modules["commit_check.config"] # Make tomllib unavailable by raising ImportError + original_import = __import__ + def mock_import(name, globals=None, locals=None, fromlist=(), level=0): if name == "tomllib": raise ImportError("No module named 'tomllib'") @@ -36,9 +38,7 @@ def load(f): return MockTomli() return original_import(name, globals, locals, fromlist, level) - original_import = __builtins__["__import__"] - - with patch.object(__builtins__, "__import__", mock_import): + with patch("builtins.__import__", side_effect=mock_import): # Now import config - should use tomli fallback import commit_check.config as config diff --git a/tests/config_test.py b/tests/config_test.py index 5b3e55e5..e062b4ff 100644 --- a/tests/config_test.py +++ b/tests/config_test.py @@ -161,6 +161,19 @@ def test_tomli_import_fallback(self): def mock_import(name, *args, **kwargs): if name == "tomllib": raise ImportError("No module named 'tomllib'") + # For tomli, return a working mock + if name == "tomli": + + class MockTomli: + @staticmethod + def load(f): + content = f.read().decode("utf-8") + # Simple parser for test + if '[test]\nvalue = "tomli_works"' in content: + return {"test": {"value": "tomli_works"}} + return {} + + return MockTomli() return original_import(name, *args, **kwargs) with patch("builtins.__import__", side_effect=mock_import): diff --git a/tests/engine_test.py b/tests/engine_test.py index 846d9e01..81ed364e 100644 --- a/tests/engine_test.py +++ b/tests/engine_test.py @@ -58,7 +58,7 @@ def test_commit_message_validator_valid_conventional_commit(self): """Test CommitMessageValidator with valid conventional commit.""" rule = ValidationRule( check="message", - pattern=r"^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+", + regex=r"^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+", ) validator = CommitMessageValidator(rule) context = ValidationContext(stdin_text="feat: add new feature") @@ -70,7 +70,7 @@ def test_commit_message_validator_invalid_commit(self): """Test CommitMessageValidator with invalid commit message.""" rule = ValidationRule( check="message", - pattern=r"^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+", + regex=r"^(feat|fix|docs|style|refactor|test|chore)(\(.+\))?: .+", ) validator = CommitMessageValidator(rule) context = ValidationContext(stdin_text="invalid commit message") @@ -80,7 +80,7 @@ def test_commit_message_validator_invalid_commit(self): def test_commit_message_validator_with_file(self): """Test CommitMessageValidator reading from file.""" - rule = ValidationRule(check="message", pattern=r"^(feat|fix):") + rule = ValidationRule(check="message", regex=r"^(feat|fix):") validator = CommitMessageValidator(rule) with tempfile.NamedTemporaryFile(mode="w", delete=False) as f: @@ -96,25 +96,30 @@ def test_commit_message_validator_with_file(self): def test_commit_message_validator_file_not_found(self): """Test CommitMessageValidator with non-existent file.""" - rule = ValidationRule(check="message", pattern=r"^feat:") + rule = ValidationRule(check="message", regex=r"^feat:") validator = CommitMessageValidator(rule) context = ValidationContext(commit_file="/nonexistent/file") result = validator.validate(context) assert result == ValidationResult.FAIL - @patch("commit_check.util.get_commit_info") + @patch("commit_check.engine.get_commit_info") def test_commit_message_validator_from_git(self, mock_get_commit_info): """Test CommitMessageValidator reading from git.""" - mock_get_commit_info.return_value = "feat: add feature from git" + # Mock both subject ("s") and body ("b") calls + mock_get_commit_info.side_effect = lambda format_str: { + "s": "feat: add feature from git", + "b": "", + }.get(format_str, "") - rule = ValidationRule(check="message", pattern=r"^feat:") + rule = ValidationRule(check="message", regex=r"^feat:") validator = CommitMessageValidator(rule) context = ValidationContext() result = validator.validate(context) assert result == ValidationResult.PASS - mock_get_commit_info.assert_called_once_with("s") + # Should call get_commit_info twice: once for subject, once for body + assert mock_get_commit_info.call_count == 2 class TestBranchValidator: @@ -123,19 +128,19 @@ def test_branch_validator_valid_branch(self, mock_get_branch_name): """Test BranchValidator with valid branch name.""" mock_get_branch_name.return_value = "feature/new-feature" - rule = ValidationRule(check="branch", pattern=r"^(feature|bugfix|hotfix)/.+") + rule = ValidationRule(check="branch", regex=r"^(feature|bugfix|hotfix)/.+") validator = BranchValidator(rule) context = ValidationContext() result = validator.validate(context) assert result == ValidationResult.PASS - @patch("commit_check.util.get_branch_name") + @patch("commit_check.engine.get_branch_name") def test_branch_validator_invalid_branch(self, mock_get_branch_name): """Test BranchValidator with invalid branch name.""" mock_get_branch_name.return_value = "invalid-branch-name" - rule = ValidationRule(check="branch", pattern=r"^(feature|bugfix|hotfix)/.+") + rule = ValidationRule(check="branch", regex=r"^(feature|bugfix|hotfix)/.+") validator = BranchValidator(rule) context = ValidationContext() @@ -144,12 +149,12 @@ def test_branch_validator_invalid_branch(self, mock_get_branch_name): class TestAuthorValidator: - @patch("commit_check.util.get_commit_info") + @patch("commit_check.engine.get_commit_info") def test_author_validator_name_valid(self, mock_get_commit_info): """Test AuthorValidator for author name.""" mock_get_commit_info.return_value = "John Doe" - rule = ValidationRule(check="author_name", pattern=r"^[A-Z][a-z]+ [A-Z][a-z]+$") + rule = ValidationRule(check="author_name", regex=r"^[A-Z][a-z]+ [A-Z][a-z]+$") validator = AuthorValidator(rule) context = ValidationContext() @@ -157,14 +162,14 @@ def test_author_validator_name_valid(self, mock_get_commit_info): assert result == ValidationResult.PASS mock_get_commit_info.assert_called_once_with("an") - @patch("commit_check.util.get_commit_info") + @patch("commit_check.engine.get_commit_info") def test_author_validator_email_valid(self, mock_get_commit_info): """Test AuthorValidator for author email.""" mock_get_commit_info.return_value = "john.doe@example.com" rule = ValidationRule( check="author_email", - pattern=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", + regex=r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$", ) validator = AuthorValidator(rule) context = ValidationContext() @@ -177,7 +182,7 @@ def test_author_validator_email_valid(self, mock_get_commit_info): class TestCommitTypeValidator: def test_commit_type_validator_merge_commits(self): """Test CommitTypeValidator with merge commits.""" - rule = ValidationRule(check="allow_merge_commits", allow=True) + rule = ValidationRule(check="allow_merge_commits", value=True) validator = CommitTypeValidator(rule) context = ValidationContext(stdin_text="Merge branch 'feature' into main") @@ -186,7 +191,7 @@ def test_commit_type_validator_merge_commits(self): def test_commit_type_validator_revert_commits(self): """Test CommitTypeValidator with revert commits.""" - rule = ValidationRule(check="allow_revert_commits", allow=True) + rule = ValidationRule(check="allow_revert_commits", value=True) validator = CommitTypeValidator(rule) context = ValidationContext(stdin_text='Revert "feat: add feature"') @@ -217,7 +222,7 @@ def test_imperative_validator_invalid_imperative(self): class TestSubjectLengthValidator: def test_subject_length_validator_max_valid(self): """Test SubjectLengthValidator with valid max length.""" - rule = ValidationRule(check="subject_max_length", length=50) + rule = ValidationRule(check="subject_max_length", value=50) validator = SubjectLengthValidator(rule) context = ValidationContext(stdin_text="feat: short message") @@ -226,7 +231,7 @@ def test_subject_length_validator_max_valid(self): def test_subject_length_validator_max_too_long(self): """Test SubjectLengthValidator with message too long.""" - rule = ValidationRule(check="subject_max_length", length=20) + rule = ValidationRule(check="subject_max_length", value=20) validator = SubjectLengthValidator(rule) context = ValidationContext( stdin_text="feat: this is a very long commit message that exceeds the limit" @@ -237,7 +242,7 @@ def test_subject_length_validator_max_too_long(self): def test_subject_length_validator_min_valid(self): """Test SubjectLengthValidator with valid min length.""" - rule = ValidationRule(check="subject_min_length", length=10) + rule = ValidationRule(check="subject_min_length", value=10) validator = SubjectLengthValidator(rule) context = ValidationContext(stdin_text="feat: add feature") @@ -246,7 +251,7 @@ def test_subject_length_validator_min_valid(self): def test_subject_length_validator_min_too_short(self): """Test SubjectLengthValidator with message too short.""" - rule = ValidationRule(check="subject_min_length", length=20) + rule = ValidationRule(check="subject_min_length", value=20) validator = SubjectLengthValidator(rule) context = ValidationContext(stdin_text="feat: fix") @@ -257,7 +262,9 @@ def test_subject_length_validator_min_too_short(self): class TestSignoffValidator: def test_signoff_validator_valid(self): """Test SignoffValidator with valid signoff.""" - rule = ValidationRule(check="require_signed_off_by") + rule = ValidationRule( + check="require_signed_off_by", regex=r"Signed-off-by: .+ <.+@.+\..+>" + ) validator = SignoffValidator(rule) context = ValidationContext( stdin_text="feat: add feature\n\nSigned-off-by: John Doe " @@ -331,16 +338,24 @@ def test_merge_base_validator_valid(self, mock_git_merge_base): result = validator.validate(context) assert result == ValidationResult.PASS + @patch("commit_check.engine.has_commits") + @patch("commit_check.engine.get_branch_name") @patch("commit_check.util.git_merge_base") - def test_merge_base_validator_invalid(self, mock_git_merge_base): + def test_merge_base_validator_invalid( + self, mock_git_merge_base, mock_get_branch_name, mock_has_commits + ): """Test MergeBaseValidator with invalid merge base.""" + mock_has_commits.return_value = True + mock_get_branch_name.return_value = "feature/test" mock_git_merge_base.return_value = 1 - rule = ValidationRule(check="merge_base") + rule = ValidationRule(check="merge_base", regex=r"^main$") validator = MergeBaseValidator(rule) context = ValidationContext() - result = validator.validate(context) + # Mock _find_target_branch to return a target branch + with patch.object(validator, "_find_target_branch", return_value="main"): + result = validator.validate(context) assert result == ValidationResult.FAIL @@ -348,15 +363,17 @@ class TestValidationEngine: def test_validation_engine_creation(self): """Test ValidationEngine creation.""" rules = [ - ValidationRule(check="message", pattern=r"^feat:"), - ValidationRule(check="branch", pattern=r"^feature/"), + ValidationRule(check="message", regex=r"^feat:"), + ValidationRule(check="branch", regex=r"^feature/"), ] engine = ValidationEngine(rules) - assert len(engine.validators) == 2 + + assert len(engine.rules) == 2 + assert engine.rules == rules def test_validation_engine_validate_all_pass(self): """Test ValidationEngine with all validations passing.""" - rules = [ValidationRule(check="message", pattern=r"^feat:")] + rules = [ValidationRule(check="message", regex=r"^feat:")] engine = ValidationEngine(rules) context = ValidationContext(stdin_text="feat: add feature") @@ -366,8 +383,8 @@ def test_validation_engine_validate_all_pass(self): def test_validation_engine_validate_all_fail(self): """Test ValidationEngine with some validations failing.""" rules = [ - ValidationRule(check="message", pattern=r"^feat:"), - ValidationRule(check="message", pattern=r"^fix:"), # This will fail + ValidationRule(check="message", regex=r"^feat:"), + ValidationRule(check="message", regex=r"^fix:"), # This will fail ] engine = ValidationEngine(rules) context = ValidationContext(stdin_text="feat: add feature") @@ -385,7 +402,10 @@ def test_validation_engine_empty_rules(self): def test_validation_engine_unknown_validator_type(self): """Test ValidationEngine with unknown validator type.""" - rules = [ValidationRule(check="unknown_check")] + rules = [ValidationRule(check="unknown_check", regex=r".*")] + engine = ValidationEngine(rules) + context = ValidationContext() - with pytest.raises(ValueError, match="Unknown validator type: unknown_check"): - ValidationEngine(rules) + # Should not raise an error, just skip unknown validators + result = engine.validate_all(context) + assert result == ValidationResult.PASS # No validation performed = PASS From 68afa2339b2dab6dc722e6fb68a837688ac8b7e2 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 3 Oct 2025 04:07:08 +0300 Subject: [PATCH 32/37] fix: checkout branch name --- .github/workflows/main.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 160c74ae..3b425305 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -65,6 +65,9 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.ref_name }} + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.py }} From bc700e20e95bffe0af65b6c5e63872e0a617958e Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 3 Oct 2025 04:11:20 +0300 Subject: [PATCH 33/37] fix: pint action to commit hash --- .github/workflows/main.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3b425305..5fdedf37 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -48,7 +48,7 @@ jobs: - name: Collect Coverage run: nox -s coverage - - uses: codecov/codecov-action@v5.0.2 + - uses: codecov/codecov-action@5c47607acb93fed5485fdbf7232e8a31425f672a # v5.0.2 with: token: ${{ secrets.CODECOV_TOKEN }} files: ./coverage.xml @@ -113,7 +113,7 @@ jobs: - name: Upload docs to github pages # only publish doc changes from main branch if: github.event_name != 'pull_request' && github.ref == 'refs/heads/main' - uses: peaceiris/actions-gh-pages@v4 + uses: peaceiris/actions-gh-pages@4f9cc6602d3f66b9c108549d475ec49e8ef4d45e # v4.0.0 with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./_build/html From 882e39dfd67f795120f7b3b96ed0079748f14d70 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 3 Oct 2025 04:14:17 +0300 Subject: [PATCH 34/37] fix: checkout branch name --- .github/workflows/main.yml | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 5fdedf37..3cbf7cd1 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -21,6 +21,8 @@ jobs: runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 + with: + ref: ${{ github.ref_name }} - uses: actions/setup-python@v6 with: python-version: '3.x' @@ -65,9 +67,6 @@ jobs: runs-on: ${{ matrix.os }} steps: - uses: actions/checkout@v5 - with: - ref: ${{ github.ref_name }} - - uses: actions/setup-python@v6 with: python-version: ${{ matrix.py }} From b43a3ef4fe03574f1c644b982b64c025c2e9a947 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 3 Oct 2025 04:17:09 +0300 Subject: [PATCH 35/37] fix: add checkout branch name --- .github/workflows/main.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 3cbf7cd1..4a145936 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -22,7 +22,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ github.ref_name }} + ref: ${{ github.head_ref }} # get current branch name - uses: actions/setup-python@v6 with: python-version: '3.x' From 56299b14b66188dfda57bcd2b16da82191997aed Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Fri, 3 Oct 2025 04:22:47 +0300 Subject: [PATCH 36/37] fix: update session name --- .github/copilot-instructions.md | 2 +- .github/workflows/main.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 03e330a0..8325a0ce 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -27,7 +27,7 @@ nox -s build python3 -m pip wheel --no-deps -w dist . # NETWORK ISSUES: Also fails due to build dependencies # Install wheel (depends on build) -nox -s install-wheel # NETWORK ISSUES: Often fails due to PyPI timeouts in CI environments +nox -s install # NETWORK ISSUES: Often fails due to PyPI timeouts in CI environments ``` ### Testing diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4a145936..876b65b4 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -83,7 +83,7 @@ jobs: - name: Install test # using a wildcard as filename on Windows requires a bash shell shell: bash - run: nox -s install-wheel + run: nox -s install docs: runs-on: ubuntu-24.04 From 643a1a75b280fdb9598139ce8e1d83c58c9ff835 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Fri, 3 Oct 2025 09:33:38 +0000 Subject: [PATCH 37/37] fix: update per code review --- commit_check/main.py | 9 ++++++--- tests/engine_comprehensive_test.py | 5 +---- tests/util_test.py | 21 --------------------- 3 files changed, 7 insertions(+), 28 deletions(-) diff --git a/commit_check/main.py b/commit_check/main.py index fda2488d..7fbeb626 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -43,7 +43,7 @@ def _get_parser() -> argparse.ArgumentParser: parser.add_argument( "-c", "--config", - help="path to config file (cchk.toml). If not specified, searches for cchk.toml in current directory", + help="path to config file (cchk.toml or commit-check.toml). If not specified, searches for cchk.toml in current directory", ) parser.add_argument( @@ -135,8 +135,11 @@ def main() -> int: stdin_reader = StdinReader() try: - # Load configuration - config_data = load_config(args.config) + # Load configuration (fallback to defaults when no file found) + try: + config_data = load_config(args.config) + except FileNotFoundError: + config_data = {} # Build validation rules from config rule_builder = RuleBuilder(config_data) diff --git a/tests/engine_comprehensive_test.py b/tests/engine_comprehensive_test.py index cfb8538f..a8d3f6ca 100644 --- a/tests/engine_comprehensive_test.py +++ b/tests/engine_comprehensive_test.py @@ -52,11 +52,8 @@ def test_commit_message_validator_creation(self): validator = CommitMessageValidator(rule) assert validator.rule == rule - @patch("commit_check.engine.get_commit_info") @patch("commit_check.engine.has_commits") - def test_commit_message_validator_with_stdin( - self, mock_has_commits, mock_get_commit_info - ): + def test_commit_message_validator_with_stdin(self, mock_has_commits): """Test CommitMessageValidator with stdin text.""" mock_has_commits.return_value = True diff --git a/tests/util_test.py b/tests/util_test.py index 9011911d..b5870550 100644 --- a/tests/util_test.py +++ b/tests/util_test.py @@ -5,7 +5,6 @@ from commit_check.util import git_merge_base from commit_check.util import get_commit_info from commit_check.util import cmd_output -from commit_check.util import validate_config from commit_check.util import print_error_header from commit_check.util import print_error_message from commit_check.util import print_suggestion @@ -250,26 +249,6 @@ def test_cmd_output_err_with_len0_stderr( "stdout": PIPE, } - class TestValidateConfig: - @pytest.mark.benchmark - def test_validate_config(self, mocker): - # Must call yaml.safe_load. - mocker.patch("builtins.open") - dummy_resp = {"key": "value"} - m_yaml_safe_load = mocker.patch("yaml.safe_load", return_value=dummy_resp) - retval = validate_config("dummy_path") - assert m_yaml_safe_load.call_count == 1 - assert retval == dummy_resp - - @pytest.mark.benchmark - def test_validate_config_file_not_found(self, mocker): - # Must return empty dictionary when FileNotFoundError raises in built-in open. - mocker.patch("builtins.open").side_effect = FileNotFoundError - m_yaml_safe_load = mocker.patch("yaml.safe_load") - retval = validate_config("dummy_path") - assert m_yaml_safe_load.call_count == 0 - assert retval == {} - class TestPrintErrorMessage: @pytest.mark.benchmark def test_print_error_header(self, capfd):