diff --git a/README.md b/README.md index ccb30f4..6e7f8c3 100644 --- a/README.md +++ b/README.md @@ -129,18 +129,19 @@ print(is_conventional("custom: this is a conventional commit", types=["custom"]) ```shell $ conventional-pre-commit -h -usage: conventional-pre-commit [-h] [--force-scope] [--strict] [types ...] input +usage: conventional-pre-commit [-h] [--force-scope] [--scopes SCOPES] [--strict] [types ...] input Check a git commit message for Conventional Commits formatting. positional arguments: - types Optional list of types to support - input A file containing a git commit message + types Optional list of types to support + input A file containing a git commit message options: - -h, --help show this help message and exit - --force-scope Force commit to have scope defined. - --strict Force commit to strictly follow Conventional Commits formatting. Disallows fixup! style commits. + -h, --help show this help message and exit + --force-scope Force commit to have scope defined. + --scopes SCOPES Optional list of scopes to support. Scopes should be separated by commas with no spaces (e.g. api,client) + --strict Force commit to strictly follow Conventional Commits formatting. Disallows fixup! style commits. ``` Supply arguments on the command-line, or via the pre-commit `hooks.args` property: diff --git a/conventional_pre_commit/format.py b/conventional_pre_commit/format.py index e79306f..672cb34 100644 --- a/conventional_pre_commit/format.py +++ b/conventional_pre_commit/format.py @@ -1,4 +1,5 @@ import re +from typing import List, Optional CONVENTIONAL_TYPES = ["feat", "fix"] DEFAULT_TYPES = [ @@ -26,8 +27,20 @@ def r_types(types): return "|".join(types) -def r_scope(optional=True): +def _get_scope_pattern(scopes: Optional[List[str]] = None): + scopes_str = r_types(scopes) + escaped_delimiters = list(map(re.escape, [":", ",", "-", "/"])) # type: ignore + delimiters_pattern = r_types(escaped_delimiters) + return rf"\(\s*(?:{scopes_str})(?:\s*(?:{delimiters_pattern})\s*(?:{scopes_str}))*\s*\)" + + +def r_scope(optional=True, scopes: Optional[List[str]] = None): """Regex str for an optional (scope).""" + + if scopes: + scopes_pattern = _get_scope_pattern(scopes) + return scopes_pattern + if optional: return r"(\([\w \/:,-]+\))?" else: @@ -79,7 +92,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, scopes: Optional[List[str]] = None): """ Returns True if input matches Conventional Commits formatting https://www.conventionalcommits.org @@ -89,7 +102,7 @@ def is_conventional(input, types=DEFAULT_TYPES, optional_scope=True): input = strip_verbose_diff(input) input = strip_comments(input) types = conventional_types(types) - pattern = f"^({r_types(types)}){r_scope(optional_scope)}{r_delim()}{r_subject()}{r_body()}" + pattern = f"^({r_types(types)}){r_scope(optional_scope, scopes=scopes)}{r_delim()}{r_subject()}{r_body()}" regex = re.compile(pattern, re.MULTILINE) result = regex.match(input) diff --git a/conventional_pre_commit/hook.py b/conventional_pre_commit/hook.py index b0d0124..25d6090 100644 --- a/conventional_pre_commit/hook.py +++ b/conventional_pre_commit/hook.py @@ -23,6 +23,12 @@ def main(argv=[]): parser.add_argument( "--force-scope", action="store_false", default=True, dest="optional_scope", help="Force commit to have scope defined." ) + parser.add_argument( + "--scopes", + type=str, + default=None, + help="Optional list of scopes to support. Scopes should be separated by commas with no spaces (e.g. api,client)", + ) parser.add_argument( "--strict", action="store_true", @@ -51,12 +57,16 @@ def main(argv=[]): """ ) return RESULT_FAIL + if args.scopes: + scopes = args.scopes.split(",") + else: + scopes = args.scopes if not args.strict: 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, scopes): return RESULT_SUCCESS else: print( diff --git a/tests/conftest.py b/tests/conftest.py index b056cb7..83034ee 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -52,3 +52,8 @@ def conventional_commit_bad_multi_line_path(): @pytest.fixture def conventional_commit_multi_line_path(): return get_message_path("conventional_commit_multi_line") + + +@pytest.fixture +def conventional_commit_with_multiple_scopes_path(): + return get_message_path("conventional_commit_with_multiple_scopes") diff --git a/tests/messages/conventional_commit_with_multiple_scopes b/tests/messages/conventional_commit_with_multiple_scopes new file mode 100644 index 0000000..77fec69 --- /dev/null +++ b/tests/messages/conventional_commit_with_multiple_scopes @@ -0,0 +1 @@ +feat(api,client): added new endpoint with client support diff --git a/tests/test_format.py b/tests/test_format.py index 6b9507b..6940a21 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -64,6 +64,20 @@ def test_r_scope__special_chars(): assert regex.match("(some,thing)") +def test_r_scope__scopes(): + scopes_input = ["api", "client"] + result = format.r_scope(scopes=scopes_input) + regex = re.compile(result) + assert regex.match("(api)") + assert regex.match("(client)") + assert regex.match("(api, client)") + assert regex.match("(api: client)") + assert regex.match("(api/client)") + assert regex.match("(api-client)") + assert not regex.match("(test)") + assert not regex.match("(api; client)") + + def test_r_delim(): result = format.r_delim() regex = re.compile(result) diff --git a/tests/test_hook.py b/tests/test_hook.py index d842e93..f2d1ce6 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -142,6 +142,16 @@ def test_subprocess_success__conventional_with_scope(cmd, conventional_commit_wi assert result == RESULT_SUCCESS +def test_subprocess_success__conventional_with_multiple_scopes(cmd, conventional_commit_with_multiple_scopes_path): + result = subprocess.call((cmd, "--scopes", "api,client", conventional_commit_with_multiple_scopes_path)) + assert result == RESULT_SUCCESS + + +def test_subprocess_fail__conventional_with_multiple_scopes(cmd, conventional_commit_with_multiple_scopes_path): + result = subprocess.call((cmd, "--scopes", "api", conventional_commit_with_multiple_scopes_path)) + assert result == RESULT_FAIL + + def test_subprocess_success__fixup_commit(cmd, fixup_commit_path): result = subprocess.call((cmd, fixup_commit_path))