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
18 changes: 13 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,13 @@ The default maximum subject line length is 72 characters. Override with
commit-guard --max-subject-length 100
```

By default there is no minimum description length. Enforce one with
`--min-description-length`:

```bash
commit-guard --min-description-length 10
```

### Type validation

By default the standard conventional commit types are accepted. Use `--types`
Expand Down Expand Up @@ -122,9 +129,9 @@ commit-guard --scopes auth,api --require-scope
### Configuration file

Place `.commit-guard.toml` in your project root (or any parent directory) to
set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`, and
`max-subject-length`. commit-guard searches upward from the working directory
and uses the first file found.
set defaults for `enable`, `disable`, `scopes`, `require-scope`, `types`,
`max-subject-length`, and `min-description-length`. commit-guard searches
upward from the working directory and uses the first file found.

```toml
# .commit-guard.toml
Expand All @@ -133,6 +140,7 @@ scopes = ["auth", "api", "db"]
require-scope = true
types = ["feat", "fix", "chore", "wip"]
max-subject-length = 100
min-description-length = 10
```

```toml
Expand All @@ -141,8 +149,8 @@ enable = ["subject", "imperative"]
```

CLI flags (`--enable`, `--disable`, `--scopes`, `--require-scope`, `--types`,
`--max-subject-length`) take full precedence and ignore config file values when
provided.
`--max-subject-length`, `--min-description-length`) take full precedence and
ignore config file values when provided.

### Checking a range of commits

Expand Down
24 changes: 23 additions & 1 deletion src/git_commit_guard/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,13 @@ def _strip_comments(message):
)


def check_subject( # noqa: PLR0913 Too many arguments in function definition (6 > 5)
def check_subject( # noqa: PLR0913 Too many arguments in function definition (7 > 5)
line,
result,
allowed_scopes=frozenset(),
allowed_types=TYPES,
max_subject_length=MAX_SUBJECT_LEN,
min_description_length=0,
*,
require_scope=False,
):
Expand All @@ -150,6 +151,8 @@ def check_subject( # noqa: PLR0913 Too many arguments in function definition (6
result.error("description must not end with period")
if len(line) > max_subject_length:
result.error(f"subject too long: {len(line)} > {max_subject_length}")
if min_description_length > 0 and len(desc) < min_description_length:
result.error(f"description too short: {len(desc)} < {min_description_length}")
return desc


Expand Down Expand Up @@ -250,6 +253,7 @@ class Args:
require_scope: bool
allowed_types: frozenset
max_subject_length: int
min_description_length: int
rev_range: str | None
allow_empty: bool
include_merges: bool
Expand Down Expand Up @@ -279,6 +283,14 @@ def _resolve_max_subject_length(args, config):
return MAX_SUBJECT_LEN


def _resolve_min_description_length(args, config):
if args.min_description_length is not None:
return args.min_description_length
if "min-description-length" in config:
return config["min-description-length"]
return 0


def _resolve_types(args, config):
if args.types:
return frozenset(t.strip() for t in args.types.split(","))
Expand Down Expand Up @@ -350,6 +362,13 @@ def _parse_args():
metavar="N",
help=f"maximum subject line length (default: {MAX_SUBJECT_LEN})",
)
parser.add_argument(
"--min-description-length",
type=int,
default=None,
metavar="N",
help="minimum description length in characters (default: 0, off)",
)
parser.add_argument(
"--range",
dest="rev_range",
Expand All @@ -374,6 +393,7 @@ def _parse_args():
allowed_scopes, require_scope = _resolve_scopes(args, config)
allowed_types = _resolve_types(args, config)
max_subject_length = _resolve_max_subject_length(args, config)
min_description_length = _resolve_min_description_length(args, config)

if args.allow_empty and not args.rev_range:
parser.error("--allow-empty requires --range")
Expand Down Expand Up @@ -406,6 +426,7 @@ def _parse_args():
require_scope=require_scope,
allowed_types=allowed_types,
max_subject_length=max_subject_length,
min_description_length=min_description_length,
rev_range=args.rev_range,
allow_empty=args.allow_empty,
include_merges=args.include_merges,
Expand All @@ -432,6 +453,7 @@ def _run_checks(args, rev, message, result):
args.allowed_scopes,
args.allowed_types,
args.max_subject_length,
args.min_description_length,
require_scope=args.require_scope,
)
if Check.IMPERATIVE in args.enabled:
Expand Down
107 changes: 107 additions & 0 deletions tests/test_git_commit_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
_parse_config_checks,
_report,
_resolve_max_subject_length,
_resolve_min_description_length,
_resolve_types,
_strip_comments,
check_body,
Expand Down Expand Up @@ -161,6 +162,22 @@ def test_custom_max_length_passes(self):
check_subject("fix: ok", r, max_subject_length=10)
assert r.ok

def test_min_description_length_zero_disables_check(self):
r = Result()
check_subject("fix: add x", r, min_description_length=0)
assert r.ok

def test_min_description_length_enforced(self):
r = Result()
check_subject("fix: add x", r, min_description_length=6)
assert not r.ok
assert any("description too short" in m for _, m in r.errors)

def test_min_description_length_exact_passes(self):
r = Result()
check_subject("fix: hello", r, min_description_length=5)
assert r.ok

def test_custom_type_passes(self):
r = Result()
check_subject("wip: add thing", r, allowed_types=frozenset(["wip"]))
Expand Down Expand Up @@ -455,6 +472,32 @@ def test_cli_overrides_config(self):
assert result == 50 # noqa: PLR2004 Magic value used in comparison, consider replacing 50 with a constant variable


class TestResolveMinDescriptionLength:
def test_defaults_to_zero(self):
result = _resolve_min_description_length(
Namespace(min_description_length=None), {}
)
assert result == 0

def test_cli_flag_overrides_default(self):
result = _resolve_min_description_length(
Namespace(min_description_length=10), {}
)
assert result == 10 # noqa: PLR2004 Magic value used in comparison, consider replacing 10 with a constant variable

def test_config_overrides_default(self):
result = _resolve_min_description_length(
Namespace(min_description_length=None), {"min-description-length": 8}
)
assert result == 8 # noqa: PLR2004 Magic value used in comparison, consider replacing 8 with a constant variable

def test_cli_overrides_config(self):
result = _resolve_min_description_length(
Namespace(min_description_length=10), {"min-description-length": 8}
)
assert result == 10 # noqa: PLR2004 Magic value used in comparison, consider replacing 10 with a constant variable


class TestResolveTypes:
def test_defaults_when_no_config_or_flag(self):
assert _resolve_types(Namespace(types=None), {}) == TYPES
Expand Down Expand Up @@ -812,6 +855,70 @@ def test_max_subject_length_cli_overrides_config(self, tmp_path):
):
assert main() == 0

