|
8 | 8 |
|
9 | 9 | from __future__ import annotations |
10 | 10 |
|
| 11 | +import argparse |
11 | 12 | import re |
| 13 | +import subprocess |
12 | 14 | import sys |
| 15 | +from dataclasses import dataclass, field |
13 | 16 |
|
14 | 17 | TYPES: set[str] = { |
15 | 18 | "build", |
@@ -124,75 +127,141 @@ def validate_message(message: str) -> tuple[str | None, list[str]]: |
124 | 127 | return (error, warnings) |
125 | 128 |
|
126 | 129 |
|
127 | | -def run_local() -> None: |
128 | | - """Validate local commit messages ahead of origin/main. |
| 130 | +@dataclass |
| 131 | +class CommitResult: |
| 132 | + """Result of validating a single commit.""" |
129 | 133 |
|
130 | | - If there are uncommitted changes, prints a warning and skips validation. |
131 | | - """ |
132 | | - import subprocess |
| 134 | + sha: str |
| 135 | + subject: str |
| 136 | + error: str | None = None |
| 137 | + warnings: list[str] = field(default_factory=list) |
133 | 138 |
|
134 | | - # Check for uncommitted changes |
135 | | - status: subprocess.CompletedProcess[str] = subprocess.run( |
136 | | - ["git", "status", "--porcelain"], |
137 | | - capture_output=True, |
138 | | - text=True, |
139 | | - ) |
140 | | - if status.stdout.strip(): |
141 | | - print( |
142 | | - "WARNING: uncommitted changes detected, skipping commit message validation.\n" |
143 | | - "Commit your changes and re-run to validate." |
| 139 | + |
| 140 | +@dataclass |
| 141 | +class LintResult: |
| 142 | + """Result of linting a range of commits.""" |
| 143 | + |
| 144 | + commits: list[CommitResult] = field(default_factory=list) |
| 145 | + skipped: bool = False |
| 146 | + skip_reason: str = "" |
| 147 | + empty: bool = False |
| 148 | + git_error: str = "" |
| 149 | + |
| 150 | + @property |
| 151 | + def has_errors(self) -> bool: |
| 152 | + return bool(self.git_error) or any(c.error for c in self.commits) |
| 153 | + |
| 154 | + |
| 155 | +def lint_range(git_range: str, *, skip_dirty_check: bool = False) -> LintResult: |
| 156 | + """Validate commit messages in a git range (e.g. 'origin/main..HEAD'). |
| 157 | +
|
| 158 | + Args: |
| 159 | + git_range: A git revision range like 'origin/main..HEAD'. |
| 160 | + skip_dirty_check: When True, skip the uncommitted changes check |
| 161 | + (useful in CI where the worktree may be clean by definition). |
| 162 | +
|
| 163 | + Returns: |
| 164 | + A LintResult with per-commit validation results. |
| 165 | + """ |
| 166 | + if not skip_dirty_check: |
| 167 | + status = subprocess.run( |
| 168 | + ["git", "status", "--porcelain"], |
| 169 | + capture_output=True, |
| 170 | + text=True, |
144 | 171 | ) |
145 | | - return |
| 172 | + if status.stdout.strip(): |
| 173 | + return LintResult( |
| 174 | + skipped=True, |
| 175 | + skip_reason=( |
| 176 | + "uncommitted changes detected, skipping commit message validation.\n" |
| 177 | + "Commit your changes and re-run to validate." |
| 178 | + ), |
| 179 | + ) |
146 | 180 |
|
147 | | - # Get all commit messages ahead of origin/main |
148 | | - result: subprocess.CompletedProcess[str] = subprocess.run( |
149 | | - ["git", "log", "origin/main..HEAD", "--format=%H%n%B%n---END---"], |
| 181 | + result = subprocess.run( |
| 182 | + ["git", "log", "--no-merges", git_range, "-z", "--format=%H%n%B"], |
150 | 183 | capture_output=True, |
151 | 184 | text=True, |
152 | 185 | ) |
153 | 186 | if result.returncode != 0: |
154 | | - print(f"git log failed: {result.stderr}", file=sys.stderr) |
155 | | - sys.exit(1) |
156 | | - |
157 | | - raw: str = result.stdout.strip() |
158 | | - if not raw: |
159 | | - print("No local commits ahead of origin/main") |
160 | | - return |
| 187 | + return LintResult(git_error=result.stderr.strip()) |
161 | 188 |
|
162 | | - blocks: list[str] = raw.split("---END---") |
163 | | - has_errors: bool = False |
| 189 | + if not result.stdout.strip(): |
| 190 | + return LintResult(empty=True) |
164 | 191 |
|
165 | | - for block in blocks: |
166 | | - block = block.strip() |
167 | | - if not block: |
| 192 | + commits: list[CommitResult] = [] |
| 193 | + for record in result.stdout.split("\0"): |
| 194 | + if not record.strip(): |
168 | 195 | continue |
169 | | - |
170 | | - lines: list[str] = block.splitlines() |
171 | | - sha: str = lines[0][:7] |
172 | | - message: str = "\n".join(lines[1:]).strip() |
173 | | - |
| 196 | + sha, _, message = record.partition("\n") |
| 197 | + message = message.strip() |
174 | 198 | if not message: |
175 | 199 | continue |
176 | 200 |
|
177 | 201 | error, warnings = validate_message(message) |
178 | | - subject: str = message.splitlines()[0] |
| 202 | + subject = message.splitlines()[0] |
| 203 | + commits.append( |
| 204 | + CommitResult( |
| 205 | + sha=sha[:7], |
| 206 | + subject=subject, |
| 207 | + error=error, |
| 208 | + warnings=warnings, |
| 209 | + ) |
| 210 | + ) |
| 211 | + |
| 212 | + return LintResult(commits=commits) |
| 213 | + |
| 214 | + |
| 215 | +def write_output(lint_result: LintResult, git_range: str) -> None: |
| 216 | + """Write lint results to stdout/stderr.""" |
| 217 | + if lint_result.skipped: |
| 218 | + print(f"WARNING: {lint_result.skip_reason}") |
| 219 | + return |
179 | 220 |
|
180 | | - if error: |
181 | | - print(f"FAIL {sha}: {subject}", file=sys.stderr) |
182 | | - print(f" Error: {error}", file=sys.stderr) |
183 | | - has_errors = True |
| 221 | + if lint_result.git_error: |
| 222 | + print(f"git log failed: {lint_result.git_error}", file=sys.stderr) |
| 223 | + return |
| 224 | + |
| 225 | + if lint_result.empty: |
| 226 | + print(f"No commits in range {git_range}") |
| 227 | + return |
| 228 | + |
| 229 | + for commit in lint_result.commits: |
| 230 | + if commit.error: |
| 231 | + print(f"FAIL {commit.sha}: {commit.subject}", file=sys.stderr) |
| 232 | + print(f" Error: {commit.error}", file=sys.stderr) |
184 | 233 | else: |
185 | | - print(f"PASS {sha}: {subject}") |
| 234 | + print(f"PASS {commit.sha}: {commit.subject}") |
186 | 235 |
|
187 | | - for warning in warnings: |
| 236 | + for warning in commit.warnings: |
188 | 237 | print(f" Warning: {warning}") |
189 | 238 |
|
190 | | - if has_errors: |
| 239 | + |
| 240 | +def run_range(git_range: str, *, skip_dirty_check: bool = False) -> None: |
| 241 | + """Validate commit messages in a git range and exit on errors.""" |
| 242 | + lint_result = lint_range(git_range, skip_dirty_check=skip_dirty_check) |
| 243 | + write_output(lint_result, git_range) |
| 244 | + if lint_result.has_errors: |
191 | 245 | sys.exit(1) |
192 | 246 |
|
193 | 247 |
|
194 | 248 | def main() -> None: |
195 | | - run_local() |
| 249 | + parser = argparse.ArgumentParser( |
| 250 | + description="Lint commit messages for conventional commits compliance." |
| 251 | + ) |
| 252 | + parser.add_argument( |
| 253 | + "--range", |
| 254 | + default=None, |
| 255 | + dest="git_range", |
| 256 | + help="Validate all commits in a git revision range (e.g. 'origin/main..HEAD'). " |
| 257 | + "Skips the uncommitted-changes check (useful in CI).", |
| 258 | + ) |
| 259 | + args = parser.parse_args() |
| 260 | + |
| 261 | + if args.git_range is not None: |
| 262 | + run_range(args.git_range, skip_dirty_check=True) |
| 263 | + else: |
| 264 | + run_range("origin/main..HEAD") |
196 | 265 |
|
197 | 266 |
|
198 | 267 | if __name__ == "__main__": |
|
0 commit comments