Repository: compilerla/conventional-pre-commit (language: python)

Issue:
Error message is imprecise and too long
The error message is not specific as to which infraction(s) has (have) been detected.

It also emits an unwelcome number of lines of generic information.

Could it emit many few lines and contain better targeted information please?

Ideally just the actual error(s), but if there's a need for reference material, then just a link to an external resource. 
feat: Give a hint on how to reedit the commit message
I think it is not common knowledge that the commit message is stored in `.git/COMMIT_EDITMSG` if the hook fails.
Let's give the user a hint and a copy-pastable command to retry the commit.


Discussion / hints:

Fix patch:
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<type>{r_types(types)})?"
+    scope_pattern = f"(?P<scope>{r_scope(optional_scope, scopes=scopes)})?"
+    delim_pattern = f"(?P<delim>{r_delim()})?"
+    subject_pattern = f"(?P<subject>{r_subject()})?"
+    body_pattern = f"(?P<body>{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}
+"""


Test patch:
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 +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