|
| 1 | +import argparse |
| 2 | +import subprocess |
| 3 | +import re |
| 4 | + |
| 5 | +# ANSI escape codes for colors |
| 6 | +RED = '\033[91m' |
| 7 | +YELLOW = '\033[93m' |
| 8 | +GREEN = '\033[92m' |
| 9 | +BLUE = '\033[94m' |
| 10 | +RESET = '\033[0m' |
| 11 | + |
| 12 | +class CommitFormat: |
| 13 | + def __init__(self, verbosity=False): |
| 14 | + self.verbosity = verbosity |
| 15 | + |
| 16 | + def error(self, text: str): |
| 17 | + """Prints the given text in red.""" |
| 18 | + print(f"{RED}{text}{RESET}") |
| 19 | + |
| 20 | + def warning(self, text: str): |
| 21 | + """Prints the given text in yellow.""" |
| 22 | + print(f"{YELLOW}{text}{RESET}") |
| 23 | + |
| 24 | + def highlight_words_in_txt(self, text: str, words="", highlight_color=f"{RED}") -> str: |
| 25 | + """Prints the given text and highlights the words in the list.""" |
| 26 | + for word in words: |
| 27 | + word = self.remove_ansi_color_codes(word) |
| 28 | + text = text[::-1].replace(f" {word}"[::-1], f"{highlight_color} {word}{RESET}"[::-1], 1)[::-1] |
| 29 | + |
| 30 | + return text |
| 31 | + |
| 32 | + def remove_ansi_color_codes(self, text: str) -> str: |
| 33 | + ansi_escape_pattern = re.compile(r'\x1B[@-_][0-?]*[ -/]*[@-~]') |
| 34 | + return ansi_escape_pattern.sub('', text) |
| 35 | + |
| 36 | + def info(self, text: str): |
| 37 | + """Prints the given text in blue.""" |
| 38 | + print(text) |
| 39 | + |
| 40 | + def debug(self, text: str): |
| 41 | + """Prints the given text in green.""" |
| 42 | + if self.verbosity: |
| 43 | + print(text) |
| 44 | + |
| 45 | + def get_current_branch(self) -> str: |
| 46 | + result = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], capture_output=True, text=True) |
| 47 | + return result.stdout.strip() |
| 48 | + |
| 49 | + def list_unique_commits(self, current_branch, base_branch='main') -> list: |
| 50 | + if current_branch != base_branch: |
| 51 | + result = subprocess.run(['git', 'log', '--pretty=format:%h', f'{base_branch}..{current_branch}'], capture_output=True, text=True) |
| 52 | + return result.stdout.split() |
| 53 | + else: |
| 54 | + self.error(f"Running on branch {base_branch}. Abort checking commits.") |
| 55 | + exit(0) |
| 56 | + |
| 57 | + def get_commit_message(self, commit_sha: str) -> str: |
| 58 | + result = subprocess.run(['git', 'show', '-s', '--format=%B', commit_sha], capture_output=True, text=True) |
| 59 | + return result.stdout.strip() |
| 60 | + |
| 61 | + def run_codespell(self, message: str) -> tuple: |
| 62 | + result = subprocess.run(['codespell', '-c', '-', '-'], input=message, capture_output=True, text=True) |
| 63 | + lines = result.stdout.strip().split('\n') |
| 64 | + selected_lines = [line for index, line in enumerate(lines) if index % 2 != 0] |
| 65 | + faulty_words = [line.split()[0] for line in selected_lines if line] |
| 66 | + return '\n'.join(selected_lines), faulty_words |
| 67 | + |
| 68 | + def spell_check(self, commit: str, commit_message: str) -> bool: |
| 69 | + spell_error = 0 |
| 70 | + |
| 71 | + # Run codespell |
| 72 | + codespell_proposition, faulty_words = self.run_codespell(commit_message) |
| 73 | + if codespell_proposition: |
| 74 | + spell_error += 1 |
| 75 | + self.warning(f"Commit {commit} has spelling mistakes") |
| 76 | + self.info(self.highlight_words_in_txt(f"---\n{commit_message}", faulty_words)) |
| 77 | + self.info(f"---\nCodespell fix proposition:\n{codespell_proposition}\n---") |
| 78 | + |
| 79 | + # Run another spelling tool: |
| 80 | + # ... |
| 81 | + |
| 82 | + return spell_error |
| 83 | + |
| 84 | + def lines_length(self, commit: str, commit_message: str, length_limit=80) -> bool: |
| 85 | + length_exceeded = 0 |
| 86 | + line_number = 0 |
| 87 | + highlighted_commit_message = "" |
| 88 | + |
| 89 | + # Split the commit message into lines |
| 90 | + lines = commit_message.split('\n') |
| 91 | + |
| 92 | + # Check if any line exceeds the length limit |
| 93 | + for line in lines: |
| 94 | + line_number += 1 |
| 95 | + removed_words = [] |
| 96 | + |
| 97 | + if (line_number > 1): |
| 98 | + highlighted_commit_message += "\n" |
| 99 | + |
| 100 | + line_length = len(line) |
| 101 | + if line_length > length_limit: |
| 102 | + length_exceeded += 1 |
| 103 | + |
| 104 | + line_copy = line |
| 105 | + # Split the line into words |
| 106 | + while len(line_copy) > length_limit: |
| 107 | + # Find the last space in the line |
| 108 | + last_space_index = line_copy.rfind(' ') |
| 109 | + |
| 110 | + # If there's no space, break out of the loop |
| 111 | + if last_space_index == -1: |
| 112 | + break |
| 113 | + |
| 114 | + removed_word = line_copy[(last_space_index+1):] |
| 115 | + removed_words.append(removed_word) |
| 116 | + |
| 117 | + # Remove the last word by slicing up to the last space |
| 118 | + line_copy = line_copy[:last_space_index] |
| 119 | + |
| 120 | + highlighted_commit_message += f"{self.highlight_words_in_txt(line, removed_words)}" |
| 121 | + |
| 122 | + if (length_exceeded): |
| 123 | + self.warning(f"Commit {commit}: exceeds {length_limit} chars limit") |
| 124 | + self.info(f"---\n{highlighted_commit_message}\n---") |
| 125 | + |
| 126 | + return length_exceeded |
| 127 | + |
| 128 | + |
| 129 | + |
| 130 | +def main(): |
| 131 | + parser = argparse.ArgumentParser(description="Various checks on commit messages.") |
| 132 | + parser.add_argument('-l', '--lineslimit', type=int, default=80, help="commit message lines max length. (Default 80)") |
| 133 | + parser.add_argument('-v', '--verbosity', action='store_true', help="increase output verbosity") |
| 134 | + args = parser.parse_args() |
| 135 | + |
| 136 | + commit_format = CommitFormat(verbosity=args.verbosity) |
| 137 | + |
| 138 | + error_found = 0 |
| 139 | + current_branch = commit_format.get_current_branch() |
| 140 | + if not current_branch: |
| 141 | + commit_format.error("Not inside an active git repository") |
| 142 | + exit(1) |
| 143 | + |
| 144 | + commit_list = commit_format.list_unique_commits(current_branch) |
| 145 | + if not commit_list: |
| 146 | + commit_format.debug(f"No unique commits on branch {GREEN}{current_branch}{RESET}") |
| 147 | + exit(0) |
| 148 | + |
| 149 | + commit_format.debug(f"Checking {GREEN}{len(commit_list)}{RESET} commits on branch {GREEN}{current_branch}{RESET}") |
| 150 | + |
| 151 | + for commit in commit_list: |
| 152 | + error_on_commit = 0 |
| 153 | + commit_message = commit_format.get_commit_message(commit) |
| 154 | + error_on_commit += commit_format.spell_check(commit, commit_message) |
| 155 | + error_on_commit += commit_format.lines_length(commit, commit_message, args.lineslimit) |
| 156 | + |
| 157 | + if not error_on_commit: |
| 158 | + commit_format.info(f"{GREEN}Commit {commit}{RESET}") |
| 159 | + else: |
| 160 | + error_found += error_on_commit |
| 161 | + |
| 162 | + exit(error_found) |
| 163 | + |
| 164 | +if __name__ == '__main__': |
| 165 | + main() |
0 commit comments