diff --git a/template.ini b/.commit-format similarity index 85% rename from template.ini rename to .commit-format index 36fc835..7d1b17c 100644 --- a/template.ini +++ b/.commit-format @@ -1,6 +1,6 @@ [header] # header line regex: -pattern = ^(option: |requirements: |doc: |release: ).+$ +pattern = ^(option: |requirements: |ci: |doc: |release: ).+$ [body] # Allow empty body commit message. (i.e. single line commit message). diff --git a/.github/workflows/commit-format.yml b/.github/workflows/commit-format.yml new file mode 100644 index 0000000..c0dc496 --- /dev/null +++ b/.github/workflows/commit-format.yml @@ -0,0 +1,24 @@ +name: commit-format 🔍 + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install codespell + - name: commit-format + run: | + git fetch --no-tags --depth=1 origin main + python commit_format/commit_format.py -t .commit-format -v -b origin/main diff --git a/.github/workflows/pylint.yml b/.github/workflows/pylint.yml new file mode 100644 index 0000000..0be0f27 --- /dev/null +++ b/.github/workflows/pylint.yml @@ -0,0 +1,23 @@ +name: Pylint 🐍 + +on: [push] + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] + steps: + - uses: actions/checkout@v4 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v3 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pylint + - name: Pylint 🐍 + run: | + pylint $(git ls-files '*.py') diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 837f610..686cda6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Publish Python 🐍 distribution 📦 to PyPI +name: Build and Publish on: push diff --git a/README.md b/README.md index 502c849..4e00819 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@ A tool to check your commit messages format. ## Supported checkers -Primarly disigned for to check for spelling mistakes in commit messages, +Primarily disigned for to check for spelling mistakes in commit messages, `commit-format` now comes with various checker allowing to: - Check that each message lines does not exceed a length limit. @@ -49,16 +49,16 @@ $ commit-format -ns ### -t (--template FILE) Template compliance -You can provide a simple INI template to validate the commit header/footer format +You can provide a configuration TOML file template to validate the commit header/footer format and required symbols. Usage: ```sh -$ commit-format -t /path/to/template.ini +$ commit-format -t /path/to/.commit-format ``` -Template schema (INI): +Template schema (TOML): - [header] - pattern: Regex that the first line (header) must match. @@ -69,19 +69,25 @@ Template schema (INI): - required: true/false to require a footer section. - pattern: Regex that each footer line must match. -Example `template.ini`: +Example `.commit-format`: -```ini +```toml [header] -pattern = ^(feat: |fix: |doc: |ci: ).+$ +# header line regex: +pattern = ^(feat: |fic: |ci: |doc: ).+$ -[structure] +[body] +# Allow empty body commit message. (i.e. single line commit message). allow_empty = false +# Require that header line and body line are separated by an empty line. blank_line_after_header = true [footer] +# Require a footer line required = true +# Footer line regex pattern = ^(Signed-off-by: ).+$ + ``` ## Behavior option @@ -89,7 +95,7 @@ pattern = ^(Signed-off-by: ).+$ ### -a (--all) Force checking all commits By default the script will only run on a branch and stop when reaching the base branch. -If run on a base branch direclty, the script will throw an error: +If run on a base branch directly, the script will throw an error: ```sh $ commit-format @@ -114,12 +120,15 @@ As described in [option -a section](#a---all-force-checking-all-commits) the bas to let the script restrict it's analysis on the commits of a branch. Default value for the base branch name is `main`. +> When running this script in a CI environment, you may be required to fetch your base branch +> manually. See [github workflow](.github/workflows/commit-format.yml) example. + Usage: ```sh -$ commit-format -b master +$ commit-format -b origin/main ``` ### -v (--verbosity) -Diplay debug messages from the script. +Display debug messages from the script. diff --git a/commit_format/__init__.py b/commit_format/__init__.py index 79e5a71..bf865a0 100644 --- a/commit_format/__init__.py +++ b/commit_format/__init__.py @@ -1 +1,2 @@ +# pylint: skip-file __author__ = 'Alex Fabre' diff --git a/commit_format/commit_format.py b/commit_format/commit_format.py index e7b0e8a..556d8e8 100644 --- a/commit_format/commit_format.py +++ b/commit_format/commit_format.py @@ -1,5 +1,13 @@ +# pylint: disable=C0114 +# pylint: disable=C0115 +# pylint: disable=C0116 +# pylint: disable=R0912 +# pylint: disable=R0914 +# pylint: disable=R0915 + import argparse import subprocess +import sys import re import configparser from urllib.parse import urlparse @@ -12,11 +20,11 @@ RESET = '\033[0m' def is_url(url): - try: - result = urlparse(url) - return all([result.scheme, result.netloc]) - except ValueError: - return False + try: + result = urlparse(url) + return all([result.scheme, result.netloc]) + except ValueError: + return False class CommitFormat: def __init__(self, verbosity=False): @@ -35,8 +43,8 @@ def highlight_words_in_txt(self, text: str, words="", highlight_color=f"{RED}") """Prints the given text and highlights the words in the list.""" for word in words: word = self.remove_ansi_color_codes(word) - text = text[::-1].replace(f"{word}"[::-1], f"{highlight_color}{word}{RESET}"[::-1], 1)[::-1] - + text = text[::-1].replace(f"{word}"[::-1], + f"{highlight_color}{word}{RESET}"[::-1], 1)[::-1] return text def remove_ansi_color_codes(self, text: str) -> str: @@ -53,32 +61,44 @@ def debug(self, text: str): print(text) def get_current_branch(self) -> str: - result = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], capture_output=True, text=True) + self.debug("get_current_branch: git rev-parse --abbrev-ref HEAD") + result = subprocess.run(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], + capture_output=True, text=True, check=False) return result.stdout.strip() def list_unique_commits(self, current_branch, base_branch) -> list: if current_branch != base_branch: - result = subprocess.run(['git', 'log', '--pretty=format:%h', f'{base_branch}..{current_branch}'], capture_output=True, text=True) + self.debug("list_unique_commits: git log --pretty=format:%h " + f"{base_branch}..{current_branch}") + result = subprocess.run(['git', 'log', '--pretty=format:%h', + f'{base_branch}..{current_branch}'], + capture_output=True, + text=True, check=False) return result.stdout.split() - else: - self.error(f"Running on branch {base_branch}. Abort checking commits.") - exit(0) - + + self.error(f"Running on branch {base_branch}. Abort checking commits.") + sys.exit(0) + def list_all_commits(self) -> list: - result = subprocess.run(['git', 'log', '--pretty=format:%h'], capture_output=True, text=True) - return result.stdout.split() + result = subprocess.run(['git', 'log', '--pretty=format:%h'], + capture_output=True, text=True, check=False) + return result.stdout.split() def get_commit_message(self, commit_sha: str) -> str: - result = subprocess.run(['git', 'show', '-s', '--format=%B', commit_sha], capture_output=True, text=True) + result = subprocess.run(['git', 'show', '-s', '--format=%B', commit_sha], + capture_output=True, + text=True, check=False) return result.stdout.strip() def run_codespell(self, message: str) -> tuple: - result = subprocess.run(['codespell', '-c', '-', '-'], input=message, capture_output=True, text=True) + result = subprocess.run(['codespell', '-c', '-', '-'], input=message, + capture_output=True, + text=True, check=False) lines = result.stdout.strip().split('\n') selected_lines = [line for index, line in enumerate(lines) if index % 2 != 0] faulty_words = [line.split()[0] for line in selected_lines if line] return '\n'.join(selected_lines), faulty_words - + def spell_check(self, commit: str, commit_message: str) -> bool: spell_error = 0 @@ -89,17 +109,17 @@ def spell_check(self, commit: str, commit_message: str) -> bool: self.warning(f"Commit {commit} has spelling mistakes") self.info(self.highlight_words_in_txt(f"---\n{commit_message}", faulty_words)) self.info(f"---\nCodespell fix proposition:\n{codespell_proposition}\n---") - + # Run another spelling tool: # ... return spell_error - + def lines_length(self, commit: str, commit_message: str, length_limit) -> bool: - + if length_limit == 0: return 0 - + length_exceeded = 0 line_number = 0 url_format_error = False @@ -107,16 +127,16 @@ def lines_length(self, commit: str, commit_message: str, length_limit) -> bool: # This variable will handle the full commit message. # It's a line by line aggregation with the problematic words highlighted in RED. highlighted_commit_message = "" - + # Split the commit message into lines lines = commit_message.split('\n') - + # Check if any line exceeds the length limit for line in lines: line_number += 1 removed_words = [] - if (line_number > 1): + if line_number > 1: # A line return must be manually added at the beginning of new lines # to rebuild the commit message. highlighted_commit_message += "\n" @@ -138,7 +158,7 @@ def lines_length(self, commit: str, commit_message: str, length_limit) -> bool: while len(line_copy) > length_limit: # Find the last space in the line last_space_index = line_copy.rfind(' ') - + removed_word = line_copy[(last_space_index+1):] removed_words.append(removed_word) @@ -149,13 +169,13 @@ def lines_length(self, commit: str, commit_message: str, length_limit) -> bool: line_copy = line_copy[:last_space_index] highlighted_commit_message += f"{self.highlight_words_in_txt(line, removed_words)}" - - if (length_exceeded): + + if length_exceeded: self.warning(f"Commit {commit}: exceeds {length_limit} chars limit") self.info(f"---\n{highlighted_commit_message}\n---") - if (url_format_error == True): + if url_format_error is True: self.warning("---\nURL format:\n[index] url://...\n---") - + return length_exceeded def load_template(self, template_path: str): @@ -163,27 +183,27 @@ def load_template(self, template_path: str): read = cfg.read(template_path) if not read: self.error(f"Template file not found or unreadable: {template_path}") - exit(2) + sys.exit(2) self.commit_template = cfg - + def _split_message(self, message: str): lines = message.splitlines() line_cnt = len(lines) header = lines[0] if lines else "" - + # Identify the last non-empty line as the potential footer i = line_cnt - 1 while i > 0 and lines[i].strip() == "": i -= 1 - + footer_start = i if i > 0 else line_cnt - + # Determine the body by excluding the header and footer body = lines[1:footer_start] if line_cnt > 1 else [] footers = [lines[footer_start]] if footer_start < line_cnt else [] self.debug(f'--HEADER--\n{header}\n---BODY---\n{body}\n--FOOTER--\n{footers}\n----------') - + return header, body, footers, lines def template_check(self, commit: str, commit_message: str) -> int: @@ -203,7 +223,7 @@ def template_check(self, commit: str, commit_message: str) -> int: self.warning(f"Commit {commit}: header does not match required pattern") self.info(f"Header: '{header}'") self.info(f"Expected pattern: {pattern}") - + # Body separation check blank_after_header = False @@ -245,7 +265,9 @@ def template_check(self, commit: str, commit_message: str) -> int: self.warning(f"Commit {commit}: missing required footer section") # Footer line pattern - if footer_required and len(footers) > 0 and cfg.has_section('footer') and cfg.has_option('footer', 'pattern'): + if (footer_required and len(footers) > 0 + and cfg.has_section('footer') + and cfg.has_option('footer', 'pattern')): fpattern = cfg.get('footer', 'pattern') compiled = re.compile(fpattern) for line in footers: @@ -261,12 +283,28 @@ def template_check(self, commit: str, commit_message: str) -> int: def main(): parser = argparse.ArgumentParser(description="Perform various checks on commit messages.") - parser.add_argument('-ns', '--no-spelling', action='store_true', help="disable checking misspelled words") - parser.add_argument('-l', '--limit', type=int, default=72, help="commit lines maximum length. Default: '72' ('0' => no line limit)") - parser.add_argument('-t', '--template', type=str, default=None, help="path to a template INI file to validate header/body/footer commit message structure") - parser.add_argument('-b', '--base', type=str, default="main", help="name of the base branch. Default 'main'") - parser.add_argument('-a', '--all', action='store_true', help="check all commits (including base branch commits)") - parser.add_argument('-v', '--verbosity', action='store_true', help="increase output verbosity") + parser.add_argument('-ns', '--no-spelling', + action='store_true', + help="disable checking misspelled words") + parser.add_argument('-l', '--limit', + type=int, + default=72, + help="commit lines maximum length. Default: '72' ('0' => no line limit)") + parser.add_argument('-t', '--template', + type=str, + default=None, + help="path to a commit-format template file to validate header/body/footer " + "commit message structure") + parser.add_argument('-b', '--base', + type=str, + default="main", + help="name of the base branch. Default 'main'") + parser.add_argument('-a', '--all', + action='store_true', + help="check all commits (including base branch commits)") + parser.add_argument('-v', '--verbosity', + action='store_true', + help="increase output verbosity") args = parser.parse_args() commit_format = CommitFormat(verbosity=args.verbosity) @@ -278,23 +316,25 @@ def main(): current_branch = commit_format.get_current_branch() if not current_branch: commit_format.error("Not inside an active git repository") - exit(1) + sys.exit(1) - if args.all == True: + if args.all is True: commit_list = commit_format.list_all_commits() else: commit_list = commit_format.list_unique_commits(current_branch, args.base) - + if not commit_list: - commit_format.error(f"Error:{RESET} branch {GREEN}{current_branch}{RESET} has no diff commit with base branch {GREEN}{args.base}{RESET}") - exit(1) + commit_format.error(f"Error:{RESET} branch {GREEN}{current_branch}{RESET} " + f"has no diff commit with base branch {GREEN}{args.base}{RESET}") + sys.exit(1) - commit_format.debug(f"Checking {GREEN}{len(commit_list)}{RESET} commits on branch {GREEN}{current_branch}{RESET}") + commit_format.debug(f"Checking {GREEN}{len(commit_list)}{RESET} " + "commits on branch {GREEN}{current_branch}{RESET}") for commit in commit_list: error_on_commit = 0 commit_message = commit_format.get_commit_message(commit) - if args.no_spelling == False: + if args.no_spelling is False: error_on_commit += commit_format.spell_check(commit, commit_message) error_on_commit += commit_format.lines_length(commit, commit_message, args.limit) if commit_format.commit_template is not None: @@ -304,8 +344,8 @@ def main(): commit_format.info(f"{GREEN}Commit {commit} OK{RESET}") else: error_found += error_on_commit - - exit(error_found) + + sys.exit(error_found) if __name__ == '__main__': main() diff --git a/pyproject.toml b/pyproject.toml index 735eef1..9adcfef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "commit_format" -version = "0.2.0" +version = "0.2.1" authors = [ { name="Alex Fabre", email="m.alexfabre@gmail.com" }, ]