Skip to content

Commit

Permalink
Refactor: classes for format checking (#119)
Browse files Browse the repository at this point in the history
thekaveman authored Dec 20, 2024
2 parents 3f1bb61 + 4684800 commit 5f9c312
Showing 11 changed files with 611 additions and 395 deletions.
2 changes: 1 addition & 1 deletion .devcontainer/devcontainer.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "compilerla/conventional-pre-commit",
"dockerComposeFile": ["./compose.yml"],
"dockerComposeFile": ["../compose.yml"],
"service": "dev",
"runServices": ["dev"],
"workspaceFolder": "/home/compiler/src",
16 changes: 9 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
```

4 changes: 2 additions & 2 deletions .devcontainer/compose.yml → compose.yml
Original file line number Diff line number Diff line change
@@ -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
374 changes: 230 additions & 144 deletions conventional_pre_commit/format.py
Original file line number Diff line number Diff line change
@@ -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<multi>\r?\n(?P<sep>^$\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<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):
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<multi>\r?\n(?P<sep>^$\r?\n)?.+)?"

@property
def regex(self):
"""`re.Pattern` for ConventionalCommits formatting."""
types_pattern = f"^(?P<type>{self.r_types})?"
scope_pattern = f"(?P<scope>{self.r_scope})?"
delim_pattern = f"(?P<delim>{self.r_delim})?"
subject_pattern = f"(?P<subject>{self.r_subject})?"
body_pattern = f"(?P<body>{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()
27 changes: 15 additions & 12 deletions conventional_pre_commit/hook.py
Original file line number Diff line number Diff line change
@@ -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

40 changes: 17 additions & 23 deletions conventional_pre_commit/output.py
Original file line number Diff line number Diff line change
@@ -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:
5 changes: 5 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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")
1 change: 1 addition & 0 deletions tests/messages/merge_commit
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Merge branch '2.x.x' into '1.x.x'
476 changes: 288 additions & 188 deletions tests/test_format.py

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions tests/test_hook.py
Original file line number Diff line number Diff line change
@@ -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])

49 changes: 31 additions & 18 deletions tests/test_output.py
Original file line number Diff line number Diff line change
@@ -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,17 +41,18 @@ 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
assert Colors.LBLUE not in output
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,46 +61,49 @@ 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
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)
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
assert "Expected value for scope but found none." not in output


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

0 comments on commit 5f9c312

Please sign in to comment.