diff --git a/README.md b/README.md index 2b729de..2764e9f 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,25 @@ Available checks: * `signed-off` - `Signed-off-by:` trailer exists * `signature` - Verify GPG or SSH signature +### Configuration file + +Place `.commit-guard.toml` in your project root (or any parent directory) to +set defaults for `enable` and `disable`. commit-guard searches upward from the +working directory and uses the first file found. + +```toml +# .commit-guard.toml +disable = ["signature", "body"] +``` + +```toml +# .commit-guard.toml +enable = ["subject", "imperative"] +``` + +CLI flags (`--enable`, `--disable`) take full precedence and ignore the config +file entirely when provided. + ### Checking a range of commits ```bash diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index a0dda05..f184eee 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -7,6 +7,7 @@ from pathlib import Path import nltk +import tomllib from nltk.corpus import wordnet TYPES = frozenset( @@ -51,6 +52,23 @@ class Check(StrEnum): ALL_CHECKS = frozenset(Check.__members__.values()) +def _load_config(start=None): + start = start or Path.cwd() + for directory in [start, *start.parents]: + config_path = directory / ".commit-guard.toml" + if config_path.exists(): + with config_path.open("rb") as f: + return tomllib.load(f) + return {} + + +def _parse_config_checks(config, key): + try: + return [Check(v) for v in config.get(key, [])] + except ValueError as e: + sys.exit(f".commit-guard.toml: {e}") + + class Level(StrEnum): ERROR = "error" WARN = "warn" @@ -220,12 +238,20 @@ def _parse_args(): help=f"skip these checks ({checks_list})", ) args = parser.parse_args() + config = _load_config() - enabled = ( - frozenset(_parse_checks(parser, args.enable)) if args.enable else ALL_CHECKS - ) - if args.disable: - enabled = enabled - frozenset(_parse_checks(parser, args.disable)) + if args.enable or args.disable: + enabled = ( + frozenset(_parse_checks(parser, args.enable)) if args.enable else ALL_CHECKS + ) + if args.disable: + enabled = enabled - frozenset(_parse_checks(parser, args.disable)) + elif config.get("enable"): + enabled = frozenset(_parse_config_checks(config, "enable")) + elif config.get("disable"): + enabled = ALL_CHECKS - frozenset(_parse_config_checks(config, "disable")) + else: + enabled = ALL_CHECKS if args.message_file: rev = None diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index 6b4150d..83a66df 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -8,7 +8,9 @@ _download_if_missing, _ensure_nltk_data, _get_message, + _load_config, _parse_checks, + _parse_config_checks, _report, _strip_comments, check_body, @@ -331,6 +333,41 @@ def test_other_git_error(self): _get_message("abc") +class TestLoadConfig: + def test_returns_empty_when_no_file(self, tmp_path): + assert _load_config(tmp_path) == {} + + def test_loads_file_in_start_dir(self, tmp_path): + (tmp_path / ".commit-guard.toml").write_text('disable = ["signature"]\n') + assert _load_config(tmp_path) == {"disable": ["signature"]} + + def test_loads_file_from_parent(self, tmp_path): + (tmp_path / ".commit-guard.toml").write_text('disable = ["body"]\n') + subdir = tmp_path / "sub" + subdir.mkdir() + assert _load_config(subdir) == {"disable": ["body"]} + + def test_first_found_wins(self, tmp_path): + (tmp_path / ".commit-guard.toml").write_text('disable = ["body"]\n') + subdir = tmp_path / "sub" + subdir.mkdir() + (subdir / ".commit-guard.toml").write_text('disable = ["signature"]\n') + assert _load_config(subdir) == {"disable": ["signature"]} + + +class TestParseConfigChecks: + def test_disable_list(self): + checks = _parse_config_checks({"disable": ["signature", "body"]}, "disable") + assert len(checks) == 2 # noqa: PLR2004 + + def test_missing_key_returns_empty(self): + assert _parse_config_checks({}, "disable") == [] + + def test_invalid_check_name_exits(self): + with pytest.raises(SystemExit, match=r"\.commit-guard\.toml"): + _parse_config_checks({"disable": ["bogus"]}, "disable") + + class TestParseChecks: def test_invalid_check_name(self): parser = ArgumentParser() @@ -462,3 +499,37 @@ def test_invalid_check_name_exits(self, tmp_path): pytest.raises(SystemExit), ): main() + + def test_config_disable_applied(self, tmp_path): + f = tmp_path / "msg" + f.write_text(_VALID_MSG) + disabled = {"disable": ["signature", "body", "signed-off", "imperative"]} + with ( + patch("sys.argv", ["cg", "--message-file", str(f)]), + patch("git_commit_guard._load_config", return_value=disabled), + ): + assert main() == 0 + + def test_config_enable_applied(self, tmp_path): + f = tmp_path / "msg" + f.write_text(_VALID_MSG) + with ( + patch("sys.argv", ["cg", "--message-file", str(f)]), + patch( + "git_commit_guard._load_config", + return_value={"enable": ["subject"]}, + ), + ): + assert main() == 0 + + def test_cli_overrides_config(self, tmp_path): + f = tmp_path / "msg" + f.write_text(_VALID_MSG) + with ( + patch("sys.argv", ["cg", "--message-file", str(f), "--enable", "subject"]), + patch( + "git_commit_guard._load_config", + return_value={"disable": ["subject"]}, + ), + ): + assert main() == 0