From 72061bba660f2df0f2ca6d7c1271b944fa18e467 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nerijus=20Bend=C5=BEi=C5=ABnas?= Date: Fri, 24 Apr 2026 10:20:24 +0300 Subject: [PATCH] feat: allow enforce minimum description length MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descriptions like 'fix: add x' pass all existing checks but carry no real information. --min-description-length (and the config key min-description-length) lets projects set a floor; default is 0 so existing repos are unaffected. Signed-off-by: Nerijus Bendžiūnas --- README.md | 18 ++++-- src/git_commit_guard/__init__.py | 24 ++++++- tests/test_git_commit_guard.py | 107 +++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 1852667..eae2618 100644 --- a/README.md +++ b/README.md @@ -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` @@ -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 @@ -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 @@ -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 diff --git a/src/git_commit_guard/__init__.py b/src/git_commit_guard/__init__.py index 4129a5f..39664d6 100644 --- a/src/git_commit_guard/__init__.py +++ b/src/git_commit_guard/__init__.py @@ -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, ): @@ -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 @@ -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 @@ -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(",")) @@ -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", @@ -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") @@ -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, @@ -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: diff --git a/tests/test_git_commit_guard.py b/tests/test_git_commit_guard.py index 9b62e41..cc3e85c 100644 --- a/tests/test_git_commit_guard.py +++ b/tests/test_git_commit_guard.py @@ -17,6 +17,7 @@ _parse_config_checks, _report, _resolve_max_subject_length, + _resolve_min_description_length, _resolve_types, _strip_comments, check_body, @@ -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"])) @@ -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 @@ -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 ") + 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 ") + 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 ") + 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 ") + 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 ")