#!/usr/bin/env python3 """ LVGL Commit Message Style Checker Format: type(scope): description Valid types: feat, fix, arch, test, perf, example, refactor, revert, docs, style, chore, ci, build Scope: required (except chore/docs/ci), letters/digits/_/-/ allowed, e.g. (draw), (obj) Description: lowercase start, no trailing period Usage: commit_msg_check.py # Check commits between base branch and HEAD commit_msg_check.py --base # Specify base branch commit_msg_check.py --check-title "msg" # Check a single PR title / commit message commit_msg_check.py --self-test # Run self-tests See: https://docs.lvgl.io/master/contributing/pull_requests.html#commit-message-format """ import os import re import subprocess import sys import argparse VALID_TYPES = [ "feat", "fix", "arch", "test", "perf", "example", "refactor", "revert", "docs", "style", "chore", "ci", "build", ] # Common typos/aliases -> correct type TYPE_TYPOS = { "feature": "feat", "fea": "feat", "refact": "refactor", "doc": "docs", "tests": "test", "bugfix": "fix", "hotfix": "fix", "perf_opt": "perf", "optimize": "perf", } VALID_TYPES_RE = "|".join(VALID_TYPES) # type(scope): description (chore/docs/ci allow omitting scope) FULL_PATTERN = re.compile(rf"^({VALID_TYPES_RE})\(([a-zA-Z0-9_/-]+)\): (.+)$") # Types that allow omitting scope SCOPE_OPTIONAL_TYPES = {"chore", "docs", "ci"} # type: description (no scope, for scope-optional types) NO_SCOPE_PATTERN = re.compile(rf"^({'|'.join(SCOPE_OPTIONAL_TYPES)}): (.+)$") # type( or type: TYPE_ONLY_PATTERN = re.compile(r"^([a-zA-Z_]+)") # "don't squash" PR pattern (rebase merge, title won't become commit msg) # Covers: don't squash, dont squash, dont's squash, do not squash, do-not-squash DONT_SQUASH_PATTERN = re.compile( r"(?:don'?t'?s?|do[\s-]+not)[\s-]+squash", re.IGNORECASE ) # Commit message format documentation URL COMMIT_MSG_DOC_URL = ( "https://docs.lvgl.io/master/contributing/pull_requests.html" "#commit-message-format" ) def check_commit_msg(msg): """Check a single commit message subject line. Returns a list of error strings. Empty list means OK. """ errors = [] # Strip leading/trailing whitespace (common in git log output) msg = msg.strip() if not msg: errors.append("Commit message is empty") return errors # Allow Revert commits if msg.startswith('Revert "'): return errors # Allow merge commits if msg.startswith("Merge "): return errors # Check for Chinese punctuation (common mistake) cn_punctuation = { "\uff08": "(", # ( -> ( "\uff09": ")", # ) -> ) "\uff1a": ":", # : -> : "\u3002": ".", # 。 -> . "\uff0c": ",", # , -> , "\uff1b": ";", # ; -> ; } found_cn = [ f"'{ch}'(U+{ord(ch):04X}) -> '{en}'" for ch, en in cn_punctuation.items() if ch in msg ] if found_cn: errors.append( f"Chinese punctuation detected: {', '.join(found_cn)}. " "Please use English punctuation only" ) return errors # Extract type word m = TYPE_ONLY_PATTERN.match(msg) type_word = m.group(1) if m else "" type_lower = type_word.lower() # Check common typos first if type_lower in TYPE_TYPOS: correct = TYPE_TYPOS[type_lower] errors.append(f"Type '{type_word}' is not standard, did you mean '{correct}'?") return errors # Check type is valid and followed by '(' type_with_paren = re.compile(rf"^({VALID_TYPES_RE})\(") if not type_with_paren.match(msg): # type: desc (missing scope) type_with_colon = re.compile(rf"^({VALID_TYPES_RE}):") if type_with_colon.match(msg): # Allow scope-optional types (chore, docs, ci) without scope if type_lower in SCOPE_OPTIONAL_TYPES: no_scope_match = NO_SCOPE_PATTERN.match(msg) if no_scope_match: desc = no_scope_match.group(2) if desc and desc[0].isupper(): errors.append( f"Description should start with lowercase: '{desc[:30]}...'" ) if desc.endswith("."): errors.append("Description should not end with a period") return errors errors.append( "Missing scope. Use 'type(scope): description' " "instead of 'type: description'" ) elif msg and msg[0].isupper(): errors.append( "Do not start with a capital letter. " "Use conventional commit format: type(scope): description" ) else: errors.append( f"Invalid type '{type_word}'. " f"Allowed types: {', '.join(VALID_TYPES)}" ) return errors # Full format check full = FULL_PATTERN.match(msg) if not full: # Diagnose specific issues empty_scope = re.compile(rf"^({VALID_TYPES_RE})\(\)") no_space = re.compile(rf"^({VALID_TYPES_RE})\([^)]*\):[^ ]") no_colon = re.compile(rf"^({VALID_TYPES_RE})\([^)]*\)[^:]") if empty_scope.match(msg): errors.append("Scope cannot be empty") elif no_space.match(msg): errors.append("Missing space after colon. Use 'type(scope): description'") elif no_colon.match(msg): errors.append("Missing colon after scope. Use 'type(scope): description'") else: # Check if scope contains filename or PR reference scope_match = re.match(rf"^({VALID_TYPES_RE})\(([^)]+)\): .+", msg) if scope_match: scope = scope_match.group(2) if re.search(r"\.[a-zA-Z]+$", scope): errors.append( f"Scope '{scope}' looks like a filename. " "Use a module name instead, e.g. 'obj' not 'lv_obj.h'" ) elif "#" in scope: errors.append( f"Scope '{scope}' should be a module name, " "not a PR reference. Put PR number in description, " "e.g. 'fix(module): description (#1234)'" ) else: errors.append("Invalid format. Expected: type(scope): description") else: errors.append("Invalid format. Expected: type(scope): description") return errors # Validate description desc = full.group(3) if desc and desc[0].isupper(): errors.append(f"Description should start with lowercase: '{desc[:30]}...'") if desc.endswith("."): errors.append("Description should not end with a period") return errors def git_run(*args): """Run a git command and return stdout.""" result = subprocess.run(["git"] + list(args), capture_output=True, text=True) return result.stdout.strip(), result.returncode def find_base_branch(): """Find the base branch for comparison.""" candidates = ["origin/master", "origin/main", "upstream/master", "upstream/main"] for branch in candidates: _, rc = git_run("rev-parse", "--verify", branch) if rc == 0: return branch return None def check_commits(base_branch=None, last_n=None): """Check all commits between base branch and HEAD.""" if last_n: log_output, _ = git_run("log", "--oneline", f"-{last_n}") if not log_output: print("No commits to check.") return 0 print(f"Checking commit message style (last {last_n} commits)...") else: if not base_branch: base_branch = find_base_branch() if not base_branch: print( "Warning: Could not determine base branch, " "skipping commit message style check." ) return 0 merge_base, rc = git_run("merge-base", base_branch, "HEAD") if rc != 0 or not merge_base: print( "Warning: Could not determine merge base, " "skipping commit message style check." ) return 0 head, _ = git_run("rev-parse", "HEAD") if head == merge_base: print("No new commits to check, skipping commit message style check.") return 0 log_output, _ = git_run("log", "--oneline", f"{merge_base}..HEAD") if not log_output: print("No new commits to check.") return 0 print(f"Checking commit message style (base: {base_branch})...") error_count = 0 total_count = 0 for line in log_output.splitlines(): parts = line.split(" ", 1) if len(parts) < 2: continue hash_str, msg = parts[0], parts[1] total_count += 1 errors = check_commit_msg(msg) if errors: error_count += 1 print(f"\n Commit: {hash_str[:12]} {msg}") for e in errors: print(f" ✗ {e}") else: print(f" ✓ {hash_str[:12]} {msg}") if error_count > 0: print( f""" ========================================== Commit message style check FAILED {error_count} commit(s) with bad format ========================================== Expected format: type(scope): description Valid types: {', '.join(VALID_TYPES)} Scope: required (except chore/docs/ci), e.g. (draw), (obj), (style) Description: lowercase start, no trailing period Good examples: feat(draw): add new gradient support fix(obj): fix crash when object is deleted test(cache): add complete cache test cases perf(style): optimize style removal performance See: {COMMIT_MSG_DOC_URL} Use 'git commit --amend' or 'git rebase -i' to fix your commit messages.""" ) return 1 print("Commit message style check passed") return 0 # ============================================================================ # Self-test # ============================================================================ def self_test(): """Run self-tests to verify the checker works correctly.""" pass_cases = [ # Standard format ("feat(draw): add support for dashed line rendering", "standard feat"), ("fix(render): resolve null pointer in flush callback", "standard fix"), ("test(cache): add unit tests for eviction policy", "standard test"), ("perf(style): reduce redundant lookups in style resolve", "standard perf"), ("refactor(obj): split tree logic into separate module", "standard refactor"), ("docs(readme): update build instructions for linux", "standard docs"), ("style(src): apply clang-format to all source files", "standard style"), ("chore(deps): bump third-party library to latest tag", "standard chore"), ("ci(github): add workflow for automated testing", "standard ci"), ("build(cmake): add option to disable example targets", "standard build"), ("arch(core): restructure module dependency graph", "standard arch"), ("example(widgets): add demo for new button styles", "standard example"), # Scope with special chars ("fix(mod_a/sub_b): handle edge case in sub module", "scope with slash"), ("fix(draw-sw): prevent buffer overflow in blend op", "scope with hyphen"), ("feat(widget_v2): expose new public api for widgets", "scope with underscore"), ("feat(myGFX): add hardware acceleration support", "scope with uppercase"), # Revert / Merge (always allowed) ('Revert "fix(render): disable fast path for now"', "revert commit"), ("Merge branch 'main' into dev", "merge commit"), ("revert(render): undo fast path optimization change", "revert with scope"), # Edge: description exactly 10 chars ("feat(core): 0123456789", "description exactly 10 chars"), # PR number in description ("fix(render): handle opacity reset on layer fail (#9521)", "with PR number"), # chore/docs/ci without scope (allowed) ("chore: bump version to release candidate tag", "chore without scope"), ("chore: fix typos in configuration file names", "chore without scope 2"), ("docs: fix typos", "docs without scope"), ("docs: add hero image", "docs without scope 2"), ("ci: add workflow for automated testing", "ci without scope"), ("ci: deploy doc builds to release folders", "ci without scope 2"), # Leading/trailing whitespace (should be stripped) (" feat(draw): add gradient support ", "leading/trailing spaces"), ] # Don't squash cases: should pass check_title() but NOT check_commit_msg() dont_squash_cases = [ ("Dont's squash: minor docs fixes", "dont squash variant 1"), ( "Dont' Squash: improvements and fixes to workflow", "dont squash variant 2", ), ("Dont's squash - gradient updates", "dont squash variant 3"), ( "feat(gdb): add lvgl GDB plugin (don't squash)", "dont squash in parens", ), ("DONT SQUASH: feat(draw): add new gradient support", "dont squash caps"), ("dont squash - multiple independent fixes", "dont squash lowercase"), ("do not squash: multiple independent fixes", "do not squash"), ("do-not-squash: multiple independent fixes", "do-not-squash"), ] fail_cases = [ # Common typos ("feature(anim): add transition support for opacity", "typo: feature -> feat"), ("fea(scroll): add force elastic attribute to scroll", "typo: fea -> feat"), ( "refact(gif): restructure decoder and add testcase", "typo: refact -> refactor", ), ("doc(readme): update build instructions for linux", "typo: doc -> docs"), ("tests(cache): add unit tests for eviction policy", "typo: tests -> test"), ("bugfix(render): resolve null pointer in callback", "typo: bugfix -> fix"), ("hotfix(obj): fix crash when object is deleted", "typo: hotfix -> fix"), # Missing scope ("fix: handle invalid escape sequence in parser", "missing scope"), ("feat: add something really cool to the project", "missing scope"), ("test: add unit tests without specifying a scope", "missing scope test"), # Capital letter start (no type) ("Add new parameter for module initialization", "capital letter start"), ("Update documentation with new build instructions", "capital letter start"), # Invalid type ("update(core): change default config values here", "invalid type"), ("add(draw): introduce new gradient for objects", "invalid type"), ("wayland: add API to get fullscreen state", "invalid type module name"), # Empty scope ("feat(): add something without a scope name", "empty scope"), # Missing space after colon ("feat(draw):implement dashed line rendering now", "no space after colon"), ("fix(parser):handle edge case in token scanner", "no space after colon 2"), # Scope is a filename (not allowed) ("fix(helper_sw.c): prevent buffer overflow in blend", "scope is filename .c"), ("feat(lv_obj.h): expose new public api for widgets", "scope is filename .h"), ("docs(README.md): update build instructions for dev", "scope is filename .md"), # Scope is a PR reference (not allowed) ( "fix(PR#1234): address review comments from reviewer", "scope is PR reference", ), # Space before colon ("fix(parser) :handle edge case in token scanner", "space before colon"), # Missing colon ("feat(draw) implement dashed line rendering now", "no colon after scope"), # Description starts with uppercase ("feat(draw): Implement dashed line rendering now", "uppercase description"), ("fix(scale): Don't return early on main drawing", "uppercase contraction"), ( "chore(cmsis-pack): Prepare for v9.5.0", "uppercase description chore with scope", ), # Description ends with period ("feat(draw): implement dashed line rendering now.", "trailing period"), ( "feat(nema_gfx): integrate hardware acceleration.", "trailing period 2", ), # Chinese punctuation ("fix\uff08draw\uff09: handle edge case in flush callback", "chinese parens"), ("fix(draw)\uff1ahandle edge case in flush callback", "chinese colon"), ("fix(draw): handle edge case in flush callback\u3002", "chinese period"), ( "fix(draw)\uff1a handle edge case in flush callback", "chinese colon with space", ), ( "feat\uff08core\uff09\uff1aadd new public api for widget tree", "all chinese punctuation", ), # Random garbage ("this is not a valid commit message at all", "random text"), ("just some random words without any structure", "random text 2"), ("WIP something something something something", "WIP commit"), ("Feat/lv check obj", "branch name as title"), ("Initial commit", "initial commit"), # Empty message ("", "empty message"), (" ", "whitespace only"), ] passed = 0 failed = 0 total = len(pass_cases) + len(fail_cases) print("=" * 60) print(" Commit Message Checker Self-Test") print("=" * 60) # Test cases that should PASS print("\n--- Should PASS ---") for msg, desc in pass_cases: errors = check_commit_msg(msg) if not errors: passed += 1 print(f" ✓ PASS [{desc}]") else: failed += 1 print(f" ✗ FAIL [{desc}]") print(f" msg: {msg}") for e in errors: print(f" err: {e}") # Test cases that should FAIL print("\n--- Should FAIL ---") for msg, desc in fail_cases: errors = check_commit_msg(msg) if errors: passed += 1 print(f" ✓ PASS [{desc}] -> caught: {errors[0]}") else: failed += 1 print(f" ✗ FAIL [{desc}] -> should have been rejected!") print(f" msg: {msg}") # Test don't squash: should bypass check_title() but NOT check_commit_msg() total += len(dont_squash_cases) print("\n--- Don't Squash (check_title should pass, check_commit_msg should fail) ---") for msg, desc in dont_squash_cases: title_rc = check_title(msg) commit_errors = check_commit_msg(msg) # check_title should return 0 (pass) due to don't squash bypass # check_commit_msg should return errors (unless msg is independently valid) msg_is_valid = not check_commit_msg(msg.replace("don't squash", "").replace("dont squash", "")) if title_rc == 0 and (commit_errors or msg_is_valid): passed += 1 if commit_errors: print(f" ✓ PASS [{desc}] -> title bypassed, commit rejected") else: print(f" ✓ PASS [{desc}] -> title bypassed, commit valid independently") else: failed += 1 if title_rc != 0: print(f" ✗ FAIL [{desc}] -> check_title should have bypassed!") else: print(f" ✗ FAIL [{desc}] -> check_commit_msg should have rejected!") print(f" msg: {msg}") print(f"\n{'=' * 60}") # Lint self total += 1 print(f"\n{'=' * 60}") print(" Lint Check (self)") print(f"{'=' * 60}") script_path = os.path.abspath(__file__) lint_failed = False # Syntax check try: import py_compile py_compile.compile(script_path, doraise=True) print(" ✓ py_compile: syntax OK") except py_compile.PyCompileError as e: print(f" ✗ py_compile: {e}") lint_failed = True # flake8 if available try: result = subprocess.run( ["flake8", "--max-line-length=120", "--ignore=E501,W503", script_path], capture_output=True, text=True, ) if result.returncode == 0: print(" ✓ flake8: no issues") else: print(" ✗ flake8:") for line in result.stdout.strip().splitlines(): print(f" {line}") lint_failed = True except FileNotFoundError: print(" - flake8: not installed, skipped") if lint_failed: failed += 1 else: passed += 1 print(f"\n{'=' * 60}") print(f" Final Results: {passed}/{total} passed, {failed} failed") print(f"{'=' * 60}") return 0 if failed == 0 else 1 def check_title(title): """Check a single PR title / commit message string.""" # Allow "don't squash" PRs (rebase merge, title won't become commit msg) if DONT_SQUASH_PATTERN.search(title): print(f"✓ PR title OK (don't squash): {title}") return 0 errors = check_commit_msg(title) if errors: print(f"\n PR Title: {title}") for e in errors: print(f" ✗ {e}") print( f"\nExpected format: type(scope): description\n" f"\n" f" Valid types: {', '.join(VALID_TYPES)}\n" f" Scope: required (except chore/docs/ci), e.g. (draw), (obj)\n" f" Description: lowercase start, no trailing period\n" f"\n" f"Examples:\n" f" feat(draw): add new gradient support\n" f" fix(obj): fix crash when object is deleted\n" f"\n" f"See: {COMMIT_MSG_DOC_URL}" ) return 1 print(f"✓ PR title OK: {title}") return 0 def main(): parser = argparse.ArgumentParser(description="LVGL Commit Message Style Checker") parser.add_argument( "--self-test", action="store_true", help="Run self-tests to verify the checker" ) parser.add_argument( "--base", type=str, default=None, help="Base branch for comparison (auto-detected if omitted)", ) parser.add_argument( "--last", type=int, default=None, help="Check the last N commits (useful for local testing)", ) parser.add_argument( "--check-title", type=str, default=None, help="Check a single PR title / commit message string", ) args = parser.parse_args() if args.self_test: return self_test() if args.check_title is not None: return check_title(args.check_title) return check_commits(args.base, args.last) if __name__ == "__main__": sys.exit(main())