From bebe84a8d7f003ea7fb3f2b92fb3f9f0d122d22a Mon Sep 17 00:00:00 2001 From: grahamhar Date: Tue, 31 Oct 2023 18:10:32 +0000 Subject: [PATCH] feat: Validate multi-line messages The documented conventional commit specification states that on multi-line commit message there must be a blank line between the title and the body. To avoid a breaking change this has been toggled using the strict option, in the next major release this toggle will be removed. --- conventional_pre_commit/format.py | 22 ++++++-- conventional_pre_commit/hook.py | 13 ++++- tests/conftest.py | 10 ++++ .../conventional_commit_bad_multi_line | 2 + tests/messages/conventional_commit_multi_line | 3 + tests/test_format.py | 55 ++++++++++++++++++- tests/test_hook.py | 18 ++++++ 7 files changed, 112 insertions(+), 11 deletions(-) create mode 100644 tests/messages/conventional_commit_bad_multi_line create mode 100644 tests/messages/conventional_commit_multi_line diff --git a/conventional_pre_commit/format.py b/conventional_pre_commit/format.py index 97766cc..075ea16 100644 --- a/conventional_pre_commit/format.py +++ b/conventional_pre_commit/format.py @@ -40,8 +40,13 @@ def r_delim(): def r_subject(): - """Regex str for subject line, body, footer.""" - return r" .+" + """Regex str for subject line.""" + return r" .+$" + + +def r_body(): + """Regex str for the body""" + return r"(?P\r?\n(?P^$\r?\n)?.+)?" def r_autosquash_prefixes(): @@ -56,7 +61,7 @@ def conventional_types(types=[]): return types -def is_conventional(input, types=DEFAULT_TYPES, optional_scope=True): +def is_conventional(input, types=DEFAULT_TYPES, optional_scope=True, is_strict=False): """ Returns True if input matches Conventional Commits formatting https://www.conventionalcommits.org @@ -64,10 +69,15 @@ def is_conventional(input, types=DEFAULT_TYPES, optional_scope=True): Optionally provide a list of additional custom types. """ types = conventional_types(types) - pattern = f"^({r_types(types)}){r_scope(optional_scope)}{r_delim()}{r_subject()}$" - regex = re.compile(pattern, re.DOTALL) + pattern = f"^({r_types(types)}){r_scope(optional_scope)}{r_delim()}{r_subject()}{r_body()}" + regex = re.compile(pattern, re.MULTILINE) - return bool(regex.match(input)) + result = regex.match(input) + is_valid_subject = bool(result) + if is_valid_subject and is_strict and result.group("multi") and not result.group("sep"): + is_valid_subject = False + + return is_valid_subject def has_autosquash_prefix(input): diff --git a/conventional_pre_commit/hook.py b/conventional_pre_commit/hook.py index 1c18ec1..beda039 100644 --- a/conventional_pre_commit/hook.py +++ b/conventional_pre_commit/hook.py @@ -54,7 +54,7 @@ def main(argv=[]): if format.has_autosquash_prefix(message): return RESULT_SUCCESS - if format.is_conventional(message, args.types, args.optional_scope): + if format.is_conventional(message, args.types, args.optional_scope, args.strict): return RESULT_SUCCESS else: print( @@ -64,7 +64,7 @@ def main(argv=[]): {Colors.LBLUE}https://www.conventionalcommits.org/{Colors.YELLOW} Conventional Commits start with one of the below types, followed by a colon, - followed by the commit message:{Colors.RESTORE} + followed by the commit subject and an optional body seperated by a blank line:{Colors.RESTORE} {" ".join(format.conventional_types(args.types))} @@ -78,7 +78,14 @@ def main(argv=[]): {Colors.YELLOW}Example commit with scope in parentheses after the type for more context:{Colors.RESTORE} - fix(account): remove infinite loop""" + fix(account): remove infinite loop + + {Colors.YELLOW}Example commit with a body + + fix: remove infinite loop + + Additional information on the issue caused by the infinite loop + """ ) return RESULT_FAIL diff --git a/tests/conftest.py b/tests/conftest.py index b9cb04f..b056cb7 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,3 +42,13 @@ def conventional_gbk_commit_path(): @pytest.fixture def fixup_commit_path(): return get_message_path("fixup_commit") + + +@pytest.fixture +def conventional_commit_bad_multi_line_path(): + return get_message_path("conventional_commit_bad_multi_line") + + +@pytest.fixture +def conventional_commit_multi_line_path(): + return get_message_path("conventional_commit_multi_line") diff --git a/tests/messages/conventional_commit_bad_multi_line b/tests/messages/conventional_commit_bad_multi_line new file mode 100644 index 0000000..4b18576 --- /dev/null +++ b/tests/messages/conventional_commit_bad_multi_line @@ -0,0 +1,2 @@ +fix: message +no blank line diff --git a/tests/messages/conventional_commit_multi_line b/tests/messages/conventional_commit_multi_line new file mode 100644 index 0000000..92c7c1c --- /dev/null +++ b/tests/messages/conventional_commit_multi_line @@ -0,0 +1,3 @@ +fix: message + +A blank line is there diff --git a/tests/test_format.py b/tests/test_format.py index 78491b0..eb0a545 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -162,13 +162,64 @@ def test_is_conventional__with_scope(): assert format.is_conventional(input) -def test_is_conventional__body_multiline(): +def test_is_conventional__body_multiline_not_strict(): input = """feat(scope): message more message """ - assert format.is_conventional(input) + assert format.is_conventional(input, is_strict=False) + + +def test_is_conventional__body_multiline_no_body_not_strict(): + input = """feat(scope): message + + """ + assert format.is_conventional(input, is_strict=False) + + +def test_is_conventional__body_multiline_body_bad_type_strict(): + input = """wrong: message + + more_message + """ + + assert not format.is_conventional(input, is_strict=True) + + +def test_is_conventional__bad_body_multiline_not_strict(): + input = """feat(scope): message + more message + """ + + assert format.is_conventional(input, is_strict=False) + + +def test_is_conventional__bad_body_multiline_strict(): + input = """feat(scope): message + more message + """ + + assert not format.is_conventional(input, is_strict=True) + + +def test_is_conventional__body_multiline_strict(): + input = """feat(scope): message + + more message + """ + + assert format.is_conventional(input, is_strict=True) + + +def test_is_conventional__bad_body_multiline_paragraphs_strict(): + input = """feat(scope): message + more message + + more body message + """ + + assert not format.is_conventional(input, is_strict=True) @pytest.mark.parametrize("char", ['"', "'", "`", "#", "&"]) diff --git a/tests/test_hook.py b/tests/test_hook.py index e56e8f4..7a47607 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -116,3 +116,21 @@ def test_main_success__fail_commit(cmd, fixup_commit_path): result = subprocess.call((cmd, "--strict", fixup_commit_path)) assert result == RESULT_FAIL + + +def test_main_success__conventional_commit_multi_line(cmd, conventional_commit_multi_line_path): + result = subprocess.call((cmd, conventional_commit_multi_line_path)) + + assert result == RESULT_SUCCESS + + +def test_main_success__conventional_commit_bad_multi_line(cmd, conventional_commit_bad_multi_line_path): + result = subprocess.call((cmd, conventional_commit_bad_multi_line_path)) + + assert result == RESULT_SUCCESS + + +def test_main_success__conventional_commit_bad_multi_line_strict(cmd, conventional_commit_bad_multi_line_path): + result = subprocess.call((cmd, "--strict", conventional_commit_bad_multi_line_path)) + + assert result == RESULT_FAIL