summaryrefslogtreecommitdiffstats
path: root/gitlint-core/gitlint/rules.py
diff options
context:
space:
mode:
Diffstat (limited to 'gitlint-core/gitlint/rules.py')
-rw-r--r--gitlint-core/gitlint/rules.py485
1 files changed, 485 insertions, 0 deletions
diff --git a/gitlint-core/gitlint/rules.py b/gitlint-core/gitlint/rules.py
new file mode 100644
index 0000000..ca4a05b
--- /dev/null
+++ b/gitlint-core/gitlint/rules.py
@@ -0,0 +1,485 @@
+import copy
+import logging
+import re
+
+from gitlint.deprecation import Deprecation
+from gitlint.exception import GitlintError
+from gitlint.options import BoolOption, IntOption, ListOption, RegexOption, StrOption
+
+
+class Rule:
+ """Class representing gitlint rules."""
+
+ options_spec = []
+ id = None
+ name = None
+ target = None
+ _log = None
+ _log_deprecated_regex_style_search = None
+
+ def __init__(self, opts=None):
+ if not opts:
+ opts = {}
+ self.options = {}
+ for op_spec in self.options_spec:
+ self.options[op_spec.name] = copy.deepcopy(op_spec)
+ actual_option = opts.get(op_spec.name)
+ if actual_option is not None:
+ self.options[op_spec.name].set(actual_option)
+
+ @property
+ def log(self):
+ if not self._log:
+ self._log = logging.getLogger(__name__)
+ logging.basicConfig()
+ return self._log
+
+ def __eq__(self, other):
+ return (
+ self.id == other.id
+ and self.name == other.name
+ and self.options == other.options
+ and self.target == other.target
+ )
+
+ def __str__(self):
+ return f"{self.id} {self.name}" # pragma: no cover
+
+
+class ConfigurationRule(Rule):
+ """Class representing rules that can dynamically change the configuration of gitlint during runtime."""
+
+
+class CommitRule(Rule):
+ """Class representing rules that act on an entire commit at once"""
+
+
+class LineRule(Rule):
+ """Class representing rules that act on a line by line basis"""
+
+
+class LineRuleTarget:
+ """Base class for LineRule targets. A LineRuleTarget specifies where a given rule will be applied
+ (e.g. commit message title, commit message body).
+ Each LineRule MUST have a target specified."""
+
+
+class CommitMessageTitle(LineRuleTarget):
+ """Target class used for rules that apply to a commit message title"""
+
+
+class CommitMessageBody(LineRuleTarget):
+ """Target class used for rules that apply to a commit message body"""
+
+
+class RuleViolation:
+ """Class representing a violation of a rule. I.e.: When a rule is broken, the rule will instantiate this class
+ to indicate how and where the rule was broken."""
+
+ def __init__(self, rule_id, message, content=None, line_nr=None):
+ self.rule_id = rule_id
+ self.line_nr = line_nr
+ self.message = message
+ self.content = content
+
+ def __eq__(self, other):
+ equal = self.rule_id == other.rule_id and self.message == other.message
+ equal = equal and self.content == other.content and self.line_nr == other.line_nr
+ return equal
+
+ def __str__(self):
+ return f'{self.line_nr}: {self.rule_id} {self.message}: "{self.content}"'
+
+
+class UserRuleError(GitlintError):
+ """Error used to indicate that an error occurred while trying to load a user rule"""
+
+
+class MaxLineLength(LineRule):
+ name = "max-line-length"
+ id = "R1"
+ options_spec = [IntOption("line-length", 80, "Max line length")]
+ violation_message = "Line exceeds max length ({0}>{1})"
+
+ def validate(self, line, _commit):
+ max_length = self.options["line-length"].value
+ if len(line) > max_length:
+ return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)]
+
+
+class TrailingWhiteSpace(LineRule):
+ name = "trailing-whitespace"
+ id = "R2"
+ violation_message = "Line has trailing whitespace"
+ pattern = re.compile(r"\s$", re.UNICODE)
+
+ def validate(self, line, _commit):
+ if self.pattern.search(line):
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class HardTab(LineRule):
+ name = "hard-tab"
+ id = "R3"
+ violation_message = "Line contains hard tab characters (\\t)"
+
+ def validate(self, line, _commit):
+ if "\t" in line:
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class LineMustNotContainWord(LineRule):
+ """Violation if a line contains one of a list of words (NOTE: using a word in the list inside another word is not
+ a violation, e.g: WIPING is not a violation if 'WIP' is a word that is not allowed.)"""
+
+ name = "line-must-not-contain"
+ id = "R5"
+ options_spec = [ListOption("words", [], "Comma separated list of words that should not be found")]
+ violation_message = "Line contains {0}"
+
+ def validate(self, line, _commit):
+ strings = self.options["words"].value
+ violations = []
+ for string in strings:
+ regex = re.compile(rf"\b{string.lower()}\b", re.IGNORECASE | re.UNICODE)
+ match = regex.search(line.lower())
+ if match:
+ violations.append(RuleViolation(self.id, self.violation_message.format(string), line))
+ return violations if violations else None
+
+
+class LeadingWhiteSpace(LineRule):
+ name = "leading-whitespace"
+ id = "R6"
+ violation_message = "Line has leading whitespace"
+
+ def validate(self, line, _commit):
+ pattern = re.compile(r"^\s", re.UNICODE)
+ if pattern.search(line):
+ return [RuleViolation(self.id, self.violation_message, line)]
+
+
+class TitleMaxLength(MaxLineLength):
+ name = "title-max-length"
+ id = "T1"
+ target = CommitMessageTitle
+ options_spec = [IntOption("line-length", 72, "Max line length")]
+ violation_message = "Title exceeds max length ({0}>{1})"
+
+
+class TitleTrailingWhitespace(TrailingWhiteSpace):
+ name = "title-trailing-whitespace"
+ id = "T2"
+ target = CommitMessageTitle
+ violation_message = "Title has trailing whitespace"
+
+
+class TitleTrailingPunctuation(LineRule):
+ name = "title-trailing-punctuation"
+ id = "T3"
+ target = CommitMessageTitle
+
+ def validate(self, title, _commit):
+ punctuation_marks = "?:!.,;"
+ for punctuation_mark in punctuation_marks:
+ if title.endswith(punctuation_mark):
+ return [RuleViolation(self.id, f"Title has trailing punctuation ({punctuation_mark})", title)]
+
+
+class TitleHardTab(HardTab):
+ name = "title-hard-tab"
+ id = "T4"
+ target = CommitMessageTitle
+ violation_message = "Title contains hard tab characters (\\t)"
+
+
+class TitleMustNotContainWord(LineMustNotContainWord):
+ name = "title-must-not-contain-word"
+ id = "T5"
+ target = CommitMessageTitle
+ options_spec = [ListOption("words", ["WIP"], "Must not contain word")]
+ violation_message = "Title contains the word '{0}' (case-insensitive)"
+
+
+class TitleLeadingWhitespace(LeadingWhiteSpace):
+ name = "title-leading-whitespace"
+ id = "T6"
+ target = CommitMessageTitle
+ violation_message = "Title has leading whitespace"
+
+
+class TitleRegexMatches(LineRule):
+ name = "title-match-regex"
+ id = "T7"
+ target = CommitMessageTitle
+ options_spec = [RegexOption("regex", None, "Regex the title should match")]
+
+ def validate(self, title, _commit):
+ # If no regex is specified, immediately return
+ if not self.options["regex"].value:
+ return
+
+ if not self.options["regex"].value.search(title):
+ violation_msg = f"Title does not match regex ({self.options['regex'].value.pattern})"
+ return [RuleViolation(self.id, violation_msg, title)]
+
+
+class TitleMinLength(LineRule):
+ name = "title-min-length"
+ id = "T8"
+ target = CommitMessageTitle
+ options_spec = [IntOption("min-length", 5, "Minimum required title length")]
+
+ def validate(self, title, _commit):
+ min_length = self.options["min-length"].value
+ actual_length = len(title)
+ if actual_length < min_length:
+ violation_message = f"Title is too short ({actual_length}<{min_length})"
+ return [RuleViolation(self.id, violation_message, title, 1)]
+
+
+class BodyMaxLineLength(MaxLineLength):
+ name = "body-max-line-length"
+ id = "B1"
+ target = CommitMessageBody
+
+
+class BodyTrailingWhitespace(TrailingWhiteSpace):
+ name = "body-trailing-whitespace"
+ id = "B2"
+ target = CommitMessageBody
+
+
+class BodyHardTab(HardTab):
+ name = "body-hard-tab"
+ id = "B3"
+ target = CommitMessageBody
+
+
+class BodyFirstLineEmpty(CommitRule):
+ name = "body-first-line-empty"
+ id = "B4"
+
+ def validate(self, commit):
+ if len(commit.message.body) >= 1:
+ first_line = commit.message.body[0]
+ if first_line != "":
+ return [RuleViolation(self.id, "Second line is not empty", first_line, 2)]
+
+
+class BodyMinLength(CommitRule):
+ name = "body-min-length"
+ id = "B5"
+ options_spec = [IntOption("min-length", 20, "Minimum body length")]
+
+ def validate(self, commit):
+ min_length = self.options["min-length"].value
+ body_message_no_newline = "".join([line for line in commit.message.body if line is not None])
+ actual_length = len(body_message_no_newline)
+ if 0 < actual_length < min_length:
+ violation_message = f"Body message is too short ({actual_length}<{min_length})"
+ return [RuleViolation(self.id, violation_message, body_message_no_newline, 3)]
+
+
+class BodyMissing(CommitRule):
+ name = "body-is-missing"
+ id = "B6"
+ options_spec = [BoolOption("ignore-merge-commits", True, "Ignore merge commits")]
+
+ def validate(self, commit):
+ # ignore merges when option tells us to, which may have no body
+ if self.options["ignore-merge-commits"].value and commit.is_merge_commit:
+ return
+ if len(commit.message.body) < 2 or not "".join(commit.message.body).strip(): # noqa: PLR2004 (Magic value)
+ return [RuleViolation(self.id, "Body message is missing", None, 3)]
+
+
+class BodyChangedFileMention(CommitRule):
+ name = "body-changed-file-mention"
+ id = "B7"
+ options_spec = [ListOption("files", [], "Files that need to be mentioned")]
+
+ def validate(self, commit):
+ violations = []
+ for needs_mentioned_file in self.options["files"].value:
+ # if a file that we need to look out for is actually changed, then check whether it occurs
+ # in the commit msg body
+ if needs_mentioned_file in commit.changed_files: # noqa: SIM102
+ if needs_mentioned_file not in " ".join(commit.message.body):
+ violation_message = f"Body does not mention changed file '{needs_mentioned_file}'"
+ violations.append(RuleViolation(self.id, violation_message, None, len(commit.message.body) + 1))
+ return violations if violations else None
+
+
+class BodyRegexMatches(CommitRule):
+ name = "body-match-regex"
+ id = "B8"
+ options_spec = [RegexOption("regex", None, "Regex the body should match")]
+
+ def validate(self, commit):
+ # If no regex is specified, immediately return
+ if not self.options["regex"].value:
+ return
+
+ # We intentionally ignore the first line in the body as that's the empty line after the title,
+ # which most users are not going to expect to be part of the body when matching a regex.
+ # If this causes contention, we can always introduce an option to change the behavior in a backward-
+ # compatible way.
+ body_lines = commit.message.body[1:] if len(commit.message.body) > 1 else []
+
+ # Similarly, the last line is often empty, this has to do with how git returns commit messages
+ # User's won't expect this, so prune it off by default
+ if body_lines and body_lines[-1] == "":
+ body_lines.pop()
+
+ full_body = "\n".join(body_lines)
+
+ if not self.options["regex"].value.search(full_body):
+ violation_msg = f"Body does not match regex ({self.options['regex'].value.pattern})"
+ return [RuleViolation(self.id, violation_msg, None, len(commit.message.body) + 1)]
+
+
+class AuthorValidEmail(CommitRule):
+ name = "author-valid-email"
+ id = "M1"
+ DEFAULT_AUTHOR_VALID_EMAIL_REGEX = r"^[^@ ]+@[^@ ]+\.[^@ ]+"
+ options_spec = [
+ RegexOption("regex", DEFAULT_AUTHOR_VALID_EMAIL_REGEX, "Regex that author email address should match")
+ ]
+
+ def validate(self, commit):
+ # If no regex is specified, immediately return
+ if not self.options["regex"].value:
+ return
+
+ # We're replacing regex match with search semantics, see https://github.com/jorisroovers/gitlint/issues/254
+ # In case the user is using the default regex, we can silently change to using search
+ # If not, it depends on config (handled by Deprecation class)
+ if self.options["regex"].value.pattern == self.DEFAULT_AUTHOR_VALID_EMAIL_REGEX:
+ regex_method = self.options["regex"].value.search
+ else:
+ regex_method = Deprecation.get_regex_method(self, self.options["regex"])
+
+ if commit.author_email and not regex_method(commit.author_email):
+ return [RuleViolation(self.id, "Author email for commit is invalid", commit.author_email)]
+
+
+class IgnoreByTitle(ConfigurationRule):
+ name = "ignore-by-title"
+ id = "I1"
+ options_spec = [
+ RegexOption("regex", None, "Regex matching the titles of commits this rule should apply to"),
+ StrOption("ignore", "all", "Comma-separated list of rules to ignore"),
+ ]
+
+ def apply(self, config, commit):
+ # If no regex is specified, immediately return
+ if not self.options["regex"].value:
+ return
+
+ # We're replacing regex match with search semantics, see https://github.com/jorisroovers/gitlint/issues/254
+ regex_method = Deprecation.get_regex_method(self, self.options["regex"])
+
+ if regex_method(commit.message.title):
+ config.ignore = self.options["ignore"].value
+
+ message = (
+ f"Commit title '{commit.message.title}' matches the regex "
+ f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}"
+ )
+
+ self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+
+
+class IgnoreByBody(ConfigurationRule):
+ name = "ignore-by-body"
+ id = "I2"
+ options_spec = [
+ RegexOption("regex", None, "Regex matching lines of the body of commits this rule should apply to"),
+ StrOption("ignore", "all", "Comma-separated list of rules to ignore"),
+ ]
+
+ def apply(self, config, commit):
+ # If no regex is specified, immediately return
+ if not self.options["regex"].value:
+ return
+
+ # We're replacing regex match with search semantics, see https://github.com/jorisroovers/gitlint/issues/254
+ regex_method = Deprecation.get_regex_method(self, self.options["regex"])
+
+ for line in commit.message.body:
+ if regex_method(line):
+ config.ignore = self.options["ignore"].value
+
+ message = (
+ f"Commit message line '{line}' matches the regex '{self.options['regex'].value.pattern}',"
+ f" ignoring rules: {self.options['ignore'].value}"
+ )
+
+ self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+ # No need to check other lines if we found a match
+ return
+
+
+class IgnoreBodyLines(ConfigurationRule):
+ name = "ignore-body-lines"
+ id = "I3"
+ options_spec = [RegexOption("regex", None, "Regex matching lines of the body that should be ignored")]
+
+ def apply(self, _, commit):
+ # If no regex is specified, immediately return
+ if not self.options["regex"].value:
+ return
+
+ # We're replacing regex match with search semantics, see https://github.com/jorisroovers/gitlint/issues/254
+ regex_method = Deprecation.get_regex_method(self, self.options["regex"])
+
+ new_body = []
+ for line in commit.message.body:
+ if regex_method(line):
+ debug_msg = "Ignoring line '%s' because it matches '%s'"
+ self.log.debug(debug_msg, line, self.options["regex"].value.pattern)
+ else:
+ new_body.append(line)
+
+ commit.message.body = new_body
+ commit.message.full = "\n".join([commit.message.title, *new_body])
+
+
+class IgnoreByAuthorName(ConfigurationRule):
+ name = "ignore-by-author-name"
+ id = "I4"
+ options_spec = [
+ RegexOption("regex", None, "Regex matching the author name of commits this rule should apply to"),
+ StrOption("ignore", "all", "Comma-separated list of rules to ignore"),
+ ]
+
+ def apply(self, config, commit):
+ # If no regex is specified, immediately return
+ if not self.options["regex"].value:
+ return
+
+ # If commit.author_name is not available, log warning and return
+ if commit.author_name is None:
+ warning_msg = (
+ "%s - %s: skipping - commit.author_name unknown. "
+ "Suggested fix: Use the --staged flag (or set general.staged=True in .gitlint). "
+ "More details: https://jorisroovers.com/gitlint/configuration/#staged"
+ )
+
+ self.log.warning(warning_msg, self.name, self.id)
+ return
+
+ regex_method = Deprecation.get_regex_method(self, self.options["regex"])
+
+ if regex_method(commit.author_name):
+ config.ignore = self.options["ignore"].value
+
+ message = (
+ f"Commit Author Name '{commit.author_name}' matches the regex "
+ f"'{self.options['regex'].value.pattern}', ignoring rules: {self.options['ignore'].value}"
+ )
+
+ self.log.debug("Ignoring commit because of rule '%s': %s", self.id, message)
+ # No need to check other lines if we found a match
+ return