diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 61bbe00..cee0f60 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -1,6 +1,6 @@ { "name": "compilerla/conventional-pre-commit", - "dockerComposeFile": ["./compose.yml"], + "dockerComposeFile": ["../compose.yml"], "service": "dev", "runServices": ["dev"], "workspaceFolder": "/home/compiler/src", diff --git a/README.md b/README.md index 15d6d3d..aae0870 100644 --- a/README.md +++ b/README.md @@ -15,9 +15,13 @@ Create a blank configuration file at the root of your repo, if needed: touch .pre-commit-config.yaml ``` -Add a new repo entry to your configuration file: +Add/update `default_install_hook_types` and add a new repo entry in your configuration file: ```yaml +default_install_hook_types: + - pre-commit + - commit-msg + repos: # - repo: ... @@ -32,7 +36,7 @@ repos: Install the `pre-commit` script: ```console -pre-commit install --hook-type commit-msg +pre-commit install --install-hooks ``` Make a (normal) commit :x:: @@ -74,9 +78,7 @@ Conventional Commit messages follow a pattern like: Please correct the following errors: - - Expected value for 'type' but found none. - - Expected value for 'delim' but found none. - - Expected value for 'subject' but found none. + - Expected value for type from: build, chore, ci, docs, feat, fix, perf, refactor, revert, style, test Run: @@ -151,8 +153,8 @@ options: -h, --help show this help message and exit --no-color Disable color in output. --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. + --scopes SCOPES 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! and merge commits. --verbose Print more verbose error output. ``` diff --git a/.devcontainer/compose.yml b/compose.yml similarity index 78% rename from .devcontainer/compose.yml rename to compose.yml index 6538c74..435ddae 100644 --- a/.devcontainer/compose.yml +++ b/compose.yml @@ -1,10 +1,10 @@ services: dev: build: - context: .. + context: . dockerfile: .devcontainer/Dockerfile entrypoint: [] command: sleep infinity image: compilerla/conventional-pre-commit:dev volumes: - - ../:/home/compiler/src + - ./:/home/compiler/src diff --git a/conventional_pre_commit/format.py b/conventional_pre_commit/format.py index dc283d3..3eab9e3 100644 --- a/conventional_pre_commit/format.py +++ b/conventional_pre_commit/format.py @@ -1,161 +1,247 @@ import re -from typing import List, Optional - -CONVENTIONAL_TYPES = ["feat", "fix"] -DEFAULT_TYPES = [ - "build", - "chore", - "ci", - "docs", - "feat", - "fix", - "perf", - "refactor", - "revert", - "style", - "test", -] -AUTOSQUASH_PREFIXES = [ - "amend", - "fixup", - "squash", -] - - -def r_types(types): - """Join types with pipe "|" to form regex ORs.""" - return "|".join(types) - - -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) - if optional: - return f"(?:{scopes_pattern})?" - else: - return scopes_pattern - - if optional: - return r"(\([\w \/:,-]+\))?" - else: - return r"(\([\w \/:,-]+\))" - - -def r_delim(): - """Regex str for optional breaking change indicator and colon delimiter.""" - return r"!?:" - - -def r_subject(): - """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(): - """Regex str for autosquash prefixes.""" - return "|".join(AUTOSQUASH_PREFIXES) - - -def r_verbose_commit_ignored(): - """Regex str for the ignored part of verbose commit message templates""" - return r"^# -{24} >8 -{24}\r?\n.*\Z" - - -def strip_verbose_commit_ignored(input): - """Strip the ignored part of verbose commit message templates.""" - return re.sub(r_verbose_commit_ignored(), "", input, flags=re.DOTALL | re.MULTILINE) - - -def r_comment(): - """Regex str for comment""" - return r"^#.*\r?\n?" - - -def strip_comments(input): - return re.sub(r_comment(), "", input, flags=re.MULTILINE) - +from typing import List -def conventional_types(types=[]): - """Return a list of Conventional Commits types merged with the given types.""" - if set(types) & set(CONVENTIONAL_TYPES) == set(): - return CONVENTIONAL_TYPES + types - return types - -def conventional_regex(types=DEFAULT_TYPES, optional_scope=True, scopes: Optional[List[str]] = None): - types = conventional_types(types) - - types_pattern = f"^(?P{r_types(types)})?" - scope_pattern = f"(?P{r_scope(optional_scope, scopes=scopes)})?" - delim_pattern = f"(?P{r_delim()})?" - subject_pattern = f"(?P{r_subject()})?" - body_pattern = f"(?P{r_body()})?" - pattern = types_pattern + scope_pattern + delim_pattern + subject_pattern + body_pattern - - return re.compile(pattern, re.MULTILINE) - - -def clean_input(input: str): +class Commit: """ - Prepares an input message for conventional commits format check. + Base class for inspecting commit message formatting. """ - input = strip_verbose_commit_ignored(input) - input = strip_comments(input) - return input - -def conventional_match(input: str, types=DEFAULT_TYPES, optional_scope=True, scopes: Optional[List[str]] = None): - """ - Returns an `re.Match` object for the input against the Conventional Commits format. + AUTOSQUASH_PREFIXES = sorted( + [ + "amend", + "fixup", + "squash", + ] + ) + + def __init__(self, commit_msg: str = ""): + self.message = str(commit_msg) + self.message = self.clean() + + @property + def r_autosquash_prefixes(self): + """Regex str for autosquash prefixes.""" + return self._r_or(self.AUTOSQUASH_PREFIXES) + + @property + def r_verbose_commit_ignored(self): + """Regex str for the ignored part of a verbose commit message.""" + return r"^# -{24} >8 -{24}\r?\n.*\Z" + + @property + def r_comment(self): + """Regex str for comments.""" + return r"^#.*\r?\n?" + + def _r_or(self, items): + """Join items with pipe "|" to form regex ORs.""" + return "|".join(items) + + def _strip_comments(self, commit_msg: str = ""): + """Strip comments from a commit message.""" + commit_msg = commit_msg or self.message + return re.sub(self.r_comment, "", commit_msg, flags=re.MULTILINE) + + def _strip_verbose_commit_ignored(self, commit_msg: str = ""): + """Strip the ignored part of a verbose commit message.""" + commit_msg = commit_msg or self.message + return re.sub(self.r_verbose_commit_ignored, "", commit_msg, flags=re.DOTALL | re.MULTILINE) + + def clean(self, commit_msg: str = ""): + """ + Removes comments and ignored verbose commit segments from a commit message. + """ + commit_msg = commit_msg or self.message + commit_msg = self._strip_verbose_commit_ignored(commit_msg) + commit_msg = self._strip_comments(commit_msg) + return commit_msg + + def has_autosquash_prefix(self, commit_msg: str = ""): + """ + Returns True if input starts with one of the autosquash prefixes used in git. + See the documentation, please https://git-scm.com/docs/git-rebase. + """ + commit_msg = self.clean(commit_msg) + pattern = f"^(({self.r_autosquash_prefixes})! ).*$" + regex = re.compile(pattern, re.DOTALL) + + return bool(regex.match(commit_msg)) + + def is_merge(self, commit_msg: str = ""): + """ + Returns True if input starts with "Merge branch" + See the documentation, please https://git-scm.com/docs/git-merge. + """ + commit_msg = self.clean(commit_msg) + return commit_msg.lower().startswith("merge branch ") + + +class ConventionalCommit(Commit): """ - input = clean_input(input) - regex = conventional_regex(types, optional_scope, scopes) - return regex.match(input) - + Impelements checks for Conventional Commits formatting. -def is_conventional(input: str, types=DEFAULT_TYPES, optional_scope=True, scopes: Optional[List[str]] = None) -> bool: - """ - Returns True if input matches Conventional Commits formatting https://www.conventionalcommits.org - - Optionally provide a list of additional custom types. """ - result = conventional_match(input, types, optional_scope, scopes) - is_valid = bool(result) - if result and result.group("multi") and not result.group("sep"): - is_valid = False - if result and not all( - [result.group("type"), optional_scope or result.group("scope"), result.group("delim"), result.group("subject")] + CONVENTIONAL_TYPES = sorted(["feat", "fix"]) + DEFAULT_TYPES = sorted( + CONVENTIONAL_TYPES + + [ + "build", + "chore", + "ci", + "docs", + "perf", + "refactor", + "revert", + "style", + "test", + ] + ) + + def __init__( + self, commit_msg: str = "", types: List[str] = DEFAULT_TYPES, scope_optional: bool = True, scopes: List[str] = [] ): - is_valid = False - - return is_valid + super().__init__(commit_msg) - -def has_autosquash_prefix(input): + if set(types) & set(self.CONVENTIONAL_TYPES) == set(): + self.types = self.CONVENTIONAL_TYPES + types + else: + self.types = types + self.types = sorted(self.types) if self.types else self.DEFAULT_TYPES + self.scope_optional = scope_optional + self.scopes = sorted(scopes) if scopes else [] + + @property + def r_types(self): + """Regex str for valid types.""" + return self._r_or(self.types) + + @property + def r_scope(self): + """Regex str for an optional (scope).""" + if self.scopes: + scopes = self._r_or(self.scopes) + escaped_delimiters = list(map(re.escape, [":", ",", "-", "/"])) # type: ignore + delimiters_pattern = self._r_or(escaped_delimiters) + scope_pattern = rf"\(\s*(?:{scopes})(?:\s*(?:{delimiters_pattern})\s*(?:{scopes}))*\s*\)" + + if self.scope_optional: + return f"(?:{scope_pattern})?" + else: + return scope_pattern + + if self.scope_optional: + return r"(\([\w \/:,-]+\))?" + else: + return r"(\([\w \/:,-]+\))" + + @property + def r_delim(self): + """Regex str for optional breaking change indicator and colon delimiter.""" + return r"!?:" + + @property + def r_subject(self): + """Regex str for subject line.""" + return r" .+$" + + @property + def r_body(self): + """Regex str for the body, with multiline support.""" + return r"(?P\r?\n(?P^$\r?\n)?.+)?" + + @property + def regex(self): + """`re.Pattern` for ConventionalCommits formatting.""" + types_pattern = f"^(?P{self.r_types})?" + scope_pattern = f"(?P{self.r_scope})?" + delim_pattern = f"(?P{self.r_delim})?" + subject_pattern = f"(?P{self.r_subject})?" + body_pattern = f"(?P{self.r_body})?" + pattern = types_pattern + scope_pattern + delim_pattern + subject_pattern + body_pattern + + return re.compile(pattern, re.MULTILINE) + + def errors(self, commit_msg: str = "") -> List[str]: + """ + Return a list of missing Conventional Commit components from a commit message. + """ + match = self.match(commit_msg) + groups = match.groupdict() if match else {} + + # With a type error, the rest of the components will be unmatched + # even if the overall structure of the commit is correct, + # since a correct type must come first. + # + # E.g. with an invalid type: + # + # invalid: this is a commit + # + # The delim, subject, and body components would all be missing from the match + # there's no need to notify on the other components when the type is invalid + if not groups.get("type"): + groups.pop("delim", None) + groups.pop("subject", None) + groups.pop("body", None) + + if self.scope_optional: + groups.pop("scope", None) + + if not groups.get("body"): + groups.pop("multi", None) + groups.pop("sep", None) + + return [g for g, v in groups.items() if not v] + + def is_valid(self, commit_msg: str = "") -> bool: + """ + Returns True if commit_msg matches Conventional Commits formatting. + https://www.conventionalcommits.org + """ + match = self.match(commit_msg) + + # match all the required components + # + # type(scope): subject + # + # extended body + # + return bool(match) and all( + [ + match.group("type"), + self.scope_optional or match.group("scope"), + match.group("delim"), + match.group("subject"), + any( + [ + # no extra body; OR + not match.group("body"), + # a multiline body with proper separator + match.group("multi") and match.group("sep"), + ] + ), + ] + ) + + def match(self, commit_msg: str = ""): + """ + Returns an `re.Match` object for the input against the Conventional Commits format. + """ + commit_msg = self.clean(commit_msg) or self.message + return self.regex.match(commit_msg) + + +def is_conventional( + input: str, types: List[str] = ConventionalCommit.DEFAULT_TYPES, optional_scope: bool = True, scopes: List[str] = [] +) -> bool: """ - Returns True if input starts with one of the autosquash prefixes used in git. - See the documentation, please https://git-scm.com/docs/git-rebase. + Returns True if input matches Conventional Commits formatting + https://www.conventionalcommits.org - It doesn't check whether the rest of the input matches Conventional Commits - formatting. + Optionally provide a list of additional custom types. """ - pattern = f"^(({r_autosquash_prefixes()})! ).*$" - regex = re.compile(pattern, re.DOTALL) + commit = ConventionalCommit(commit_msg=input, types=types, scope_optional=optional_scope, scopes=scopes) - return bool(regex.match(input)) + return commit.is_valid() diff --git a/conventional_pre_commit/hook.py b/conventional_pre_commit/hook.py index 5ffb308..8c144e0 100644 --- a/conventional_pre_commit/hook.py +++ b/conventional_pre_commit/hook.py @@ -1,7 +1,8 @@ import argparse import sys -from conventional_pre_commit import format, output +from conventional_pre_commit import output +from conventional_pre_commit.format import ConventionalCommit RESULT_SUCCESS = 0 RESULT_FAIL = 1 @@ -11,7 +12,9 @@ def main(argv=[]): parser = argparse.ArgumentParser( prog="conventional-pre-commit", description="Check a git commit message for Conventional Commits formatting." ) - parser.add_argument("types", type=str, nargs="*", default=format.DEFAULT_TYPES, help="Optional list of types to support") + parser.add_argument( + "types", type=str, nargs="*", default=ConventionalCommit.DEFAULT_TYPES, help="Optional list of types to support" + ) parser.add_argument("input", type=str, help="A file containing a git commit message") parser.add_argument("--no-color", action="store_false", default=True, dest="color", help="Disable color in output.") parser.add_argument( @@ -21,12 +24,12 @@ def main(argv=[]): "--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)", + help="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", - help="Force commit to strictly follow Conventional Commits formatting. Disallows fixup! style commits.", + help="Force commit to strictly follow Conventional Commits formatting. Disallows fixup! and merge commits.", ) parser.add_argument( "--verbose", @@ -55,23 +58,23 @@ def main(argv=[]): else: scopes = args.scopes + commit = ConventionalCommit(commit_msg, args.types, args.optional_scope, scopes) + if not args.strict: - if format.has_autosquash_prefix(commit_msg): + if commit.has_autosquash_prefix(): + return RESULT_SUCCESS + if commit.is_merge(): return RESULT_SUCCESS - if format.is_conventional(commit_msg, args.types, args.optional_scope, scopes): + if commit.is_valid(): return RESULT_SUCCESS - print(output.fail(commit_msg, use_color=args.color)) + print(output.fail(commit, use_color=args.color)) if not args.verbose: print(output.verbose_arg(use_color=args.color)) else: - print( - output.fail_verbose( - commit_msg, types=args.types, optional_scope=args.optional_scope, scopes=scopes, use_color=args.color - ) - ) + print(output.fail_verbose(commit, use_color=args.color)) return RESULT_FAIL diff --git a/conventional_pre_commit/output.py b/conventional_pre_commit/output.py index 8d71385..e957e9c 100644 --- a/conventional_pre_commit/output.py +++ b/conventional_pre_commit/output.py @@ -1,7 +1,6 @@ import os -from typing import List, Optional -from conventional_pre_commit import format +from conventional_pre_commit.format import ConventionalCommit class Colors: @@ -30,10 +29,10 @@ def yellow(self): return self.YELLOW if self.enabled else "" -def fail(commit_msg, use_color=True): +def fail(commit: ConventionalCommit, use_color=True): c = Colors(use_color) lines = [ - f"{c.red}[Bad commit message] >>{c.restore} {commit_msg}" + f"{c.red}[Bad commit message] >>{c.restore} {commit.message}" f"{c.yellow}Your commit message does not follow Conventional Commits formatting{c.restore}", f"{c.blue}https://www.conventionalcommits.org/{c.restore}", ] @@ -49,11 +48,8 @@ def verbose_arg(use_color=True): return os.linesep.join(lines) -def fail_verbose( - commit_msg: str, types=format.DEFAULT_TYPES, optional_scope=True, scopes: Optional[List[str]] = None, use_color=True -): +def fail_verbose(commit: ConventionalCommit, use_color=True): c = Colors(use_color) - match = format.conventional_match(commit_msg, types, optional_scope, scopes) lines = [ "", f"{c.yellow}Conventional Commit messages follow a pattern like:", @@ -64,24 +60,22 @@ def fail_verbose( "", ] - groups = match.groupdict() if match else {} + def _options(opts): + formatted_opts = f"{c.yellow}, {c.blue}".join(opts) + return f"{c.blue}{formatted_opts}" - if optional_scope: - groups.pop("scope", None) - - if not groups.get("body"): - groups.pop("body", None) - groups.pop("multi", None) - groups.pop("sep", None) - - if groups.keys(): + errors = commit.errors() + if errors: lines.append(f"{c.yellow}Please correct the following errors:{c.restore}") lines.append("") - for group in [g for g, v in groups.items() if not v]: - if group == "scope": - if scopes: - scopt_opts = f"{c.yellow},{c.restore}".join(scopes) - lines.append(f"{c.yellow} - Expected value for {c.restore}scope{c.yellow} from: {c.restore}{scopt_opts}") + for group in errors: + if group == "type": + type_opts = _options(commit.types) + lines.append(f"{c.yellow} - Expected value for {c.restore}type{c.yellow} from: {type_opts}") + elif group == "scope": + if commit.scopes: + scopt_opts = _options(commit.scopes) + lines.append(f"{c.yellow} - Expected value for {c.restore}scope{c.yellow} from: {scopt_opts}") else: lines.append(f"{c.yellow} - Expected value for {c.restore}scope{c.yellow} but found none.{c.restore}") else: diff --git a/tests/conftest.py b/tests/conftest.py index 83034ee..bab7488 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -44,6 +44,11 @@ def fixup_commit_path(): return get_message_path("fixup_commit") +@pytest.fixture +def merge_commit_path(): + return get_message_path("merge_commit") + + @pytest.fixture def conventional_commit_bad_multi_line_path(): return get_message_path("conventional_commit_bad_multi_line") diff --git a/tests/messages/merge_commit b/tests/messages/merge_commit new file mode 100644 index 0000000..3cfe63b --- /dev/null +++ b/tests/messages/merge_commit @@ -0,0 +1 @@ +Merge branch '2.x.x' into '1.x.x' diff --git a/tests/test_format.py b/tests/test_format.py index e57d3d8..6ad99eb 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -2,171 +2,109 @@ import pytest -from conventional_pre_commit import format +from conventional_pre_commit.format import Commit, ConventionalCommit, is_conventional CUSTOM_TYPES = ["one", "two"] -def test_r_types(): - result = format.r_types(CUSTOM_TYPES) - regex = re.compile(result) - - assert regex.match("one") - assert regex.match("two") - - -def test_r_scope__optional(): - result = format.r_scope() - regex = re.compile(result) - - assert regex.match("") - - -def test_r_scope__not_optional(): - result = format.r_scope(optional=False) - regex = re.compile(result) - - # Assert not optional anymore - assert not regex.match("") - - -def test_r_scope__parenthesis_required(): - result = format.r_scope() - regex = re.compile(result) - - # without parens produces a match object with a 0 span - # since the (scope) is optional - without_parens = regex.match("something") - assert without_parens.span() == (0, 0) - - # with parens produces a match object with a span - # that covers the input string - with_parens = regex.match("(something)") - assert with_parens.span() == (0, 11) - - -def test_r_scope__alphanumeric(): - result = format.r_scope() - regex = re.compile(result) - - assert regex.match("(50m3t41N6)") - - -def test_r_scope__special_chars(): - result = format.r_scope() - regex = re.compile(result) +@pytest.fixture +def commit() -> Commit: + return Commit() - assert regex.match("(some-thing)") - assert regex.match("(some_thing)") - assert regex.match("(some/thing)") - assert regex.match("(some thing)") - assert regex.match("(some:thing)") - assert regex.match("(some,thing)") - - -def test_r_scope__scopes(): - scopes_input = ["api", "client"] - result = format.r_scope(scopes=scopes_input, optional=False) - 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) - - assert regex.match(":") - - -def test_r_delim__optional_breaking_indicator(): - result = format.r_delim() - regex = re.compile(result) - assert regex.match("!:") +@pytest.fixture +def conventional_commit() -> ConventionalCommit: + return ConventionalCommit() -def test_r_subject__starts_with_space(): - result = format.r_subject() - regex = re.compile(result) +@pytest.fixture +def conventional_commit_scope_required(conventional_commit) -> ConventionalCommit: + conventional_commit.scope_optional = False + return conventional_commit - assert not regex.match("something") - assert regex.match(" something") +def test_commit_init(): + input = ( + """feat: some commit message +# Please enter the commit message for your changes. Lines starting +# with '#' will be ignored, and an empty message aborts the commit. +# +# On branch main +# Your branch is up to date with 'origin/main'. +# +# Changes to be committed: +# modified: README.md +# +# Changes not staged for commit: +# modified: README.md +# +# ------------------------ >8 ------------------------ +# Do not modify or remove the line above. +# Everything below it will be ignored. +diff --git c/README.md i/README.md +index ea80a93..fe8a527 100644 +--- c/README.md ++++ i/README.md +@@ -20,3 +20,4 @@ Some hunk header + Context 1 +""" + + " " # This is on purpose to preserve the space from overly eager stripping. + + """ + Context 2 ++Added line +""" + ) -def test_r_subject__alphanumeric(): - result = format.r_subject() - regex = re.compile(result) + expected = "feat: some commit message\n" - assert regex.match(" 50m3t41N6") + assert Commit(input).message == expected -def test_r_subject__special_chars(): - result = format.r_subject() +def test_r_or(commit): + result = commit._r_or(CUSTOM_TYPES) regex = re.compile(result) - assert regex.match(" some-thing") - assert regex.match(" some_thing") - assert regex.match(" some/thing") - assert regex.match(" some thing") + for item in CUSTOM_TYPES: + assert regex.match(item) -def test_r_autosquash_prefixes(): - result = format.r_autosquash_prefixes() - regex = re.compile(result) +def test_r_autosquash_prefixes(commit): + regex = re.compile(commit.r_autosquash_prefixes) - for prefix in format.AUTOSQUASH_PREFIXES: + for prefix in commit.AUTOSQUASH_PREFIXES: assert regex.match(prefix) -def test_conventional_types__default(): - result = format.conventional_types() - - assert result == format.CONVENTIONAL_TYPES - - -def test_conventional_types__custom(): - result = format.conventional_types(["custom"]) - - assert set(["custom", *format.CONVENTIONAL_TYPES]) == set(result) - - -def test_r_comment_single(): - regex = re.compile(format.r_comment()) +def test_r_comment_single(commit): + regex = re.compile(commit.r_comment) assert regex.match("# Some comment") assert not regex.match("Some comment") assert not regex.match(" # Some comment") -def test_strip_comments__consecutive(): +def test_strip_comments__consecutive(commit): input = """feat(scope): message # Please enter the commit message for your changes. # These are comments usually added by editors, f.ex. with export EDITOR=vim """ - result = format.strip_comments(input) + result = commit._strip_comments(input) assert result.count("\n") == 1 assert result.strip() == "feat(scope): message" -def test_strip_comments__spaced(): +def test_strip_comments__spaced(commit): input = """feat(scope): message # Please enter the commit message for your changes. # These are comments usually added by editors, f.ex. with export EDITOR=vim """ - result = format.strip_comments(input) + result = commit._strip_comments(input) assert result.count("\n") == 2 assert result.strip() == "feat(scope): message" -def test_r_verbose_commit_ignored__does_not_match_no_verbose(): - regex = re.compile(format.r_verbose_commit_ignored(), re.DOTALL | re.MULTILINE) +def test_r_verbose_commit_ignored__does_not_match_no_verbose(commit): + regex = re.compile(commit.r_verbose_commit_ignored, re.DOTALL | re.MULTILINE) input = """feat: some commit message # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. @@ -185,8 +123,8 @@ def test_r_verbose_commit_ignored__does_not_match_no_verbose(): assert not regex.search(input) -def test_r_verbose_commit_ignored__matches_single_verbose_ignored(): - regex = re.compile(format.r_verbose_commit_ignored(), re.DOTALL | re.MULTILINE) +def test_r_verbose_commit_ignored__matches_single_verbose_ignored(commit): + regex = re.compile(commit.r_verbose_commit_ignored, re.DOTALL | re.MULTILINE) input = ( """feat: some commit message # Please enter the commit message for your changes. Lines starting @@ -221,8 +159,8 @@ def test_r_verbose_commit_ignored__matches_single_verbose_ignored(): assert regex.search(input) -def test_r_verbose_commit_ignored__matches_double_verbose_ignored(): - regex = re.compile(format.r_verbose_commit_ignored(), re.DOTALL | re.MULTILINE) +def test_r_verbose_commit_ignored__matches_double_verbose_ignored(commit): + regex = re.compile(commit.r_verbose_commit_ignored, re.DOTALL | re.MULTILINE) input = ( """feat: some commit message # Please enter the commit message for your changes. Lines starting @@ -278,7 +216,7 @@ def test_r_verbose_commit_ignored__matches_double_verbose_ignored(): assert regex.search(input) -def test_strip_verbose_commit_ignored__does_not_strip_no_verbose(): +def test_strip_verbose_commit_ignored__does_not_strip_no_verbose(commit): input = """feat: some commit message # Please enter the commit message for your changes. Lines starting # with '#' will be ignored, and an empty message aborts the commit. @@ -309,11 +247,11 @@ def test_strip_verbose_commit_ignored__does_not_strip_no_verbose(): # """ - result = format.strip_verbose_commit_ignored(input) + result = commit._strip_verbose_commit_ignored(input) assert result == expected -def test_strip_verbose_commit_ignored__strips_single_verbose_ignored(): +def test_strip_verbose_commit_ignored__strips_single_verbose_ignored(commit): input = ( """feat: some commit message # Please enter the commit message for your changes. Lines starting @@ -360,11 +298,11 @@ def test_strip_verbose_commit_ignored__strips_single_verbose_ignored(): # """ - result = format.strip_verbose_commit_ignored(input) + result = commit._strip_verbose_commit_ignored(input) assert result == expected -def test_strip_verbose_commit_ignored__strips_double_verbose_ignored(): +def test_strip_verbose_commit_ignored__strips_double_verbose_ignored(commit): input = ( """feat: some commit message # Please enter the commit message for your changes. Lines starting @@ -432,12 +370,134 @@ def test_strip_verbose_commit_ignored__strips_double_verbose_ignored(): # """ - result = format.strip_verbose_commit_ignored(input) + result = commit._strip_verbose_commit_ignored(input) assert result == expected -def test_conventional_regex(): - regex = format.conventional_regex() +@pytest.mark.parametrize( + "input,expected_result", + [ + ("amend! ", True), + ("fixup! ", True), + ("squash! ", True), + ("squash! whatever .. $12 #", True), + ("squash!", False), + (" squash! ", False), + ("squash!:", False), + ("feat(foo):", False), + ], +) +def test_has_autosquash_prefix(commit, input, expected_result): + assert commit.has_autosquash_prefix(input) is expected_result + assert Commit(input).has_autosquash_prefix() is expected_result + + +@pytest.mark.parametrize( + "input,expected_result", + [ + ("Merge branch '2.x.x' into '1.x.x'", True), + ("merge branch 'dev' into 'main'", True), + ("nope not a merge commit", False), + ("type: subject", False), + ], +) +def test_is_merge_commit(input, expected_result): + commit = Commit(input) + assert commit.is_merge() is expected_result + + +def test_r_scope__optional(conventional_commit): + regex = re.compile(conventional_commit.r_scope) + + assert regex.match("") + + +def test_r_scope__not_optional(conventional_commit_scope_required): + regex = re.compile(conventional_commit_scope_required.r_scope) + + assert not regex.match("") + assert not regex.match("scope") + assert regex.match("(scope)") + + +def test_r_scope__alphanumeric(conventional_commit_scope_required): + regex = re.compile(conventional_commit_scope_required.r_scope) + + assert regex.match("(50m3t41N6)") + + +def test_r_scope__special_chars(conventional_commit_scope_required): + regex = re.compile(conventional_commit_scope_required.r_scope) + + assert regex.match("(some-thing)") + assert regex.match("(some_thing)") + assert regex.match("(some/thing)") + assert regex.match("(some thing)") + assert regex.match("(some:thing)") + assert regex.match("(some,thing)") + + +def test_r_scope__scopes(conventional_commit_scope_required): + conventional_commit_scope_required.scopes = ["api", "client"] + regex = re.compile(conventional_commit_scope_required.r_scope) + + 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(conventional_commit): + regex = re.compile(conventional_commit.r_delim) + + assert regex.match(":") + assert not regex.match("") + + +def test_r_delim__optional_breaking_indicator(conventional_commit): + regex = re.compile(conventional_commit.r_delim) + + assert regex.match("!:") + + +def test_r_subject__starts_with_space(conventional_commit): + regex = re.compile(conventional_commit.r_subject) + + assert not regex.match("something") + assert regex.match(" something") + + +def test_r_subject__alphanumeric(conventional_commit): + regex = re.compile(conventional_commit.r_subject) + + assert regex.match(" 50m3t41N6") + + +def test_r_subject__special_chars(conventional_commit): + regex = re.compile(conventional_commit.r_subject) + + assert regex.match(" some-thing") + assert regex.match(" some_thing") + assert regex.match(" some/thing") + assert regex.match(" some thing") + + +def test_types__default(): + assert ConventionalCommit().types == ConventionalCommit.DEFAULT_TYPES + + +def test_types__custom(): + result = ConventionalCommit(types=["custom"]) + + assert set(["custom", *ConventionalCommit.CONVENTIONAL_TYPES]) == set(result.types) + + +def test_regex(conventional_commit): + regex = conventional_commit.regex assert isinstance(regex, re.Pattern) assert "type" in regex.groupindex @@ -449,14 +509,25 @@ def test_conventional_regex(): assert "sep" in regex.groupindex -def test_conventional_match(): - match = format.conventional_match( +def test_match(conventional_commit): + match = conventional_commit.match("test: subject line") + + assert isinstance(match, re.Match) + assert match.group("type") == "test" + assert match.group("scope") == "" + assert match.group("delim") == ":" + assert match.group("subject").strip() == "subject line" + assert match.group("body") == "" + + +def test_match_multiline(conventional_commit): + match = conventional_commit.match( """test(scope): subject line body copy """ ) - assert match + assert isinstance(match, re.Match) assert match.group("type") == "test" assert match.group("scope") == "(scope)" assert match.group("delim") == ":" @@ -464,151 +535,180 @@ def test_conventional_match(): assert match.group("body").strip() == "body copy" -@pytest.mark.parametrize("type", format.DEFAULT_TYPES) -def test_is_conventional__default_type(type): +def test_match_invalid_type(conventional_commit): + match = conventional_commit.match( + """invalid(scope): subject line + +body copy +""" + ) + assert isinstance(match, re.Match) + assert match.group("type") is None + assert match.group("scope") == "" + assert match.group("delim") is None + assert match.group("subject") is None + assert match.group("body") == "" + + +@pytest.mark.parametrize("type", ConventionalCommit.DEFAULT_TYPES) +def test_is_valid__default_type(conventional_commit, type): input = f"{type}: message" - assert format.is_conventional(input) + assert conventional_commit.is_valid(input) -@pytest.mark.parametrize("type", format.CONVENTIONAL_TYPES) -def test_is_conventional__conventional_type(type): +@pytest.mark.parametrize("type", ConventionalCommit.CONVENTIONAL_TYPES) +def test_is_valid__conventional_type(conventional_commit, type): input = f"{type}: message" - assert format.is_conventional(input) + assert conventional_commit.is_valid(input) @pytest.mark.parametrize("type", CUSTOM_TYPES) -def test_is_conventional__custom_type(type): +def test_is_valid__custom_type(type): input = f"{type}: message" + conventional_commits = ConventionalCommit(types=CUSTOM_TYPES) - assert format.is_conventional(input, CUSTOM_TYPES) + assert conventional_commits.is_valid(input) -@pytest.mark.parametrize("type", format.CONVENTIONAL_TYPES) -def test_is_conventional__conventional_custom_type(type): +@pytest.mark.parametrize("type", ConventionalCommit.CONVENTIONAL_TYPES) +def test_is_valid__conventional_custom_type(type): input = f"{type}: message" + conventional_commits = ConventionalCommit(types=CUSTOM_TYPES) - assert format.is_conventional(input, CUSTOM_TYPES) + assert conventional_commits.is_valid(input) -def test_is_conventional__breaking_change(): +def test_is_valid__breaking_change(conventional_commit): input = "fix!: message" - assert format.is_conventional(input) + assert conventional_commit.is_valid(input) -def test_is_conventional__with_scope(): +def test_is_valid__with_scope(conventional_commit): input = "feat(scope): message" - assert format.is_conventional(input) + assert conventional_commit.is_valid(input) -def test_is_conventional__body_multiline_body_bad_type(): +def test_is_valid__body_multiline_body_bad_type(conventional_commit): input = """wrong: message more_message """ - assert not format.is_conventional(input) + assert not conventional_commit.is_valid(input) -def test_is_conventional__bad_body_multiline(): +def test_is_valid__bad_body_multiline(conventional_commit): input = """feat(scope): message more message """ - assert not format.is_conventional(input) + assert not conventional_commit.is_valid(input) -def test_is_conventional__body_multiline(): +def test_is_valid__body_multiline(conventional_commit): input = """feat(scope): message more message """ - assert format.is_conventional(input) + assert conventional_commit.is_valid(input) -def test_is_conventional__bad_body_multiline_paragraphs(): +def test_is_valid__bad_body_multiline_paragraphs(conventional_commit): input = """feat(scope): message more message more body message """ - assert not format.is_conventional(input) + assert not conventional_commit.is_valid(input) -def test_is_conventional__comment(): +def test_is_valid__comment(conventional_commit): input = """feat(scope): message # Please enter the commit message for your changes. # These are comments usually added by editors, f.ex. with export EDITOR=vim """ - assert format.is_conventional(input) + assert conventional_commit.is_valid(input) @pytest.mark.parametrize("char", ['"', "'", "`", "#", "&"]) -def test_is_conventional__body_special_char(char): +def test_is_valid__body_special_char(conventional_commit, char): input = f"feat: message with {char}" - assert format.is_conventional(input) + assert conventional_commit.is_valid(input) -def test_is_conventional__wrong_type(): +def test_is_valid__wrong_type(conventional_commit): input = "wrong: message" - assert not format.is_conventional(input) + assert not conventional_commit.is_valid(input) -def test_is_conventional__scope_special_chars(): +def test_is_valid__scope_special_chars(conventional_commit): input = "feat(%&*@()): message" - assert not format.is_conventional(input) + assert not conventional_commit.is_valid(input) -def test_is_conventional__space_scope(): +def test_is_valid__space_scope(conventional_commit): input = "feat (scope): message" - assert not format.is_conventional(input) + assert not conventional_commit.is_valid(input) -def test_is_conventional__scope_space(): +def test_is_valid__scope_space(conventional_commit): input = "feat(scope) : message" - assert not format.is_conventional(input) + assert not conventional_commit.is_valid(input) -def test_is_conventional__scope_not_optional(): +def test_is_valid__scope_not_optional(conventional_commit_scope_required): input = "feat: message" - assert not format.is_conventional(input, optional_scope=False) + assert not conventional_commit_scope_required.is_valid(input) -def test_is_conventional__scope_not_optional_empty_parenthesis(): +def test_is_valid__scope_not_optional_empty_parenthesis(conventional_commit_scope_required): input = "feat(): message" - assert not format.is_conventional(input, optional_scope=False) + assert not conventional_commit_scope_required.is_valid(input) -def test_is_conventional__missing_delimiter(): +def test_is_valid__missing_delimiter(conventional_commit): input = "feat message" - assert not format.is_conventional(input) + assert not conventional_commit.is_valid(input) @pytest.mark.parametrize( - "input,has_prefix", + "input,expected_result", [ - ("amend! ", True), - ("fixup! ", True), - ("squash! ", True), - ("squash! whatever .. $12 #", True), - ("squash!", False), - (" squash! ", False), - ("squash!:", False), - ("feat(foo):", False), + ("feat: subject", True), + ("feat(scope): subject", True), + ( + """feat(scope): subject + + extended body + """, + True, + ), + ("feat", False), + ("feat subject", False), + ("feat(scope): ", False), + (": subject", False), + ("(scope): subject", False), + ( + """feat(scope): subject + extended body no newline + """, + False, + ), ], ) -def test_has_autosquash_prefix(input, has_prefix): - assert format.has_autosquash_prefix(input) == has_prefix +def test_is_conventional(input, expected_result): + assert is_conventional(input) == expected_result diff --git a/tests/test_hook.py b/tests/test_hook.py index f21aa9a..c3a3d28 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -84,6 +84,18 @@ def test_main_fail__fixup_commit(fixup_commit_path): assert result == RESULT_FAIL +def test_main_fail__merge_commit(merge_commit_path): + result = main(["--strict", merge_commit_path]) + + assert result == RESULT_FAIL + + +def test_main_success__merge_commit(merge_commit_path): + result = main([merge_commit_path]) + + assert result == RESULT_SUCCESS + + def test_main_success__conventional_commit_multi_line(conventional_commit_multi_line_path): result = main([conventional_commit_multi_line_path]) diff --git a/tests/test_output.py b/tests/test_output.py index 2eef0fc..b47fdd5 100644 --- a/tests/test_output.py +++ b/tests/test_output.py @@ -1,7 +1,16 @@ import os + +import pytest + +from conventional_pre_commit.format import ConventionalCommit from conventional_pre_commit.output import Colors, fail, fail_verbose, unicode_decode_error +@pytest.fixture +def commit(): + return ConventionalCommit("commit msg") + + def test_colors(): colors = Colors() @@ -18,8 +27,8 @@ def test_colors(): assert colors.yellow == "" -def test_fail(): - output = fail("commit msg") +def test_fail(commit): + output = fail(commit) assert Colors.LRED in output assert Colors.YELLOW in output @@ -32,8 +41,8 @@ def test_fail(): assert "https://www.conventionalcommits.org/" in output -def test_fail__no_color(): - output = fail("commit msg", use_color=False) +def test_fail__no_color(commit): + output = fail(commit, use_color=False) assert Colors.LRED not in output assert Colors.YELLOW not in output @@ -41,8 +50,9 @@ def test_fail__no_color(): assert Colors.RESTORE not in output -def test_fail_verbose(): - output = fail_verbose("commit msg", optional_scope=False) +def test_fail_verbose(commit): + commit.scope_optional = False + output = fail_verbose(commit) assert Colors.YELLOW in output assert Colors.RESTORE in output @@ -51,16 +61,16 @@ def test_fail_verbose(): assert "Conventional Commit messages follow a pattern like" in output assert f"type(scope): subject{os.linesep}{os.linesep} extended body" in output - assert "Expected value for type but found none." in output - assert "Expected value for delim but found none." in output + assert "Expected value for type from:" in output + for t in commit.types: + assert t in output assert "Expected value for scope but found none." in output - assert "Expected value for subject but found none." in output assert "git commit --edit --file=.git/COMMIT_EDITMSG" in output assert "edit the commit message and retry the commit" in output -def test_fail_verbose__no_color(): - output = fail_verbose("commit msg", use_color=False) +def test_fail_verbose__no_color(commit): + output = fail_verbose(commit, use_color=False) assert Colors.LRED not in output assert Colors.YELLOW not in output @@ -68,14 +78,16 @@ def test_fail_verbose__no_color(): assert Colors.RESTORE not in output -def test_fail_verbose__optional_scope(): - output = fail_verbose("commit msg", optional_scope=True, use_color=False) +def test_fail_verbose__optional_scope(commit): + commit.scope_optional = True + output = fail_verbose(commit, use_color=False) assert "Expected value for scope but found none." not in output def test_fail_verbose__missing_subject(): - output = fail_verbose("feat(scope):", optional_scope=False, use_color=False) + commit = ConventionalCommit("feat(scope):", scope_optional=False) + output = fail_verbose(commit, use_color=False) assert "Expected value for subject but found none." in output assert "Expected value for type but found none." not in output @@ -83,14 +95,15 @@ def test_fail_verbose__missing_subject(): def test_fail_verbose__no_body_sep(): - output = fail_verbose( - """feat(scope): subject + commit = ConventionalCommit( + scope_optional=False, + commit_msg="""feat(scope): subject body without blank line """, - optional_scope=False, - use_color=False, ) + output = fail_verbose(commit, use_color=False) + assert "Expected value for sep but found none." in output assert "Expected value for multi but found none." not in output