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