def test_min_description_length_flag_passes(self, tmp_path):
f = tmp_path / "msg"
f.write_text("fix: add thing\n\nbody\n\nSigned-off-by: A User <a@b.com>")
argv = [
"cg",
"--message-file",
str(f),
"--disable",
"signature",
"--min-description-length",
"5",
]
with patch("sys.argv", argv):
assert main() == 0

def test_min_description_length_flag_fails(self, tmp_path):
f = tmp_path / "msg"
f.write_text("fix: add x\n\nbody\n\nSigned-off-by: A User <a@b.com>")
argv = [
"cg",
"--message-file",
str(f),
"--disable",
"signature,imperative",
"--min-description-length",
"6",
]
with patch("sys.argv", argv):
assert main() == 1

def test_min_description_length_from_config(self, tmp_path):
f = tmp_path / "msg"
f.write_text("fix: add x\n\nbody\n\nSigned-off-by: A User <a@b.com>")
argv = ["cg", "--message-file", str(f), "--disable", "signature,imperative"]
with (
patch("sys.argv", argv),
patch(
"git_commit_guard._load_config",
return_value={"min-description-length": 6},
),
):
assert main() == 1

def test_min_description_length_cli_overrides_config(self, tmp_path):
f = tmp_path / "msg"
f.write_text("fix: add x\n\nbody\n\nSigned-off-by: A User <a@b.com>")
argv = [
"cg",
"--message-file",
str(f),
"--disable",
"signature,imperative",
"--min-description-length",
"3",
]
with (
patch("sys.argv", argv),
patch(
"git_commit_guard._load_config",
return_value={"min-description-length": 6},
),
):
assert main() == 0

def test_types_cli_overrides_config(self, tmp_path):
f = tmp_path / "msg"
f.write_text("wip: add thing\n\nbody\n\nSigned-off-by: A User <a@b.com>")
Expand Down
Loading