#!/usr/bin/env python3 # Check a Git commit message according to the seven rules of a good commit message: # https://chris.beams.io/posts/git-commit/ import sys class GitCommitMessage: "Represents a parsed Git commit message" rules = [ "Separate subject from body with a blank line", "Limit the subject line to 50 characters", "Capitalize the subject line", "Do not end the subject line with a period", "Use the imperative mood in the subject line", "Wrap the body at 72 characters", "Use the body to explain what and why vs. how", ] valid_rules = [False, False, False, False, False, False, False] def __init__(self, filename=None): lines = [] if filename is not None: with open(filename, "r", encoding="utf-8") as f: for line in f: if line.startswith( "# ------------------------ >8 ------------------------" ): break if not line.startswith("#"): lines.append(line) self.parse_lines(lines) def parse_lines(self, lines): self.body_lines = [] self.subject = [] if not lines or len(lines) == 0: return self self.subject = lines[0] self.subject_words = self.subject.split() self.has_subject_body_separator = False if len(lines) > 1: self.has_subject_body_separator = len(lines[1].strip()) == 0 if self.has_subject_body_separator: self.body_lines = lines[2:] else: self.body_lines = lines[1:] return self def check_subject_body_separtor(self): "Rule 1: Separate subject from body with a blank line" if len(self.body_lines) > 0: return self.has_subject_body_separator return True def check_subject_limit(self): "Rule 2: Limit the subject line to 50 characters" return len(self.subject.rstrip("\n")) <= 50 def check_subject_capitalized(self): "Rule 3: Capitalize the subject line" return len(self.subject) > 0 and self.subject[0].isupper() def check_subject_no_period(self): "Rule 4: Do not end the subject line with a period" return not self.subject.endswith(".") common_first_words = [ "Add", "Adjust", "Support", "Change", "Remove", "Fix", "Print", "Track", "Refactor", "Combine", "Release", "Set", "Stop", "Make", "Mark", "Enable", "Check", "Exclude", "Format", "Correct", ] def check_subject_imperative(self): """Rule 5: Use the imperative mood in the subject line. We can only check for common mistakes here, like using the -ing form of a verb or non-imperative version of common verbs """ firstword = self.subject_words[0] if firstword.endswith("ing"): return False for word in self.common_first_words: if firstword.startswith(word) and firstword != word: return False return True def check_body_limit(self): "Rule 6: Wrap the body at 72 characters" if len(self.body_lines) == 0: return True for line in self.body_lines: if len(line.rstrip("\n")) > 72: return False return True def check_body_uses_why(self): "Rule 7: Use the body to explain what and why vs. how" # Not enforceable return True rule_funcs = [ check_subject_body_separtor, check_subject_limit, check_subject_capitalized, check_subject_no_period, check_subject_imperative, check_body_limit, check_body_uses_why, ] def check_the_seven_rules(self): "validates the commit message against the seven rules" num_violations = 0 for i, func in enumerate(self.rule_funcs): res = func(self) self.valid_rules[i] = res if not res: num_violations += 1 if num_violations > 0: print() print("**** WARNING ****") print() print( "The commit message does not seem to comply with the project's guidelines." ) print('Please try to follow the "Seven rules of a great commit message":') print("https://chris.beams.io/posts/git-commit/") print() print("The following rules are violated:\n") for i in range(len(self.rule_funcs)): if not self.valid_rules[i]: print(f'\t* Rule {i+1}: "{self.rules[i]}"') # Extra sanity checks beyond the seven rules if len(self.body_lines) == 0: print() print("NOTE: the commit message has no body.") print("It is recommended to add a body with a description of your") print( "changes, even if they are small. Explain what and why instead of how:" ) print("https://chris.beams.io/posts/git-commit/#why-not-how") if len(self.subject_words) < 3: print() print("Warning: the subject line has less than three words.") print("Consider using a more explanatory subject line.") if num_violations > 0: print() print("Run 'git commit --amend' to change the commit message") print() return num_violations def main(): if len(sys.argv) != 2: print("Unexpected number of arguments") sys.exit(1) msg = GitCommitMessage(sys.argv[1]) return msg.check_the_seven_rules() if __name__ == "__main__": main() # Always exit with success. We could also fail the commit if with # a non-zero exit code, but that might be a bit excessive and we'd # have to save the failed commit message to a file so that it can # be recovered. sys.exit(0)