|
| 1 | +#!/usr/bin/env python3 |
| 2 | +"""Auto-fix a single commit message to satisfy commitlint (config-conventional + |
| 3 | +this repo's commitlint.config.js). |
| 4 | +
|
| 5 | +Designed to be driven by `git filter-branch --msg-filter` so it rewrites EVERY |
| 6 | +commit in a PR range in place, preserving each commit's diff and all merge |
| 7 | +topology — no destructive squash. Reads the raw message on stdin, writes the |
| 8 | +fixed message on stdout. It is deliberately conservative: a message that already |
| 9 | +passes is emitted byte-for-byte unchanged, so valid history is never churned. |
| 10 | +
|
| 11 | +Rules handled (all commitlint ERROR-level, i.e. the ones that actually fail CI): |
| 12 | + * type-enum / type-case — unknown or mis-cased type is remapped to an allowed, |
| 13 | + lower-case type (best-effort inference from wording). |
| 14 | + * subject-case — a Sentence/Start/Pascal/UPPER subject is lower-cased |
| 15 | + at its first character (the standard commitlint fix). |
| 16 | + * subject-full-stop — a single trailing '.' is stripped. |
| 17 | + * header-max-length (100)— an over-long header is trimmed at a word boundary and |
| 18 | + the trimmed remainder is preserved as a body line, so |
| 19 | + no information is lost. |
| 20 | + * non-conventional header— a header with no valid type gets one inferred and |
| 21 | + prefixed. |
| 22 | +
|
| 23 | +Merge/revert commits and commits already co-authored by github-actions[bot] are |
| 24 | +left untouched (commitlint ignores them via commitlint.config.js). |
| 25 | +
|
| 26 | +Kept in sync with commitlint.config.js: update ALLOWED_TYPES and HEADER_MAX_LEN |
| 27 | +here whenever that config changes. |
| 28 | +""" |
| 29 | +import re |
| 30 | +import sys |
| 31 | + |
| 32 | +# Must match commitlint.config.js type-enum (config-conventional + 'deps'). |
| 33 | +ALLOWED_TYPES = { |
| 34 | + "feat", "fix", "docs", "refactor", "perf", "test", |
| 35 | + "chore", "ci", "style", "revert", "deps", |
| 36 | +} |
| 37 | +# config-conventional header-max-length (not overridden in commitlint.config.js). |
| 38 | +HEADER_MAX_LEN = 100 |
| 39 | + |
| 40 | +_HEADER_RE = re.compile(r"^(?P<type>\w+)(?P<scope>\([^)]*\))?(?P<bang>!)?:\s*(?P<subject>.*)$") |
| 41 | + |
| 42 | + |
| 43 | +def _infer_type(text: str) -> str: |
| 44 | + """Best-effort conventional type from free-form wording.""" |
| 45 | + t = text.strip().lower() |
| 46 | + if re.match(r"^(add|implement|create|introduce|support)\b", t): |
| 47 | + return "feat" |
| 48 | + if re.match(r"^(fix|correct|resolve|patch|prevent|stop|guard)\b", t): |
| 49 | + return "fix" |
| 50 | + if re.match(r"^(document|docs?\b|update docs)", t): |
| 51 | + return "docs" |
| 52 | + if re.match(r"^refactor\b", t): |
| 53 | + return "refactor" |
| 54 | + if re.match(r"^(test|cover)\b", t): |
| 55 | + return "test" |
| 56 | + if re.match(r"^(bump|upgrade|update .*version|dependency|deps)\b", t): |
| 57 | + return "deps" |
| 58 | + if re.match(r"^(perf|optimi[sz]e|speed up)\b", t): |
| 59 | + return "perf" |
| 60 | + return "chore" |
| 61 | + |
| 62 | + |
| 63 | +def _lower_first(s: str) -> str: |
| 64 | + """Lower-case the first character so the subject cannot be classified as |
| 65 | + sentence-/start-/pascal-/upper-case (the cases config-conventional's |
| 66 | + subject-case rejects). |
| 67 | +
|
| 68 | + We deliberately lower-case even a leading acronym/PascalCase class name |
| 69 | + ('DeepFilterNet' -> 'deepFilterNet', 'DFA' -> 'dFA'): commitlint keys the |
| 70 | + check off the leading character, so this is the reliable automated fix. It |
| 71 | + is slightly less pretty than a human rephrase, but an auto-fixer's job is to |
| 72 | + make CI pass deterministically — a maintainer can always reword afterward. |
| 73 | + Interior words (e.g. a mid-subject 'BN'/'GPU') are untouched. |
| 74 | + """ |
| 75 | + if not s: |
| 76 | + return s |
| 77 | + if s[0].isupper(): |
| 78 | + return s[0].lower() + s[1:] |
| 79 | + return s |
| 80 | + |
| 81 | + |
| 82 | +def _shorten_header(prefix: str, subject: str): |
| 83 | + """Trim `prefix+subject` to <= HEADER_MAX_LEN at a word boundary. |
| 84 | +
|
| 85 | + Returns (header, overflow_or_None). Any trimmed words are returned as overflow |
| 86 | + so the caller can preserve them in the body. |
| 87 | + """ |
| 88 | + budget = HEADER_MAX_LEN - len(prefix) |
| 89 | + if budget <= 0: |
| 90 | + # Pathological: prefix alone already too long. Hard-truncate. |
| 91 | + return (prefix + subject)[:HEADER_MAX_LEN], None |
| 92 | + if len(subject) <= budget: |
| 93 | + return prefix + subject, None |
| 94 | + words = subject.split(" ") |
| 95 | + kept, length = [], 0 |
| 96 | + for w in words: |
| 97 | + add = (1 if kept else 0) + len(w) |
| 98 | + if length + add <= budget: |
| 99 | + kept.append(w) |
| 100 | + length += add |
| 101 | + else: |
| 102 | + break |
| 103 | + if not kept: |
| 104 | + # A single word longer than the budget — hard split it. |
| 105 | + return prefix + subject[:budget], subject[budget:] |
| 106 | + rest = " ".join(words[len(kept):]).strip() |
| 107 | + header = (prefix + " ".join(kept)).rstrip() |
| 108 | + overflow = ("…" + rest) if rest else None |
| 109 | + return header, overflow |
| 110 | + |
| 111 | + |
| 112 | +def fix_message(msg: str) -> str: |
| 113 | + lines = msg.split("\n") |
| 114 | + if not lines: |
| 115 | + return msg |
| 116 | + header = lines[0] |
| 117 | + |
| 118 | + # Leave commitlint-ignored commits untouched. |
| 119 | + if header.startswith("Merge ") or header.startswith("Revert "): |
| 120 | + return msg |
| 121 | + if "Co-Authored-By: github-actions[bot]" in msg or "Co-authored-by: github-actions[bot]" in msg: |
| 122 | + return msg |
| 123 | + |
| 124 | + body_lines = lines[1:] |
| 125 | + overflow = None |
| 126 | + |
| 127 | + m = _HEADER_RE.match(header) |
| 128 | + if m: |
| 129 | + type_ = m.group("type") |
| 130 | + scope = m.group("scope") or "" |
| 131 | + bang = m.group("bang") or "" |
| 132 | + subject = m.group("subject") |
| 133 | + |
| 134 | + type_l = type_.lower() |
| 135 | + if type_l not in ALLOWED_TYPES: |
| 136 | + type_l = _infer_type(subject) |
| 137 | + scope = scope.lower() |
| 138 | + # Drop a scope made redundant by a type remap, e.g. build(deps) -> deps. |
| 139 | + if scope.strip("()") == type_l: |
| 140 | + scope = "" |
| 141 | + subject = _lower_first(subject.rstrip()) |
| 142 | + if subject.endswith(".") and not subject.endswith("..."): |
| 143 | + subject = subject[:-1] |
| 144 | + |
| 145 | + prefix = f"{type_l}{scope}{bang}: " |
| 146 | + header, overflow = _shorten_header(prefix, subject) |
| 147 | + else: |
| 148 | + # Non-conventional header: infer a type and prefix it. |
| 149 | + subject = _lower_first(header.strip()) |
| 150 | + prefix = f"{_infer_type(header)}: " |
| 151 | + header, overflow = _shorten_header(prefix, subject) |
| 152 | + |
| 153 | + if overflow: |
| 154 | + # Preserve trimmed words as a body line with a leading blank (also |
| 155 | + # satisfies body-leading-blank when there was no body before). |
| 156 | + if body_lines and body_lines[0] != "": |
| 157 | + body_lines = ["", overflow] + body_lines |
| 158 | + elif body_lines: |
| 159 | + body_lines = [body_lines[0], overflow] + body_lines[1:] |
| 160 | + else: |
| 161 | + body_lines = ["", overflow] |
| 162 | + |
| 163 | + return "\n".join([header] + body_lines) |
| 164 | + |
| 165 | + |
| 166 | +def main() -> int: |
| 167 | + raw = sys.stdin.buffer.read().decode("utf-8", errors="replace") |
| 168 | + sys.stdout.buffer.write(fix_message(raw).encode("utf-8")) |
| 169 | + return 0 |
| 170 | + |
| 171 | + |
| 172 | +if __name__ == "__main__": |
| 173 | + raise SystemExit(main()) |
0 commit comments