Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
37 changes: 35 additions & 2 deletions README.rst
Original file line number Diff line number Diff line change
Expand Up @@ -70,18 +70,51 @@ For more information, see the `docs <https://commit-check.github.io/commit-check
Configuration
-------------

Commit Check can be configured in three ways (in order of priority):

1. **Command-line arguments** — Override settings for specific runs
2. **Environment variables** — Configure via ``CCHK_*`` environment variables
3. **Configuration files** — Use ``cchk.toml`` or ``commit-check.toml``

Use Default Configuration
~~~~~~~~~~~~~~~~~~~~~~~~~

- **Commit Check** uses a `default configuration <https://github.com/commit-check/commit-check/blob/main/docs/configuration.rst>`_ if you do not provide a ``cchk.toml`` or ``commit-check.toml`` file.

- The default configuration is lenient — it only checks whether commit messages follow the `Conventional Commits <https://www.conventionalcommits.org/en/v1.0.0/#summary>`_ specification and branch names follow the `Conventional Branch <https://conventional-branch.github.io/#summary>`_ 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 <https://github.com/commit-check/commit-check/blob/main/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.3.0
hooks:
- id: commit-check
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The id: commit-check is not correct...
It is not defined in https://github.com/commit-check/commit-check/blob/main/.pre-commit-hooks.yaml

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! fixed into the main branch.

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure if it is just on my side, but I'm getting error:

$ git commit -m 'feat: improve logging messages and add checks for .lycheecache'
...
...
...
check commit message.....................................................Failed
- hook id: check-message
- exit code: 2

  usage: commit-check [-h] [-v] [-c CONFIG] [-m [MESSAGE]] [-b] [-n] [-e] [-d]
                      [--conventional-commits BOOL] [--subject-capitalized BOOL]
                      [--subject-imperative BOOL] [--subject-max-length INT]
                      [--subject-min-length INT] [--allow-commit-types LIST]
                      [--allow-merge-commits BOOL] [--allow-revert-commits BOOL]
                      [--allow-empty-commits BOOL] [--allow-fixup-commits BOOL]
                      [--allow-wip-commits BOOL] [--require-body BOOL]
                      [--require-signed-off-by BOOL] [--ignore-authors LIST]
                      [--conventional-branch BOOL] [--allow-branch-types LIST]
                      [--allow-branch-names LIST]
                      [--require-rebase-target BRANCH]
                      [--branch-ignore-authors LIST]
  commit-check: error: unrecognized arguments: .git/COMMIT_EDITMSG

I have this in .pre-commit-config.yaml:

  - repo: https://github.com/commit-check/commit-check
    rev: v2.4.0
    hooks:
      - id: check-message
        args:
          - --subject-imperative=false
      - id: check-branch
      - id: check-author-name
      - id: check-author-email

args:
- --subject-imperative=false
- --subject-max-length=100

See the `Configuration documentation <https://commit-check.github.io/commit-check/configuration.html>`_ for all available options.

Usage
-----

Expand Down
220 changes: 220 additions & 0 deletions commit_check/config_merger.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
"""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,
Comment on lines +65 to +66
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, check the documentation as suggested
rg -n "subject_max_length|subject_min_length" docs/configuration.rst | head -20

Repository: commit-check/commit-check

Length of output: 427


🏁 Script executed:

# Read the full context around lines 65-66 in config_merger.py
cat -n commit_check/config_merger.py | sed -n '50,80p'

Repository: commit-check/commit-check

Length of output: 1794


🏁 Script executed:

# Also check if there are multiple config files or defaults defined elsewhere
fd -t f config || true
fd -t f "\.py$" commit_check/ | xargs rg -l "subject_max_length|subject_min_length"

Repository: commit-check/commit-check

Length of output: 326


🏁 Script executed:

# Get more context around lines 279-287 in the documentation
cat -n docs/configuration.rst | sed -n '270,300p'

Repository: commit-check/commit-check

Length of output: 1169


🏁 Script executed:

# Check broader context around lines 62-63 and 183-186
cat -n docs/configuration.rst | sed -n '55,75p'
cat -n docs/configuration.rst | sed -n '175,195p'

Repository: commit-check/commit-check

Length of output: 1978


Default values in code conflict with documented behavior.

The code sets subject_max_length to 80 and subject_min_length to 5, but the documentation at lines 279-287 specifies these options default to None (no limit). This mismatch could cause unexpected validation failures for users relying on the documented default behavior.

Align either the code defaults or documentation to ensure consistency.

🤖 Prompt for AI Agents
In `@commit_check/config_merger.py` around lines 65 - 66, The default
configuration in commit_check/config_merger.py currently sets
"subject_max_length": 80 and "subject_min_length": 5 which conflicts with the
documented defaults of None; update the defaults to match the docs by changing
the values for the keys subject_max_length and subject_min_length to None (no
limit) in the default config/dict (where these keys are defined) so validation
will behave as documented.

"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),
}

# 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."""
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": {}}

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}
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
Loading
Loading