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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from pathlib import Path

import nltk
import tomllib
from nltk.corpus import wordnet

TYPES = frozenset(
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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
Expand Down
71 changes: 71 additions & 0 deletions tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
_download_if_missing,
_ensure_nltk_data,
_get_message,
_load_config,
_parse_checks,
_parse_config_checks,
_report,
_strip_comments,
check_body,
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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
Loading