From de4bf2d3f1f3bb820da445d35adcae1e65623fee Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 1 Feb 2026 20:44:13 +0200 Subject: [PATCH 1/4] feat: Enhance configuration management with CLI, env vars, and TOML support - Introduced a new ConfigMerger class to handle merging configurations from multiple sources: command-line arguments, environment variables, TOML files, and defaults. - Added command-line options for various commit and branch configurations, including subject length, imperative mood, and allowed commit types. - Updated README and configuration documentation to reflect new configuration methods and examples. - Implemented comprehensive tests for CLI argument integration, environment variable handling, and configuration priority. - Ensured backward compatibility with existing configuration files while providing enhanced flexibility for users. --- .pre-commit-config.yaml | 2 +- README.rst | 37 +++- commit_check/config_merger.py | 261 ++++++++++++++++++++++++ commit_check/main.py | 160 ++++++++++++++- docs/configuration.rst | 184 +++++++++++++++++ tests/config_merger_test.py | 367 ++++++++++++++++++++++++++++++++++ tests/main_test.py | 155 ++++++++++++++ 7 files changed, 1160 insertions(+), 6 deletions(-) create mode 100644 commit_check/config_merger.py create mode 100644 tests/config_merger_test.py diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 9f0fd244..000e078d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -31,7 +31,7 @@ repos: hooks: - id: codespell - repo: https://github.com/commit-check/commit-check - rev: v2.2.2 + rev: v2.3.0 hooks: - id: check-message stages: [commit-msg] diff --git a/README.rst b/README.rst index 53cf03a1..714907f0 100644 --- a/README.rst +++ b/README.rst @@ -70,6 +70,12 @@ For more information, see the `docs `_ specification and branch names follow the `Conventional Branch `_ convention. -Use Custom Configuration -~~~~~~~~~~~~~~~~~~~~~~~~ +Use Custom Configuration File +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ To customize the behavior, create a configuration file named ``cchk.toml`` or ``commit-check.toml`` in your repository's root directory or in the ``.github`` folder, e.g., `cchk.toml `_ or ``.github/cchk.toml``. +Use CLI Arguments or Environment Variables +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For one-off checks or CI/CD pipelines, you can configure via CLI arguments or environment variables: + +.. code-block:: bash + + # Using CLI arguments + commit-check --message --subject-imperative=true --subject-max-length=72 + + # Using environment variables + export CCHK_SUBJECT_IMPERATIVE=true + export CCHK_SUBJECT_MAX_LENGTH=72 + commit-check --message + + # In pre-commit hooks (.pre-commit-config.yaml) + repos: + - repo: https://github.com/commit-check/commit-check + rev: v2.2.0 + hooks: + - id: commit-check + args: + - --subject-imperative=false + - --subject-max-length=100 + +See the `Configuration documentation `_ for all available options. + Usage ----- diff --git a/commit_check/config_merger.py b/commit_check/config_merger.py new file mode 100644 index 00000000..17a578d7 --- /dev/null +++ b/commit_check/config_merger.py @@ -0,0 +1,261 @@ +"""Configuration merger that combines CLI args, env vars, TOML config, and defaults.""" + +from __future__ import annotations +import os +import argparse +from typing import Dict, Any, Optional, List, Callable, Tuple + +from commit_check.config import load_config as load_toml_config +from commit_check import ( + DEFAULT_COMMIT_TYPES, + DEFAULT_BRANCH_TYPES, + DEFAULT_BRANCH_NAMES, + DEFAULT_BOOLEAN_RULES, +) + + +def parse_bool(value: Any) -> bool: + """Parse a boolean value from string, int, or bool. + + Accepts: true/false, yes/no, 1/0, t/f, y/n (case-insensitive) + """ + if isinstance(value, bool): + return value + if isinstance(value, int): + return bool(value) + if isinstance(value, str): + normalized = value.lower().strip() + if normalized in ("true", "yes", "1", "t", "y"): + return True + if normalized in ("false", "no", "0", "f", "n"): + return False + raise ValueError(f"Cannot parse '{value}' as boolean") + raise TypeError(f"Cannot convert {type(value).__name__} to bool") + + +def parse_list(value: Any) -> List[str]: + """Parse a list from comma-separated string or list.""" + if isinstance(value, list): + return value + if isinstance(value, str): + # Split by comma and strip whitespace + return [item.strip() for item in value.split(",") if item.strip()] + raise TypeError(f"Cannot convert {type(value).__name__} to list") + + +def parse_int(value: Any) -> int: + """Parse an integer value.""" + if isinstance(value, int): + return value + if isinstance(value, str): + try: + return int(value.strip()) + except ValueError: + raise ValueError(f"Cannot parse '{value}' as integer") + raise TypeError(f"Cannot convert {type(value).__name__} to int") + + +def get_default_config() -> Dict[str, Any]: + """Get the default configuration with all options.""" + return { + "commit": { + "conventional_commits": True, + "subject_capitalized": DEFAULT_BOOLEAN_RULES["subject_capitalized"], + "subject_imperative": DEFAULT_BOOLEAN_RULES["subject_imperative"], + "subject_max_length": 80, + "subject_min_length": 5, + "allow_commit_types": DEFAULT_COMMIT_TYPES.copy(), + "allow_merge_commits": DEFAULT_BOOLEAN_RULES["allow_merge_commits"], + "allow_revert_commits": DEFAULT_BOOLEAN_RULES["allow_revert_commits"], + "allow_empty_commits": DEFAULT_BOOLEAN_RULES["allow_empty_commits"], + "allow_fixup_commits": DEFAULT_BOOLEAN_RULES["allow_fixup_commits"], + "allow_wip_commits": DEFAULT_BOOLEAN_RULES["allow_wip_commits"], + "require_body": DEFAULT_BOOLEAN_RULES["require_body"], + "require_signed_off_by": DEFAULT_BOOLEAN_RULES["require_signed_off_by"], + "ignore_authors": [], + }, + "branch": { + "conventional_branch": True, + "allow_branch_types": DEFAULT_BRANCH_TYPES.copy(), + "allow_branch_names": DEFAULT_BRANCH_NAMES.copy(), + "require_rebase_target": "", + "ignore_authors": [], + }, + } + + +def deep_merge(base: Dict[str, Any], override: Dict[str, Any]) -> None: + """Deep merge override into base dictionary (modifies base in-place).""" + for key, value in override.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + deep_merge(base[key], value) + else: + base[key] = value + + +class ConfigMerger: + """Merges configurations from multiple sources with priority: CLI > Env > TOML > Defaults.""" + + # Mapping of environment variable names to config keys + ENV_VAR_MAPPING: Dict[str, Tuple[str, str, Callable[[Any], Any]]] = { + # Commit section + "CCHK_CONVENTIONAL_COMMITS": ("commit", "conventional_commits", parse_bool), + "CCHK_SUBJECT_CAPITALIZED": ("commit", "subject_capitalized", parse_bool), + "CCHK_SUBJECT_IMPERATIVE": ("commit", "subject_imperative", parse_bool), + "CCHK_SUBJECT_MAX_LENGTH": ("commit", "subject_max_length", parse_int), + "CCHK_SUBJECT_MIN_LENGTH": ("commit", "subject_min_length", parse_int), + "CCHK_ALLOW_COMMIT_TYPES": ("commit", "allow_commit_types", parse_list), + "CCHK_ALLOW_MERGE_COMMITS": ("commit", "allow_merge_commits", parse_bool), + "CCHK_ALLOW_REVERT_COMMITS": ("commit", "allow_revert_commits", parse_bool), + "CCHK_ALLOW_EMPTY_COMMITS": ("commit", "allow_empty_commits", parse_bool), + "CCHK_ALLOW_FIXUP_COMMITS": ("commit", "allow_fixup_commits", parse_bool), + "CCHK_ALLOW_WIP_COMMITS": ("commit", "allow_wip_commits", parse_bool), + "CCHK_REQUIRE_BODY": ("commit", "require_body", parse_bool), + "CCHK_REQUIRE_SIGNED_OFF_BY": ("commit", "require_signed_off_by", parse_bool), + "CCHK_IGNORE_AUTHORS": ("commit", "ignore_authors", parse_list), + # Branch section + "CCHK_CONVENTIONAL_BRANCH": ("branch", "conventional_branch", parse_bool), + "CCHK_ALLOW_BRANCH_TYPES": ("branch", "allow_branch_types", parse_list), + "CCHK_ALLOW_BRANCH_NAMES": ("branch", "allow_branch_names", parse_list), + "CCHK_REQUIRE_REBASE_TARGET": ("branch", "require_rebase_target", str), + "CCHK_BRANCH_IGNORE_AUTHORS": ("branch", "ignore_authors", parse_list), + } + + @staticmethod + def parse_env_vars() -> Dict[str, Any]: + """Parse environment variables with CCHK_ prefix into config dict.""" + config: Dict[str, Any] = {"commit": {}, "branch": {}} + + for env_var, (section, key, parser) in ConfigMerger.ENV_VAR_MAPPING.items(): + value = os.environ.get(env_var) + if value is not None: + try: + parsed_value = parser(value) + config[section][key] = parsed_value + except (ValueError, TypeError) as e: + # Log warning but don't fail - just skip invalid env vars + print(f"Warning: Invalid value for {env_var}: {e}") + + # Remove empty sections + config = {k: v for k, v in config.items() if v} + return config + + @staticmethod + def parse_cli_args(args: argparse.Namespace) -> Dict[str, Any]: + """Parse CLI arguments into config dict.""" + config: Dict[str, Any] = {"commit": {}, "branch": {}} + + # Commit section arguments + if ( + hasattr(args, "conventional_commits") + and args.conventional_commits is not None + ): + config["commit"]["conventional_commits"] = args.conventional_commits + if ( + hasattr(args, "subject_capitalized") + and args.subject_capitalized is not None + ): + config["commit"]["subject_capitalized"] = args.subject_capitalized + if hasattr(args, "subject_imperative") and args.subject_imperative is not None: + config["commit"]["subject_imperative"] = args.subject_imperative + if hasattr(args, "subject_max_length") and args.subject_max_length is not None: + config["commit"]["subject_max_length"] = args.subject_max_length + if hasattr(args, "subject_min_length") and args.subject_min_length is not None: + config["commit"]["subject_min_length"] = args.subject_min_length + if hasattr(args, "allow_commit_types") and args.allow_commit_types is not None: + config["commit"]["allow_commit_types"] = args.allow_commit_types + if ( + hasattr(args, "allow_merge_commits") + and args.allow_merge_commits is not None + ): + config["commit"]["allow_merge_commits"] = args.allow_merge_commits + if ( + hasattr(args, "allow_revert_commits") + and args.allow_revert_commits is not None + ): + config["commit"]["allow_revert_commits"] = args.allow_revert_commits + if ( + hasattr(args, "allow_empty_commits") + and args.allow_empty_commits is not None + ): + config["commit"]["allow_empty_commits"] = args.allow_empty_commits + if ( + hasattr(args, "allow_fixup_commits") + and args.allow_fixup_commits is not None + ): + config["commit"]["allow_fixup_commits"] = args.allow_fixup_commits + if hasattr(args, "allow_wip_commits") and args.allow_wip_commits is not None: + config["commit"]["allow_wip_commits"] = args.allow_wip_commits + if hasattr(args, "require_body") and args.require_body is not None: + config["commit"]["require_body"] = args.require_body + if ( + hasattr(args, "require_signed_off_by") + and args.require_signed_off_by is not None + ): + config["commit"]["require_signed_off_by"] = args.require_signed_off_by + if hasattr(args, "ignore_authors") and args.ignore_authors is not None: + config["commit"]["ignore_authors"] = args.ignore_authors + + # Branch section arguments + if ( + hasattr(args, "conventional_branch") + and args.conventional_branch is not None + ): + config["branch"]["conventional_branch"] = args.conventional_branch + if hasattr(args, "allow_branch_types") and args.allow_branch_types is not None: + config["branch"]["allow_branch_types"] = args.allow_branch_types + if hasattr(args, "allow_branch_names") and args.allow_branch_names is not None: + config["branch"]["allow_branch_names"] = args.allow_branch_names + if ( + hasattr(args, "require_rebase_target") + and args.require_rebase_target is not None + ): + config["branch"]["require_rebase_target"] = args.require_rebase_target + if ( + hasattr(args, "branch_ignore_authors") + and args.branch_ignore_authors is not None + ): + config["branch"]["ignore_authors"] = args.branch_ignore_authors + + # Remove empty sections + config = {k: v for k, v in config.items() if v} + return config + + @staticmethod + def from_all_sources( + cli_args: argparse.Namespace, config_path: Optional[str] = None + ) -> Dict[str, Any]: + """Merge configs from all sources with priority: CLI > Env > TOML > Defaults. + + Args: + cli_args: Parsed command line arguments + config_path: Optional path to TOML config file + + Returns: + Merged configuration dictionary + """ + # 1. Start with defaults + config = get_default_config() + + # 2. Merge TOML config (if exists) + try: + toml_config = load_toml_config(config_path or "") + if toml_config: + deep_merge(config, toml_config) + except FileNotFoundError: + # If a specific path was provided and not found, this error is already raised + # If no path provided and no default files exist, that's fine + if config_path: + raise + + # 3. Merge environment variables + env_config = ConfigMerger.parse_env_vars() + if env_config: + deep_merge(config, env_config) + + # 4. Merge CLI arguments (highest priority) + cli_config = ConfigMerger.parse_cli_args(cli_args) + if cli_config: + deep_merge(config, cli_config) + + return config diff --git a/commit_check/main.py b/commit_check/main.py index 3803f63c..73d27eb0 100644 --- a/commit_check/main.py +++ b/commit_check/main.py @@ -5,7 +5,7 @@ import argparse from typing import Optional -from commit_check.config import load_config +from commit_check.config_merger import ConfigMerger, parse_bool, parse_list, parse_int from commit_check.rule_builder import RuleBuilder from commit_check.engine import ValidationEngine, ValidationContext, ValidationResult from . import __version__ @@ -86,6 +86,160 @@ def _get_parser() -> argparse.ArgumentParser: required=False, ) + # Commit configuration options + parser.add_argument( + "--conventional-commits", + type=parse_bool, + default=None, + metavar="BOOL", + help="enforce conventional commits format (true/false)", + ) + + parser.add_argument( + "--subject-capitalized", + type=parse_bool, + default=None, + metavar="BOOL", + help="require subject to start with capital letter (true/false)", + ) + + parser.add_argument( + "--subject-imperative", + type=parse_bool, + default=None, + metavar="BOOL", + help="require subject to use imperative mood (true/false)", + ) + + parser.add_argument( + "--subject-max-length", + type=parse_int, + default=None, + metavar="INT", + help="maximum length of commit subject", + ) + + parser.add_argument( + "--subject-min-length", + type=parse_int, + default=None, + metavar="INT", + help="minimum length of commit subject", + ) + + parser.add_argument( + "--allow-commit-types", + type=parse_list, + default=None, + metavar="LIST", + help="comma-separated list of allowed commit types (e.g., feat,fix,docs)", + ) + + parser.add_argument( + "--allow-merge-commits", + type=parse_bool, + default=None, + metavar="BOOL", + help="allow merge commits (true/false)", + ) + + parser.add_argument( + "--allow-revert-commits", + type=parse_bool, + default=None, + metavar="BOOL", + help="allow revert commits (true/false)", + ) + + parser.add_argument( + "--allow-empty-commits", + type=parse_bool, + default=None, + metavar="BOOL", + help="allow empty commit messages (true/false)", + ) + + parser.add_argument( + "--allow-fixup-commits", + type=parse_bool, + default=None, + metavar="BOOL", + help="allow fixup commits (true/false)", + ) + + parser.add_argument( + "--allow-wip-commits", + type=parse_bool, + default=None, + metavar="BOOL", + help="allow WIP commits (true/false)", + ) + + parser.add_argument( + "--require-body", + type=parse_bool, + default=None, + metavar="BOOL", + help="require commit body (true/false)", + ) + + parser.add_argument( + "--require-signed-off-by", + type=parse_bool, + default=None, + metavar="BOOL", + help="require 'Signed-off-by' trailer (true/false)", + ) + + parser.add_argument( + "--ignore-authors", + type=parse_list, + default=None, + metavar="LIST", + help="comma-separated list of authors to ignore for commit checks", + ) + + # Branch configuration options + parser.add_argument( + "--conventional-branch", + type=parse_bool, + default=None, + metavar="BOOL", + help="enforce conventional branch naming (true/false)", + ) + + parser.add_argument( + "--allow-branch-types", + type=parse_list, + default=None, + metavar="LIST", + help="comma-separated list of allowed branch types (e.g., feature,bugfix,hotfix)", + ) + + parser.add_argument( + "--allow-branch-names", + type=parse_list, + default=None, + metavar="LIST", + help="comma-separated list of additional allowed branch names", + ) + + parser.add_argument( + "--require-rebase-target", + type=str, + default=None, + metavar="BRANCH", + help="target branch for rebase validation", + ) + + parser.add_argument( + "--branch-ignore-authors", + type=parse_list, + default=None, + metavar="LIST", + help="comma-separated list of authors to ignore for branch checks", + ) + return parser @@ -135,8 +289,8 @@ def main() -> int: stdin_reader = StdinReader() try: - # Load configuration - config_data = load_config(args.config) + # Load and merge configuration from all sources: CLI > Env > TOML > Defaults + config_data = ConfigMerger.from_all_sources(args, args.config) # Build validation rules from config rule_builder = RuleBuilder(config_data) diff --git a/docs/configuration.rst b/docs/configuration.rst index 79713d61..34d6f52e 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -3,6 +3,23 @@ Configuration ============= +``commit-check`` can be configured in three ways with the following priority (highest to lowest): + +1. **Command-line arguments** (``--subject-imperative=true``) +2. **Environment variables** (``CCHK_SUBJECT_IMPERATIVE=true``) +3. **Configuration files** (``cchk.toml`` or ``commit-check.toml``) +4. **Built-in defaults** + +This flexibility allows you to: + +* Use configuration files for project-wide settings +* Override with environment variables in CI/CD pipelines +* Override specific settings via CLI for one-off checks +* Use without any configuration files (relies on defaults) + +Configuration Files +------------------- + ``commit-check`` configuration files support the TOML format. See ``cchk.toml`` for an example configuration. .. tip:: @@ -65,6 +82,173 @@ Example Configuration # ignore_authors = [] # Optional - no authors ignored by default +Command-Line Arguments +---------------------- + +All configuration options can be specified via command-line arguments, which take precedence over environment variables and configuration files. + +**Syntax:** + +* Boolean options: ``--option-name=true`` or ``--option-name=false`` +* Integer options: ``--option-name=80`` +* List options: ``--option-name=value1,value2,value3`` (comma-separated) +* String options: ``--option-name=value`` + +**Examples:** + +.. code-block:: bash + + # Disable imperative mood check + commit-check --message --subject-imperative=false + + # Set custom subject length limit + commit-check --message --subject-max-length=72 + + # Restrict allowed commit types + commit-check --message --allow-commit-types=feat,fix,docs + + # Combine multiple options + commit-check --message --subject-imperative=true --subject-max-length=50 --allow-commit-types=feat,fix + + # Branch configuration via CLI + commit-check --branch --allow-branch-types=feature,bugfix,hotfix + +**Pre-commit Hook Usage:** + +The primary use case for CLI arguments is configuring commit-check in ``.pre-commit-config.yaml`` without requiring a TOML file: + +.. code-block:: yaml + + repos: + - repo: https://github.com/commit-check/commit-check + rev: v2.2.0 + hooks: + - id: commit-check + args: + - --subject-imperative=false + - --subject-max-length=100 + - --allow-merge-commits=false + + +Environment Variables +--------------------- + +Configuration can also be set via environment variables with the ``CCHK_`` prefix. This is useful for CI/CD pipelines and temporary overrides. + +**Naming Convention:** + +* Convert option name to uppercase +* Replace hyphens with underscores +* Add ``CCHK_`` prefix + +**Examples:** + +.. code-block:: bash + + # Set boolean options + export CCHK_SUBJECT_IMPERATIVE=true + export CCHK_SUBJECT_CAPITALIZED=false + + # Set integer options + export CCHK_SUBJECT_MAX_LENGTH=72 + export CCHK_SUBJECT_MIN_LENGTH=10 + + # Set list options (comma-separated) + export CCHK_ALLOW_COMMIT_TYPES=feat,fix,docs,chore + export CCHK_ALLOW_BRANCH_TYPES=feature,bugfix,hotfix + + # Set string options + export CCHK_REQUIRE_REBASE_TARGET=main + + # Use in CI/CD + CCHK_SUBJECT_MAX_LENGTH=100 commit-check --message + +**Complete Mapping:** + +.. list-table:: + :header-rows: 1 + + * - TOML Config + - Environment Variable + - CLI Argument + * - ``conventional_commits = true`` + - ``CCHK_CONVENTIONAL_COMMITS=true`` + - ``--conventional-commits=true`` + * - ``subject_capitalized = false`` + - ``CCHK_SUBJECT_CAPITALIZED=false`` + - ``--subject-capitalized=false`` + * - ``subject_imperative = true`` + - ``CCHK_SUBJECT_IMPERATIVE=true`` + - ``--subject-imperative=true`` + * - ``subject_max_length = 80`` + - ``CCHK_SUBJECT_MAX_LENGTH=80`` + - ``--subject-max-length=80`` + * - ``subject_min_length = 5`` + - ``CCHK_SUBJECT_MIN_LENGTH=5`` + - ``--subject-min-length=5`` + * - ``allow_commit_types = ["feat", "fix"]`` + - ``CCHK_ALLOW_COMMIT_TYPES=feat,fix`` + - ``--allow-commit-types=feat,fix`` + * - ``allow_merge_commits = true`` + - ``CCHK_ALLOW_MERGE_COMMITS=true`` + - ``--allow-merge-commits=true`` + * - ``allow_revert_commits = true`` + - ``CCHK_ALLOW_REVERT_COMMITS=true`` + - ``--allow-revert-commits=true`` + * - ``allow_empty_commits = false`` + - ``CCHK_ALLOW_EMPTY_COMMITS=false`` + - ``--allow-empty-commits=false`` + * - ``allow_fixup_commits = true`` + - ``CCHK_ALLOW_FIXUP_COMMITS=true`` + - ``--allow-fixup-commits=true`` + * - ``allow_wip_commits = false`` + - ``CCHK_ALLOW_WIP_COMMITS=false`` + - ``--allow-wip-commits=false`` + * - ``require_body = false`` + - ``CCHK_REQUIRE_BODY=false`` + - ``--require-body=false`` + * - ``require_signed_off_by = false`` + - ``CCHK_REQUIRE_SIGNED_OFF_BY=false`` + - ``--require-signed-off-by=false`` + * - ``ignore_authors = ["bot"]`` + - ``CCHK_IGNORE_AUTHORS=bot,user`` + - ``--ignore-authors=bot,user`` + * - ``conventional_branch = true`` + - ``CCHK_CONVENTIONAL_BRANCH=true`` + - ``--conventional-branch=true`` + * - ``allow_branch_types = ["feature"]`` + - ``CCHK_ALLOW_BRANCH_TYPES=feature,bugfix`` + - ``--allow-branch-types=feature,bugfix`` + * - ``allow_branch_names = ["develop"]`` + - ``CCHK_ALLOW_BRANCH_NAMES=develop,staging`` + - ``--allow-branch-names=develop,staging`` + * - ``require_rebase_target = "main"`` + - ``CCHK_REQUIRE_REBASE_TARGET=main`` + - ``--require-rebase-target=main`` + * - ``ignore_authors = ["bot"]`` (in branch section) + - ``CCHK_BRANCH_IGNORE_AUTHORS=bot,user`` + - ``--branch-ignore-authors=bot,user`` + + +Configuration Priority Example +------------------------------- + +When the same option is specified in multiple places, the priority determines which value is used: + +.. code-block:: bash + + # In cchk.toml: + # subject_max_length = 100 + + # Set via environment: + export CCHK_SUBJECT_MAX_LENGTH=80 + + # Override via CLI: + commit-check --message --subject-max-length=50 + + # Result: subject_max_length = 50 (CLI wins) + + Options Table Description ------------------------- diff --git a/tests/config_merger_test.py b/tests/config_merger_test.py new file mode 100644 index 00000000..d165344e --- /dev/null +++ b/tests/config_merger_test.py @@ -0,0 +1,367 @@ +"""Tests for config_merger module.""" + +import os +import pytest +import argparse +from commit_check.config_merger import ( + parse_bool, + parse_list, + parse_int, + get_default_config, + deep_merge, + ConfigMerger, +) + + +class TestParseBool: + """Tests for parse_bool function.""" + + def test_parse_bool_from_bool(self): + assert parse_bool(True) is True + assert parse_bool(False) is False + + def test_parse_bool_from_int(self): + assert parse_bool(1) is True + assert parse_bool(0) is False + + def test_parse_bool_from_string_true_variants(self): + for value in ["true", "True", "TRUE", "yes", "YES", "y", "Y", "t", "T", "1"]: + assert parse_bool(value) is True, f"Failed for '{value}'" + + def test_parse_bool_from_string_false_variants(self): + for value in ["false", "False", "FALSE", "no", "NO", "n", "N", "f", "F", "0"]: + assert parse_bool(value) is False, f"Failed for '{value}'" + + def test_parse_bool_invalid_string(self): + with pytest.raises(ValueError, match="Cannot parse"): + parse_bool("invalid") + + def test_parse_bool_invalid_type(self): + with pytest.raises(TypeError, match="Cannot convert"): + parse_bool([]) + + +class TestParseList: + """Tests for parse_list function.""" + + def test_parse_list_from_list(self): + assert parse_list(["a", "b", "c"]) == ["a", "b", "c"] + + def test_parse_list_from_comma_separated(self): + assert parse_list("a,b,c") == ["a", "b", "c"] + + def test_parse_list_from_comma_separated_with_spaces(self): + assert parse_list("a, b , c") == ["a", "b", "c"] + + def test_parse_list_empty_string(self): + assert parse_list("") == [] + + def test_parse_list_single_item(self): + assert parse_list("single") == ["single"] + + def test_parse_list_invalid_type(self): + with pytest.raises(TypeError, match="Cannot convert"): + parse_list(123) + + +class TestParseInt: + """Tests for parse_int function.""" + + def test_parse_int_from_int(self): + assert parse_int(42) == 42 + + def test_parse_int_from_string(self): + assert parse_int("42") == 42 + assert parse_int(" 42 ") == 42 + + def test_parse_int_invalid_string(self): + with pytest.raises(ValueError, match="Cannot parse"): + parse_int("invalid") + + def test_parse_int_invalid_type(self): + with pytest.raises(TypeError, match="Cannot convert"): + parse_int([]) + + +class TestGetDefaultConfig: + """Tests for get_default_config function.""" + + def test_returns_dict(self): + config = get_default_config() + assert isinstance(config, dict) + + def test_has_commit_section(self): + config = get_default_config() + assert "commit" in config + assert isinstance(config["commit"], dict) + + def test_has_branch_section(self): + config = get_default_config() + assert "branch" in config + assert isinstance(config["branch"], dict) + + def test_commit_defaults(self): + config = get_default_config() + commit = config["commit"] + assert commit["conventional_commits"] is True + assert commit["subject_max_length"] == 80 + assert commit["subject_min_length"] == 5 + assert isinstance(commit["allow_commit_types"], list) + assert "feat" in commit["allow_commit_types"] + + def test_branch_defaults(self): + config = get_default_config() + branch = config["branch"] + assert branch["conventional_branch"] is True + assert isinstance(branch["allow_branch_types"], list) + assert "feature" in branch["allow_branch_types"] + + +class TestDeepMerge: + """Tests for deep_merge function.""" + + def test_merge_simple_keys(self): + base = {"a": 1, "b": 2} + override = {"b": 3, "c": 4} + deep_merge(base, override) + assert base == {"a": 1, "b": 3, "c": 4} + + def test_merge_nested_dicts(self): + base = {"section": {"key1": "value1", "key2": "value2"}} + override = {"section": {"key2": "new_value2", "key3": "value3"}} + deep_merge(base, override) + assert base == { + "section": {"key1": "value1", "key2": "new_value2", "key3": "value3"} + } + + def test_override_with_non_dict(self): + base = {"section": {"key": "value"}} + override = {"section": "not_a_dict"} + deep_merge(base, override) + assert base == {"section": "not_a_dict"} + + +class TestConfigMergerParseEnvVars: + """Tests for ConfigMerger.parse_env_vars method.""" + + def test_parse_boolean_env_var(self, monkeypatch): + monkeypatch.setenv("CCHK_SUBJECT_IMPERATIVE", "true") + config = ConfigMerger.parse_env_vars() + assert config["commit"]["subject_imperative"] is True + + def test_parse_integer_env_var(self, monkeypatch): + monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "100") + config = ConfigMerger.parse_env_vars() + assert config["commit"]["subject_max_length"] == 100 + + def test_parse_list_env_var(self, monkeypatch): + monkeypatch.setenv("CCHK_ALLOW_COMMIT_TYPES", "feat,fix,docs") + config = ConfigMerger.parse_env_vars() + assert config["commit"]["allow_commit_types"] == ["feat", "fix", "docs"] + + def test_parse_multiple_env_vars(self, monkeypatch): + monkeypatch.setenv("CCHK_SUBJECT_IMPERATIVE", "false") + monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "72") + monkeypatch.setenv("CCHK_ALLOW_MERGE_COMMITS", "false") + config = ConfigMerger.parse_env_vars() + assert config["commit"]["subject_imperative"] is False + assert config["commit"]["subject_max_length"] == 72 + assert config["commit"]["allow_merge_commits"] is False + + def test_parse_branch_env_vars(self, monkeypatch): + monkeypatch.setenv("CCHK_CONVENTIONAL_BRANCH", "false") + monkeypatch.setenv("CCHK_ALLOW_BRANCH_TYPES", "feature,bugfix") + config = ConfigMerger.parse_env_vars() + assert config["branch"]["conventional_branch"] is False + assert config["branch"]["allow_branch_types"] == ["feature", "bugfix"] + + def test_invalid_env_var_is_skipped(self, monkeypatch, capsys): + monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "invalid") + config = ConfigMerger.parse_env_vars() + # Should not crash, but should print warning + captured = capsys.readouterr() + assert "Warning" in captured.out + # Should not have subject_max_length in config + assert "subject_max_length" not in config.get("commit", {}) + + def test_no_env_vars_returns_empty_sections(self, monkeypatch): + # Clear all CCHK_ environment variables + for key in list(os.environ.keys()): + if key.startswith("CCHK_"): + monkeypatch.delenv(key, raising=False) + + config = ConfigMerger.parse_env_vars() + # Should return empty dict or dict with empty sections + assert not config or all(not v for v in config.values()) + + +class TestConfigMergerParseCliArgs: + """Tests for ConfigMerger.parse_cli_args method.""" + + def test_parse_boolean_cli_arg(self): + args = argparse.Namespace(subject_imperative=True) + config = ConfigMerger.parse_cli_args(args) + assert config["commit"]["subject_imperative"] is True + + def test_parse_integer_cli_arg(self): + args = argparse.Namespace(subject_max_length=100) + config = ConfigMerger.parse_cli_args(args) + assert config["commit"]["subject_max_length"] == 100 + + def test_parse_list_cli_arg(self): + args = argparse.Namespace(allow_commit_types=["feat", "fix", "docs"]) + config = ConfigMerger.parse_cli_args(args) + assert config["commit"]["allow_commit_types"] == ["feat", "fix", "docs"] + + def test_parse_multiple_cli_args(self): + args = argparse.Namespace( + subject_imperative=False, + subject_max_length=72, + allow_merge_commits=False, + ) + config = ConfigMerger.parse_cli_args(args) + assert config["commit"]["subject_imperative"] is False + assert config["commit"]["subject_max_length"] == 72 + assert config["commit"]["allow_merge_commits"] is False + + def test_none_values_are_not_included(self): + args = argparse.Namespace(subject_imperative=None, subject_max_length=100) + config = ConfigMerger.parse_cli_args(args) + assert "subject_imperative" not in config.get("commit", {}) + assert config["commit"]["subject_max_length"] == 100 + + def test_missing_attributes_are_ignored(self): + args = argparse.Namespace() + config = ConfigMerger.parse_cli_args(args) + # Should not crash, should return empty sections + assert not config or all(not v for v in config.values()) + + def test_branch_cli_args(self): + args = argparse.Namespace( + conventional_branch=False, + allow_branch_types=["feature", "bugfix"], + require_rebase_target="main", + ) + config = ConfigMerger.parse_cli_args(args) + assert config["branch"]["conventional_branch"] is False + assert config["branch"]["allow_branch_types"] == ["feature", "bugfix"] + assert config["branch"]["require_rebase_target"] == "main" + + +class TestConfigMergerFromAllSources: + """Tests for ConfigMerger.from_all_sources method.""" + + def test_default_config_only(self, tmp_path): + # No TOML file, no env vars, no CLI args + args = argparse.Namespace() + config = ConfigMerger.from_all_sources(args) + + # Should have default values + assert config["commit"]["conventional_commits"] is True + assert config["commit"]["subject_max_length"] == 80 + + def test_toml_overrides_defaults(self, tmp_path, monkeypatch): + # Create a TOML config file + toml_file = tmp_path / "cchk.toml" + toml_file.write_text(""" +[commit] +subject_max_length = 100 +subject_imperative = true +""") + + # Change to temp directory + monkeypatch.chdir(tmp_path) + + args = argparse.Namespace() + config = ConfigMerger.from_all_sources(args) + + assert config["commit"]["subject_max_length"] == 100 + assert config["commit"]["subject_imperative"] is True + # Defaults should still be present for other keys + assert config["commit"]["conventional_commits"] is True + + def test_env_overrides_toml(self, tmp_path, monkeypatch): + # Create a TOML config file + toml_file = tmp_path / "cchk.toml" + toml_file.write_text(""" +[commit] +subject_max_length = 100 +""") + + # Set env var + monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "120") + monkeypatch.chdir(tmp_path) + + args = argparse.Namespace() + config = ConfigMerger.from_all_sources(args) + + # Env var should override TOML + assert config["commit"]["subject_max_length"] == 120 + + def test_cli_overrides_env(self, tmp_path, monkeypatch): + # Create a TOML config file + toml_file = tmp_path / "cchk.toml" + toml_file.write_text(""" +[commit] +subject_max_length = 100 +""") + + # Set env var + monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "120") + monkeypatch.chdir(tmp_path) + + # Set CLI arg + args = argparse.Namespace(subject_max_length=150) + config = ConfigMerger.from_all_sources(args) + + # CLI should override both env and TOML + assert config["commit"]["subject_max_length"] == 150 + + def test_priority_chain_full(self, tmp_path, monkeypatch): + """Test full priority chain: CLI > Env > TOML > Defaults""" + # Create TOML config + toml_file = tmp_path / "cchk.toml" + toml_file.write_text(""" +[commit] +subject_max_length = 100 +subject_min_length = 10 +subject_imperative = true +allow_merge_commits = false +""") + + # Set env vars (override some TOML values) + monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "120") + monkeypatch.setenv("CCHK_SUBJECT_IMPERATIVE", "false") + monkeypatch.chdir(tmp_path) + + # Set CLI args (override some env values) + args = argparse.Namespace( + subject_max_length=150, + allow_merge_commits=True, + ) + config = ConfigMerger.from_all_sources(args) + + # Verify priorities: + assert config["commit"]["subject_max_length"] == 150 # CLI wins + assert config["commit"]["subject_imperative"] is False # Env wins (no CLI) + assert config["commit"]["subject_min_length"] == 10 # TOML wins (no CLI or env) + assert config["commit"]["allow_merge_commits"] is True # CLI wins + assert config["commit"]["conventional_commits"] is True # Default wins + + def test_specific_config_path(self, tmp_path): + # Create a custom config file + custom_config = tmp_path / "custom.toml" + custom_config.write_text(""" +[commit] +subject_max_length = 200 +""") + + args = argparse.Namespace() + config = ConfigMerger.from_all_sources(args, str(custom_config)) + + assert config["commit"]["subject_max_length"] == 200 + + def test_missing_specific_config_raises_error(self, tmp_path): + args = argparse.Namespace() + with pytest.raises(FileNotFoundError): + ConfigMerger.from_all_sources(args, str(tmp_path / "nonexistent.toml")) diff --git a/tests/main_test.py b/tests/main_test.py index b03ab76c..71da25ca 100644 --- a/tests/main_test.py +++ b/tests/main_test.py @@ -319,3 +319,158 @@ def test_nonexistent_config_file_error(self, capsys): "Error: Specified config file not found: /nonexistent/config.toml" in captured.err ) + + +class TestCLIArgumentIntegration: + """Test CLI argument integration with the new config merger.""" + + @pytest.mark.benchmark + def test_cli_subject_imperative_true(self, mocker): + """Test --subject-imperative=true rejects non-imperative commit.""" + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch("sys.stdin.read", return_value="feat: Added feature\n") + mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") + + sys.argv = ["commit-check", "--message", "--subject-imperative=true"] + result = main() + assert result == 1 # Should fail due to non-imperative mood + + @pytest.mark.benchmark + def test_cli_subject_imperative_false(self, mocker): + """Test --subject-imperative=false allows non-imperative commit.""" + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch("sys.stdin.read", return_value="feat: Added feature\n") + + sys.argv = ["commit-check", "--message", "--subject-imperative=false"] + result = main() + assert result == 0 # Should pass + + @pytest.mark.benchmark + def test_cli_subject_max_length(self, mocker): + """Test --subject-max-length limits commit subject.""" + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch( + "sys.stdin.read", + return_value="feat: This is a very long commit message that exceeds the limit\n", + ) + mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") + + sys.argv = ["commit-check", "--message", "--subject-max-length=30"] + result = main() + assert result == 1 # Should fail due to length + + @pytest.mark.benchmark + def test_cli_allow_commit_types(self, mocker): + """Test --allow-commit-types restricts commit types.""" + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch("sys.stdin.read", return_value="chore: do something\n") + mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") + + sys.argv = ["commit-check", "--message", "--allow-commit-types=feat,fix"] + result = main() + assert result == 1 # Should fail because 'chore' is not in allowed types + + @pytest.mark.benchmark + def test_cli_allow_merge_commits_false(self, mocker): + """Test --allow-merge-commits=false rejects merge commits.""" + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch( + "sys.stdin.read", return_value="Merge branch 'feature' into main\n" + ) + mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") + + sys.argv = ["commit-check", "--message", "--allow-merge-commits=false"] + result = main() + assert result == 1 # Should fail + + @pytest.mark.benchmark + def test_cli_multiple_args_combined(self, mocker): + """Test multiple CLI arguments work together.""" + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch("sys.stdin.read", return_value="feat: Add feature\n") + + sys.argv = [ + "commit-check", + "--message", + "--subject-imperative=true", + "--subject-max-length=100", + "--allow-commit-types=feat,fix,docs", + ] + result = main() + assert result == 0 # Should pass all checks + + +class TestEnvironmentVariableIntegration: + """Test environment variable integration with the new config merger.""" + + @pytest.mark.benchmark + def test_env_subject_imperative(self, mocker, monkeypatch): + """Test CCHK_SUBJECT_IMPERATIVE environment variable.""" + monkeypatch.setenv("CCHK_SUBJECT_IMPERATIVE", "true") + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch("sys.stdin.read", return_value="feat: Added feature\n") + mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") + + sys.argv = ["commit-check", "--message"] + result = main() + assert result == 1 # Should fail due to non-imperative + + @pytest.mark.benchmark + def test_env_subject_max_length(self, mocker, monkeypatch): + """Test CCHK_SUBJECT_MAX_LENGTH environment variable.""" + monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "30") + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch( + "sys.stdin.read", + return_value="feat: This is a very long commit message\n", + ) + mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") + + sys.argv = ["commit-check", "--message"] + result = main() + assert result == 1 # Should fail due to length + + @pytest.mark.benchmark + def test_env_allow_commit_types(self, mocker, monkeypatch): + """Test CCHK_ALLOW_COMMIT_TYPES environment variable.""" + monkeypatch.setenv("CCHK_ALLOW_COMMIT_TYPES", "feat,fix") + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch("sys.stdin.read", return_value="chore: do something\n") + mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") + + sys.argv = ["commit-check", "--message"] + result = main() + assert result == 1 # Should fail + + +class TestConfigPriority: + """Test configuration priority: CLI > Env > TOML > Defaults.""" + + @pytest.mark.benchmark + def test_cli_overrides_env(self, mocker, monkeypatch): + """Test that CLI arguments override environment variables.""" + # Set env var to true + monkeypatch.setenv("CCHK_SUBJECT_IMPERATIVE", "true") + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch("sys.stdin.read", return_value="feat: Added feature\n") + + # Override with CLI to false + sys.argv = ["commit-check", "--message", "--subject-imperative=false"] + result = main() + assert result == 0 # CLI wins, should pass + + @pytest.mark.benchmark + def test_env_overrides_default(self, mocker, monkeypatch): + """Test that environment variables override defaults.""" + # Default subject_max_length is 80 + monkeypatch.setenv("CCHK_SUBJECT_MAX_LENGTH", "30") + mocker.patch("sys.stdin.isatty", return_value=False) + mocker.patch( + "sys.stdin.read", + return_value="feat: This is a commit message that is longer than 30 chars\n", + ) + mocker.patch("commit_check.engine.get_commit_info", return_value="test-user") + + sys.argv = ["commit-check", "--message"] + result = main() + assert result == 1 # Env var wins, should fail From 7375d8dd92b06f4bb1c138c405b7415a0c3ea286 Mon Sep 17 00:00:00 2001 From: Xianpeng Shen Date: Sun, 1 Feb 2026 21:00:13 +0200 Subject: [PATCH 2/4] chore: Update docs/configuration.rst Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs/configuration.rst | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/docs/configuration.rst b/docs/configuration.rst index 34d6f52e..beb55b06 100644 --- a/docs/configuration.rst +++ b/docs/configuration.rst @@ -121,7 +121,7 @@ The primary use case for CLI arguments is configuring commit-check in ``.pre-com repos: - repo: https://github.com/commit-check/commit-check - rev: v2.2.0 + rev: v2.3.0 hooks: - id: commit-check args: @@ -129,7 +129,6 @@ The primary use case for CLI arguments is configuring commit-check in ``.pre-com - --subject-max-length=100 - --allow-merge-commits=false - Environment Variables --------------------- From 8f80ac6f2db9fe3739be8ee6537a88a4f4f422b3 Mon Sep 17 00:00:00 2001 From: shenxianpeng Date: Sun, 1 Feb 2026 21:01:39 +0200 Subject: [PATCH 3/4] chore: Update commit-check version to v2.3.0 in README --- README.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.rst b/README.rst index 714907f0..161c5c21 100644 --- a/README.rst +++ b/README.rst @@ -106,7 +106,7 @@ For one-off checks or CI/CD pipelines, you can configure via CLI arguments or en # In pre-commit hooks (.pre-commit-config.yaml) repos: - repo: https://github.com/commit-check/commit-check - rev: v2.2.0 + rev: v2.3.0 hooks: - id: commit-check args: From 1d8f9a2e565cd1f7d3e9b51d5a904e15904273a2 Mon Sep 17 00:00:00 2001 From: Copilot <198982749+Copilot@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:21:14 +0200 Subject: [PATCH 4/4] refactor: change parse_cli_args to use data-driven approach (#358) * Initial plan * refactor: Use data-driven approach in parse_cli_args to reduce duplication Co-authored-by: shenxianpeng <3353385+shenxianpeng@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: shenxianpeng <3353385+shenxianpeng@users.noreply.github.com> --- commit_check/config_merger.py | 101 ++++++++++------------------------ 1 file changed, 30 insertions(+), 71 deletions(-) diff --git a/commit_check/config_merger.py b/commit_check/config_merger.py index 17a578d7..0cbeff11 100644 --- a/commit_check/config_merger.py +++ b/commit_check/config_merger.py @@ -121,6 +121,31 @@ class ConfigMerger: "CCHK_BRANCH_IGNORE_AUTHORS": ("branch", "ignore_authors", parse_list), } + # Mapping of CLI argument names to config keys + CLI_ARG_MAPPING: Dict[str, Tuple[str, str]] = { + # Commit section + "conventional_commits": ("commit", "conventional_commits"), + "subject_capitalized": ("commit", "subject_capitalized"), + "subject_imperative": ("commit", "subject_imperative"), + "subject_max_length": ("commit", "subject_max_length"), + "subject_min_length": ("commit", "subject_min_length"), + "allow_commit_types": ("commit", "allow_commit_types"), + "allow_merge_commits": ("commit", "allow_merge_commits"), + "allow_revert_commits": ("commit", "allow_revert_commits"), + "allow_empty_commits": ("commit", "allow_empty_commits"), + "allow_fixup_commits": ("commit", "allow_fixup_commits"), + "allow_wip_commits": ("commit", "allow_wip_commits"), + "require_body": ("commit", "require_body"), + "require_signed_off_by": ("commit", "require_signed_off_by"), + "ignore_authors": ("commit", "ignore_authors"), + # Branch section + "conventional_branch": ("branch", "conventional_branch"), + "allow_branch_types": ("branch", "allow_branch_types"), + "allow_branch_names": ("branch", "allow_branch_names"), + "require_rebase_target": ("branch", "require_rebase_target"), + "branch_ignore_authors": ("branch", "ignore_authors"), + } + @staticmethod def parse_env_vars() -> Dict[str, Any]: """Parse environment variables with CCHK_ prefix into config dict.""" @@ -145,77 +170,11 @@ def parse_cli_args(args: argparse.Namespace) -> Dict[str, Any]: """Parse CLI arguments into config dict.""" config: Dict[str, Any] = {"commit": {}, "branch": {}} - # Commit section arguments - if ( - hasattr(args, "conventional_commits") - and args.conventional_commits is not None - ): - config["commit"]["conventional_commits"] = args.conventional_commits - if ( - hasattr(args, "subject_capitalized") - and args.subject_capitalized is not None - ): - config["commit"]["subject_capitalized"] = args.subject_capitalized - if hasattr(args, "subject_imperative") and args.subject_imperative is not None: - config["commit"]["subject_imperative"] = args.subject_imperative - if hasattr(args, "subject_max_length") and args.subject_max_length is not None: - config["commit"]["subject_max_length"] = args.subject_max_length - if hasattr(args, "subject_min_length") and args.subject_min_length is not None: - config["commit"]["subject_min_length"] = args.subject_min_length - if hasattr(args, "allow_commit_types") and args.allow_commit_types is not None: - config["commit"]["allow_commit_types"] = args.allow_commit_types - if ( - hasattr(args, "allow_merge_commits") - and args.allow_merge_commits is not None - ): - config["commit"]["allow_merge_commits"] = args.allow_merge_commits - if ( - hasattr(args, "allow_revert_commits") - and args.allow_revert_commits is not None - ): - config["commit"]["allow_revert_commits"] = args.allow_revert_commits - if ( - hasattr(args, "allow_empty_commits") - and args.allow_empty_commits is not None - ): - config["commit"]["allow_empty_commits"] = args.allow_empty_commits - if ( - hasattr(args, "allow_fixup_commits") - and args.allow_fixup_commits is not None - ): - config["commit"]["allow_fixup_commits"] = args.allow_fixup_commits - if hasattr(args, "allow_wip_commits") and args.allow_wip_commits is not None: - config["commit"]["allow_wip_commits"] = args.allow_wip_commits - if hasattr(args, "require_body") and args.require_body is not None: - config["commit"]["require_body"] = args.require_body - if ( - hasattr(args, "require_signed_off_by") - and args.require_signed_off_by is not None - ): - config["commit"]["require_signed_off_by"] = args.require_signed_off_by - if hasattr(args, "ignore_authors") and args.ignore_authors is not None: - config["commit"]["ignore_authors"] = args.ignore_authors - - # Branch section arguments - if ( - hasattr(args, "conventional_branch") - and args.conventional_branch is not None - ): - config["branch"]["conventional_branch"] = args.conventional_branch - if hasattr(args, "allow_branch_types") and args.allow_branch_types is not None: - config["branch"]["allow_branch_types"] = args.allow_branch_types - if hasattr(args, "allow_branch_names") and args.allow_branch_names is not None: - config["branch"]["allow_branch_names"] = args.allow_branch_names - if ( - hasattr(args, "require_rebase_target") - and args.require_rebase_target is not None - ): - config["branch"]["require_rebase_target"] = args.require_rebase_target - if ( - hasattr(args, "branch_ignore_authors") - and args.branch_ignore_authors is not None - ): - config["branch"]["ignore_authors"] = args.branch_ignore_authors + for arg_name, (section, key) in ConfigMerger.CLI_ARG_MAPPING.items(): + if hasattr(args, arg_name): + value = getattr(args, arg_name) + if value is not None: + config[section][key] = value # Remove empty sections config = {k: v for k, v in config.items() if v}