1# SPDX-License-Identifier: Apache-2.0 2 3""" 4The classes below are examples of user-defined CommitRules. Commit rules are gitlint rules that 5act on the entire commit at once. Once the rules are discovered, gitlint will automatically take care of applying them 6to the entire commit. This happens exactly once per commit. 7 8A CommitRule contrasts with a LineRule (see examples/my_line_rules.py) in that a commit rule is only applied once on 9an entire commit. This allows commit rules to implement more complex checks that span multiple lines and/or checks 10that should only be done once per gitlint run. 11 12While every LineRule can be implemented as a CommitRule, it's usually easier and more concise to go with a LineRule if 13that fits your needs. 14""" 15 16from gitlint.rules import CommitRule, RuleViolation, CommitMessageTitle, LineRule, CommitMessageBody 17from gitlint.options import IntOption, StrOption 18import re 19 20class BodyMinLineCount(CommitRule): 21 # A rule MUST have a human friendly name 22 name = "body-min-line-count" 23 24 # A rule MUST have an *unique* id, we recommend starting with UC (for User-defined Commit-rule). 25 id = "UC6" 26 27 # A rule MAY have an options_spec if its behavior should be configurable. 28 options_spec = [IntOption('min-line-count', 1, "Minimum body line count excluding Signed-off-by")] 29 30 def validate(self, commit): 31 filtered = [x for x in commit.message.body if not x.lower().startswith("signed-off-by") and x != ''] 32 line_count = len(filtered) 33 min_line_count = self.options['min-line-count'].value 34 if line_count < min_line_count: 35 message = "Commit message body is empty, should at least have {} line(s).".format(min_line_count) 36 return [RuleViolation(self.id, message, line_nr=1)] 37 38class BodyMaxLineCount(CommitRule): 39 # A rule MUST have a human friendly name 40 name = "body-max-line-count" 41 42 # A rule MUST have an *unique* id, we recommend starting with UC (for User-defined Commit-rule). 43 id = "UC1" 44 45 # A rule MAY have an options_spec if its behavior should be configurable. 46 options_spec = [IntOption('max-line-count', 200, "Maximum body line count")] 47 48 def validate(self, commit): 49 line_count = len(commit.message.body) 50 max_line_count = self.options['max-line-count'].value 51 if line_count > max_line_count: 52 message = "Commit message body contains too many lines ({0} > {1})".format(line_count, max_line_count) 53 return [RuleViolation(self.id, message, line_nr=1)] 54 55class SignedOffBy(CommitRule): 56 """ This rule will enforce that each commit contains a "Signed-off-by" line. 57 We keep things simple here and just check whether the commit body contains a line that starts with "Signed-off-by". 58 """ 59 60 # A rule MUST have a human friendly name 61 name = "body-requires-signed-off-by" 62 63 # A rule MUST have an *unique* id, we recommend starting with UC (for User-defined Commit-rule). 64 id = "UC2" 65 66 def validate(self, commit): 67 flags = re.UNICODE 68 flags |= re.IGNORECASE 69 for line in commit.message.body: 70 if line.lower().startswith("signed-off-by"): 71 if not re.search(r"(^)Signed-off-by: ([-'\w.]+) ([-'\w.]+) (.*)", line, flags=flags): 72 return [RuleViolation(self.id, "Signed-off-by: must have a full name", line_nr=1)] 73 else: 74 return 75 return [RuleViolation(self.id, "Commit message does not contain a 'Signed-off-by:' line", line_nr=1)] 76 77class TitleMaxLengthRevert(LineRule): 78 name = "title-max-length-no-revert" 79 id = "UC5" 80 target = CommitMessageTitle 81 options_spec = [IntOption('line-length', 75, "Max line length")] 82 violation_message = "Commit title exceeds max length ({0}>{1})" 83 84 def validate(self, line, _commit): 85 max_length = self.options['line-length'].value 86 if len(line) > max_length and not line.startswith("Revert"): 87 return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)] 88 89class TitleStartsWithSubsystem(LineRule): 90 name = "title-starts-with-subsystem" 91 id = "UC3" 92 target = CommitMessageTitle 93 options_spec = [StrOption('regex', ".*", "Regex the title should match")] 94 95 def validate(self, title, _commit): 96 regex = self.options['regex'].value 97 pattern = re.compile(regex, re.UNICODE) 98 violation_message = "Commit title does not follow [subsystem]: [subject] (and should not start with literal subsys:)" 99 if not pattern.search(title): 100 return [RuleViolation(self.id, violation_message, title)] 101 102class MaxLineLengthExceptions(LineRule): 103 name = "max-line-length-with-exceptions" 104 id = "UC4" 105 target = CommitMessageBody 106 options_spec = [IntOption('line-length', 75, "Max line length")] 107 violation_message = "Commit message body line exceeds max length ({0}>{1})" 108 109 def validate(self, line, _commit): 110 max_length = self.options['line-length'].value 111 urls = re.findall(r'http[s]?://(?:[a-zA-Z]|[0-9]|[$-_@.&+]|[!*\(\),]|(?:%[0-9a-fA-F][0-9a-fA-F]))+', line) 112 if line.lower().startswith('signed-off-by') or line.lower().startswith('co-authored-by'): 113 return 114 115 if urls: 116 return 117 118 if len(line) > max_length: 119 return [RuleViolation(self.id, self.violation_message.format(len(line), max_length), line)] 120 121class BodyContainsBlockedTags(LineRule): 122 name = "body-contains-blocked-tags" 123 id = "UC7" 124 target = CommitMessageBody 125 tags = ["Change-Id"] 126 127 def validate(self, line, _commit): 128 flags = re.IGNORECASE 129 for tag in self.tags: 130 if re.search(rf"^\s*{tag}:", line, flags=flags): 131 return [RuleViolation(self.id, f"Commit message contains a blocked tag: {tag}")] 132 return 133