diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eafc0ea..20599bf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -51,3 +51,4 @@ repos: entry: conventional-pre-commit language: python stages: [commit-msg] + args: [--verbose] diff --git a/README.md b/README.md index 6e7f8c3..15d6d3d 100644 --- a/README.md +++ b/README.md @@ -46,33 +46,43 @@ Conventional Commit......................................................Failed - duration: 0.07s - exit code: 1 -[Bad Commit message] >> add a new feature - +[Bad commit message] >> add a new feature Your commit message does not follow Conventional Commits formatting https://www.conventionalcommits.org/ +``` + +And with the `--verbose` arg: -Conventional Commits start with one of the below types, followed by a colon, -followed by the commit message: +```console +$ git commit -m "add a new feature" - build chore ci docs feat fix perf refactor revert style test +[INFO] Initializing environment for .... +Conventional Commit......................................................Failed +- hook id: conventional-pre-commit +- duration: 0.07s +- exit code: 1 -Example commit message adding a feature: +[Bad commit message] >> add a new feature +Your commit message does not follow Conventional Commits formatting +https://www.conventionalcommits.org/ - feat: implement new API +Conventional Commit messages follow a pattern like: -Example commit message fixing an issue: + type(scope): subject - fix: remove infinite loop + extended body -Example commit with scope in parentheses after the type for more context: +Please correct the following errors: - fix(account): remove infinite loop + - Expected value for 'type' but found none. + - Expected value for 'delim' but found none. + - Expected value for 'subject' but found none. -Example commit with a body: +Run: - fix: remove infinite loop + git commit --edit --file=.git/COMMIT_EDITMSG - Additional information on the issue caused by the infinite loop +to edit the commit message and retry the commit. ``` Make a (conventional) commit :heavy_check_mark:: @@ -129,7 +139,7 @@ print(is_conventional("custom: this is a conventional commit", types=["custom"]) ```shell $ conventional-pre-commit -h -usage: conventional-pre-commit [-h] [--force-scope] [--scopes SCOPES] [--strict] [types ...] input +usage: conventional-pre-commit [-h] [--no-color] [--force-scope] [--scopes SCOPES] [--strict] [--verbose] [types ...] input Check a git commit message for Conventional Commits formatting. @@ -139,9 +149,11 @@ positional arguments: 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. + --verbose Print more verbose error output. ``` 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 8e1822d..dc283d3 100644 --- a/conventional_pre_commit/format.py +++ b/conventional_pre_commit/format.py @@ -96,22 +96,52 @@ def conventional_types(types=[]): return types -def is_conventional(input, types=DEFAULT_TYPES, optional_scope=True, scopes: Optional[List[str]] = None): +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): + """ + Prepares an input message for conventional commits format check. + """ + 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. + """ + input = clean_input(input) + regex = conventional_regex(types, optional_scope, scopes) + return regex.match(input) + + +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. """ - input = strip_verbose_commit_ignored(input) - input = strip_comments(input) - types = conventional_types(types) - 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) + result = conventional_match(input, types, optional_scope, scopes) is_valid = bool(result) - if is_valid and result.group("multi") and not result.group("sep"): + + 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")] + ): is_valid = False return is_valid diff --git a/conventional_pre_commit/hook.py b/conventional_pre_commit/hook.py index 25d6090..5ffb308 100644 --- a/conventional_pre_commit/hook.py +++ b/conventional_pre_commit/hook.py @@ -1,25 +1,19 @@ import argparse import sys -from conventional_pre_commit import format +from conventional_pre_commit import format, output RESULT_SUCCESS = 0 RESULT_FAIL = 1 -class Colors: - LBLUE = "\033[00;34m" - LRED = "\033[01;31m" - RESTORE = "\033[0m" - YELLOW = "\033[00;33m" - - 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("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( "--force-scope", action="store_false", default=True, dest="optional_scope", help="Force commit to have scope defined." ) @@ -34,6 +28,13 @@ def main(argv=[]): action="store_true", help="Force commit to strictly follow Conventional Commits formatting. Disallows fixup! style commits.", ) + parser.add_argument( + "--verbose", + action="store_true", + dest="verbose", + default=False, + help="Print more verbose error output.", + ) if len(argv) < 1: argv = sys.argv[1:] @@ -45,17 +46,9 @@ def main(argv=[]): try: with open(args.input, encoding="utf-8") as f: - message = f.read() + commit_msg = f.read() except UnicodeDecodeError: - print( - f""" -{Colors.LRED}[Bad Commit message encoding] {Colors.RESTORE} - -{Colors.YELLOW}conventional-pre-commit couldn't decode your commit message.{Colors.RESTORE} -{Colors.YELLOW}UTF-8{Colors.RESTORE} encoding is assumed, please configure git to write commit messages in UTF-8. -See {Colors.LBLUE}https://git-scm.com/docs/git-commit/#_discussion{Colors.RESTORE} for more. - """ - ) + print(output.unicode_decode_error(args.color)) return RESULT_FAIL if args.scopes: scopes = args.scopes.split(",") @@ -63,43 +56,24 @@ def main(argv=[]): scopes = args.scopes if not args.strict: - if format.has_autosquash_prefix(message): + if format.has_autosquash_prefix(commit_msg): return RESULT_SUCCESS - if format.is_conventional(message, args.types, args.optional_scope, scopes): + if format.is_conventional(commit_msg, args.types, args.optional_scope, scopes): return RESULT_SUCCESS - else: - print( - f""" - {Colors.LRED}[Bad Commit message] >>{Colors.RESTORE} {message} - {Colors.YELLOW}Your commit message does not follow Conventional Commits formatting - {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 subject and an optional body seperated by a blank line:{Colors.RESTORE} - - {" ".join(format.conventional_types(args.types))} - {Colors.YELLOW}Example commit message adding a feature:{Colors.RESTORE} + print(output.fail(commit_msg, use_color=args.color)) - feat: implement new API - - {Colors.YELLOW}Example commit message fixing an issue:{Colors.RESTORE} - - fix: remove infinite loop - - {Colors.YELLOW}Example commit with scope in parentheses after the type for more context:{Colors.RESTORE} - - fix(account): remove infinite loop - - {Colors.YELLOW}Example commit with a body:{Colors.RESTORE} - - fix: remove infinite loop - - Additional information on the issue caused by the infinite loop - """ + 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 + ) ) - return RESULT_FAIL + + return RESULT_FAIL if __name__ == "__main__": diff --git a/conventional_pre_commit/output.py b/conventional_pre_commit/output.py new file mode 100644 index 0000000..8d71385 --- /dev/null +++ b/conventional_pre_commit/output.py @@ -0,0 +1,111 @@ +import os +from typing import List, Optional + +from conventional_pre_commit import format + + +class Colors: + LBLUE = "\033[00;34m" + LRED = "\033[01;31m" + RESTORE = "\033[0m" + YELLOW = "\033[00;33m" + + def __init__(self, enabled=True): + self.enabled = enabled + + @property + def blue(self): + return self.LBLUE if self.enabled else "" + + @property + def red(self): + return self.LRED if self.enabled else "" + + @property + def restore(self): + return self.RESTORE if self.enabled else "" + + @property + def yellow(self): + return self.YELLOW if self.enabled else "" + + +def fail(commit_msg, use_color=True): + c = Colors(use_color) + lines = [ + f"{c.red}[Bad commit message] >>{c.restore} {commit_msg}" + f"{c.yellow}Your commit message does not follow Conventional Commits formatting{c.restore}", + f"{c.blue}https://www.conventionalcommits.org/{c.restore}", + ] + return os.linesep.join(lines) + + +def verbose_arg(use_color=True): + c = Colors(use_color) + lines = [ + "", + f"{c.yellow}Use the {c.restore}--verbose{c.yellow} arg for more information{c.restore}", + ] + 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 +): + 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:", + "", + f"{c.restore} type(scope): subject", + "", + " extended body", + "", + ] + + groups = match.groupdict() if match else {} + + 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(): + 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}") + else: + lines.append(f"{c.yellow} - Expected value for {c.restore}scope{c.yellow} but found none.{c.restore}") + else: + lines.append(f"{c.yellow} - Expected value for {c.restore}{group}{c.yellow} but found none.{c.restore}") + + lines.extend( + [ + "", + f"{c.yellow}Run:{c.restore}", + "", + " git commit --edit --file=.git/COMMIT_EDITMSG", + "", + f"{c.yellow}to edit the commit message and retry the commit.{c.restore}", + ] + ) + return os.linesep.join(lines) + + +def unicode_decode_error(use_color=True): + c = Colors(use_color) + return f""" +{c.red}[Bad commit message encoding]{c.restore} + +{c.yellow}conventional-pre-commit couldn't decode your commit message. +UTF-8 encoding is assumed, please configure git to write commit messages in UTF-8. +See {c.blue}https://git-scm.com/docs/git-commit/#_discussion{c.yellow} for more.{c.restore} +""" diff --git a/tests/messages/bad_commit b/tests/messages/bad_commit index bb212db..75909c7 100644 --- a/tests/messages/bad_commit +++ b/tests/messages/bad_commit @@ -1 +1 @@ -bad: message +bad message diff --git a/tests/test_format.py b/tests/test_format.py index 6377086..e57d3d8 100644 --- a/tests/test_format.py +++ b/tests/test_format.py @@ -436,6 +436,34 @@ def test_strip_verbose_commit_ignored__strips_double_verbose_ignored(): assert result == expected +def test_conventional_regex(): + regex = format.conventional_regex() + + assert isinstance(regex, re.Pattern) + assert "type" in regex.groupindex + assert "scope" in regex.groupindex + assert "delim" in regex.groupindex + assert "subject" in regex.groupindex + assert "body" in regex.groupindex + assert "multi" in regex.groupindex + assert "sep" in regex.groupindex + + +def test_conventional_match(): + match = format.conventional_match( + """test(scope): subject line + +body copy +""" + ) + assert match + assert match.group("type") == "test" + assert match.group("scope") == "(scope)" + assert match.group("delim") == ":" + assert match.group("subject").strip() == "subject line" + assert match.group("body").strip() == "body copy" + + @pytest.mark.parametrize("type", format.DEFAULT_TYPES) def test_is_conventional__default_type(type): input = f"{type}: message" diff --git a/tests/test_hook.py b/tests/test_hook.py index 59056bc..f21aa9a 100644 --- a/tests/test_hook.py +++ b/tests/test_hook.py @@ -1,8 +1,10 @@ +import os import subprocess import pytest from conventional_pre_commit.hook import RESULT_FAIL, RESULT_SUCCESS, main +from conventional_pre_commit.output import Colors @pytest.fixture @@ -94,6 +96,38 @@ def test_main_fail__conventional_commit_bad_multi_line(conventional_commit_bad_m assert result == RESULT_FAIL +def test_main_fail__verbose(bad_commit_path, capsys): + result = main(["--verbose", "--force-scope", bad_commit_path]) + + assert result == RESULT_FAIL + + captured = capsys.readouterr() + output = captured.out + + assert Colors.LBLUE in output + assert Colors.LRED in output + assert Colors.RESTORE in output + assert Colors.YELLOW in output + assert "Conventional Commit messages follow a pattern like" in output + assert f"type(scope): subject{os.linesep}{os.linesep} extended body" 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_main_fail__no_color(bad_commit_path, capsys): + result = main(["--verbose", "--no-color", bad_commit_path]) + + assert result == RESULT_FAIL + + captured = capsys.readouterr() + output = captured.out + + assert Colors.LBLUE not in output + assert Colors.LRED not in output + assert Colors.RESTORE not in output + assert Colors.YELLOW not in output + + def test_subprocess_fail__missing_args(cmd): result = subprocess.call(cmd) diff --git a/tests/test_output.py b/tests/test_output.py new file mode 100644 index 0000000..2eef0fc --- /dev/null +++ b/tests/test_output.py @@ -0,0 +1,121 @@ +import os +from conventional_pre_commit.output import Colors, fail, fail_verbose, unicode_decode_error + + +def test_colors(): + colors = Colors() + + assert colors.blue == colors.LBLUE + assert colors.red == colors.LRED + assert colors.restore == colors.RESTORE + assert colors.yellow == colors.YELLOW + + colors = Colors(enabled=False) + + assert colors.blue == "" + assert colors.red == "" + assert colors.restore == "" + assert colors.yellow == "" + + +def test_fail(): + output = fail("commit msg") + + assert Colors.LRED in output + assert Colors.YELLOW in output + assert Colors.LBLUE in output + assert Colors.RESTORE in output + + assert "Bad commit message" in output + assert "commit msg" in output + assert "Conventional Commits formatting" in output + assert "https://www.conventionalcommits.org/" in output + + +def test_fail__no_color(): + output = fail("commit msg", use_color=False) + + assert Colors.LRED not in output + assert Colors.YELLOW not in output + assert Colors.LBLUE not in output + assert Colors.RESTORE not in output + + +def test_fail_verbose(): + output = fail_verbose("commit msg", optional_scope=False) + + assert Colors.YELLOW in output + assert Colors.RESTORE in output + + output = output.replace(Colors.YELLOW, Colors.RESTORE).replace(Colors.RESTORE, "") + + 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 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) + + assert Colors.LRED not in output + assert Colors.YELLOW not in output + assert Colors.LBLUE not in output + assert Colors.RESTORE not in output + + +def test_fail_verbose__optional_scope(): + output = fail_verbose("commit msg", optional_scope=True, 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) + + assert "Expected value for subject but found none." in output + assert "Expected value for type but found none." not in output + assert "Expected value for scope but found none." not in output + + +def test_fail_verbose__no_body_sep(): + output = fail_verbose( + """feat(scope): subject +body without blank line +""", + optional_scope=False, + use_color=False, + ) + + assert "Expected value for sep but found none." in output + assert "Expected value for multi but found none." not in output + + assert "Expected value for subject but found none." not in output + assert "Expected value for type but found none." not in output + assert "Expected value for scope but found none." not in output + + +def test_unicode_decode_error(): + output = unicode_decode_error() + + assert Colors.LRED in output + assert Colors.YELLOW in output + assert Colors.LBLUE in output + assert Colors.RESTORE in output + + assert "Bad commit message encoding" in output + assert "UTF-8 encoding is assumed" in output + assert "https://git-scm.com/docs/git-commit/#_discussion" in output + + +def test_unicode_decode_error__no_color(): + output = unicode_decode_error(use_color=False) + + assert Colors.LRED not in output + assert Colors.YELLOW not in output + assert Colors.LBLUE not in output + assert Colors.RESTORE not in output