-
Notifications
You must be signed in to change notification settings - Fork 59
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Loading status checks…
Refactor: classes for format checking (#119)
Showing
11 changed files
with
611 additions
and
395 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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() |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
Merge branch '2.x.x' into '1.x.x' |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters