commithygiene 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,236 @@
1
+ Metadata-Version: 2.4
2
+ Name: commithygiene
3
+ Version: 0.1.0
4
+ Summary: Read-only linter for noisy, AI-generated git commit history
5
+ Author: commithygiene contributors
6
+ License: MIT
7
+ Keywords: git,commits,linter,ai,agent,cli,ci
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Environment :: Console
10
+ Classifier: Intended Audience :: Developers
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Programming Language :: Python :: 3
13
+ Classifier: Programming Language :: Python :: 3.10
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Quality Assurance
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Dynamic: license-file
22
+
23
+ # commithygiene
24
+
25
+ <div align="center">
26
+
27
+ **The read-only linter for AI-mangled git history.**
28
+
29
+ [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-blue.svg)](https://www.python.org/downloads/)
30
+ [![Zero dependencies](https://img.shields.io/badge/dependencies-zero-brightgreen.svg)](#-zero-dependencies)
31
+ [![License: MIT](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
32
+ [![Tests](https://img.shields.io/badge/tests-13%2F13%20passing-success.svg)](tests/)
33
+
34
+ *AI agents write a lot of commits. Some of them deserve to be squashed before anyone sees them.*
35
+
36
+ </div>
37
+
38
+ ---
39
+
40
+ ## What problem does this solve?
41
+
42
+ Your AI coding agent (or you, at 2 a.m.) just produced this history:
43
+
44
+ ```
45
+ a1b2c3d Update LoginForm.tsx
46
+ d4e5f6 Fix TypeScript error
47
+ g7h8i9 Revert LoginForm.tsx
48
+ j0k1l2 Update LoginForm.tsx again
49
+ m3n4o5 Fix import
50
+ p6q7r8 wip
51
+ s9t0u1 LoginForm complete
52
+ ```
53
+
54
+ Seven commits. One component. **History that is useless for bisect, hostile to review, and embarrassing to merge.**
55
+
56
+ `commithygiene` finds these commits — *before* they hit your branch. It's read-only: it never rewrites history, never stages files, never touches your reflog. It just **reads** and **reports**. You decide what to do.
57
+
58
+ > **AI wrote the code. You're still responsible for the history.**
59
+
60
+ ## Why commithygiene?
61
+
62
+ Every other tool in this space wants to *rewrite* your history:
63
+
64
+ | Tool | Approach | Needs | Can it gate CI? |
65
+ |---|---|---|---|
66
+ | Claude `git-squash` skill | Interactive squash | Claude Code runtime | ❌ Lives in a chat session |
67
+ | `commit-tidy` / `squash-commits` skills | LLM-guided rebase | Claude + context | ❌ |
68
+ | `yawn` | AI *writes new* commit messages | API key | ❌ |
69
+ | `git-shrink` | Group + squash by similarity | Node.js + npm | ⚠️ Rewrites history |
70
+ | **commithygiene** | **Read-only linter with exit codes** | **Just Python 3.10+** | ✅ **Made for CI** |
71
+
72
+ The ecosystem had `prettier` but no `eslint`. `git-cliff` but no `shellcheck`. **commithygiene is the linter** — the thing you put in your pipeline to *catch* noise, not the thing you run interactively to fix it.
73
+
74
+ ## Features
75
+
76
+ - 🔒 **Read-only by design** — `git log` and `git show` only. Zero risk of destroying history.
77
+ - 🪶 **Zero dependencies** — one file, pure Python standard library. No npm, no Rust toolchain, no API key.
78
+ - 🤖 **Knows AI fingerprints** — detects `wip`, `fix typo again`, `revert the revert`, `actually works now`, and other agent churn patterns.
79
+ - 🚦 **CI-ready exit codes** — `0` clean, `1` noise found, `2` error. `--strict` promotes warnings to errors.
80
+ - 📊 **JSON output** — machine-readable for scripts, bots, and dashboards.
81
+ - 📈 **Health reports** — `report` gives you a project-wide noise ratio and verdict.
82
+ - 🐍 **Python 3.10+** — runs anywhere Python runs. Windows, macOS, Linux.
83
+
84
+ ## Quick start
85
+
86
+ ### 1. Drop it in
87
+
88
+ ```bash
89
+ # No install needed — it's a single file.
90
+ curl -O https://raw.githubusercontent.com/DEL8108/commit-hygiene-checker/main/commithygiene.py
91
+ python commithygiene.py check
92
+ ```
93
+
94
+ Or install properly:
95
+
96
+ ```bash
97
+ pip install git+https://github.com/DEL8108/commit-hygiene-checker.git
98
+ commithygiene check
99
+ ```
100
+
101
+ ### 2. Run it on your current branch
102
+
103
+ ```bash
104
+ $ commithygiene check
105
+
106
+ commithygiene — scanned 8 commit(s)
107
+
108
+ ✗ noise-subject 650b5e8c43 noise commit: 'wip'
109
+ ✗ noise-subject 9743088c03 noise commit: 'wip'
110
+ ! churn-subject 562493cd3e possible churn commit: 'fix typo again'
111
+ ! churn-subject 5871f1d5c1 possible churn commit: 'actually works now'
112
+ ! churn-subject a4590d7b47 possible churn commit: 'revert the revert'
113
+ ! trivial-only 4ffd15159e tiny diff touching only trivial files (README.md)
114
+ ```
115
+
116
+ Exit code `1` — your CI just found noise before your reviewer did.
117
+
118
+ ### 3. Gate your PRs
119
+
120
+ ```yaml
121
+ # .github/workflows/hygiene.yml
122
+ name: Commit Hygiene
123
+ on: [pull_request]
124
+ jobs:
125
+ hygiene:
126
+ runs-on: ubuntu-latest
127
+ steps:
128
+ - uses: actions/checkout@v4
129
+ with:
130
+ fetch-depth: 0 # need full history
131
+ - uses: actions/setup-python@v5
132
+ with:
133
+ python-version: "3.12"
134
+ - name: Check commit hygiene
135
+ run: |
136
+ curl -O https://raw.githubusercontent.com/DEL8108/commit-hygiene-checker/main/commithygiene.py
137
+ python commithygiene.py check "${{ github.event.pull_request.base.sha }}..HEAD" --strict
138
+ ```
139
+
140
+ Now every PR with a `wip` or an `actually works now` in it gets flagged **before merge**.
141
+
142
+ ## Usage
143
+
144
+ ### `check` — find noisy commits
145
+
146
+ ```bash
147
+ commithygiene check # scan all of HEAD
148
+ commithygiene check HEAD~20..HEAD # scan the last 20 commits
149
+ commithygiene check main..HEAD # only commits on this branch
150
+ commithygiene check --strict # warnings become errors (exit 1)
151
+ commithygiene check --format json # machine-readable output
152
+ ```
153
+
154
+ ### `report` — project-wide health
155
+
156
+ ```bash
157
+ $ commithygiene report
158
+
159
+ commit hygiene report — HEAD
160
+
161
+ commits : 128
162
+ noise commits : 31 (24%)
163
+ churn commits : 12 (9%)
164
+ verdict : very noisy
165
+ ```
166
+
167
+ ### Exit codes
168
+
169
+ | Code | Meaning |
170
+ |---|---|
171
+ | `0` | Clean — no noise, or only warnings (without `--strict`) |
172
+ | `1` | Noise found — errors, or warnings under `--strict` |
173
+ | `2` | Usage / I/O error (not a git repo, bad range, git missing) |
174
+
175
+ ## What gets flagged
176
+
177
+ | Rule | Severity | Example |
178
+ |---|---|---|
179
+ | `noise-subject` | error | `wip`, `tmp`, `fixup!`, `???`, `initial commit`, `checkpoint` |
180
+ | `churn-subject` | warning | `fix typo again`, `revert the revert`, `actually works now`, `for real this time` |
181
+ | `trivial-only` | warning | a 1-file commit touching only `README.md`, lockfiles, `.gitignore` |
182
+
183
+ Warnings stay warnings unless you pass `--strict` — because sometimes a `revert` is a legitimate, deliberate decision. `commithygiene` errs on the side of *showing* you, not *blocking* you.
184
+
185
+ ## How it works
186
+
187
+ ```
188
+ git log --format=%H <range> # list commits, oldest first
189
+
190
+
191
+ git show --numstat --format=%s <h> # subject + per-file stats per commit
192
+
193
+
194
+ heuristic engine # noise patterns, churn tokens, trivial paths
195
+
196
+
197
+ findings ──► table / json ──► exit code 0/1/2
198
+ ```
199
+
200
+ No git history is modified at any step. The only commands executed are `git log`, `git show`, and `git rev-parse`. You can audit the entire tool in one sitting — it's ~500 lines.
201
+
202
+ ## Why "hygiene"?
203
+
204
+ Because that's what it is. Like flossing, nobody *wants* to think about commit hygiene, but the alternative is worse. The name also means the tool is easily discoverable apart from the squash/rewrite crowd — this is the *prevention* layer, not the *surgery* layer.
205
+
206
+ ## Limitations
207
+
208
+ Being read-only and heuristic-driven means some things are out of scope, on purpose:
209
+
210
+ - **It never rewrites history.** For that, use `git rebase -i`, `git-squash`, or `git-shrink`.
211
+ - **It judges by signals, not semantics.** A `wip` with a giant meaningful diff is still flagged — you know your repo better than the heuristic does.
212
+ - **It doesn't verify build state.** Two commits both labeled `fix` could each be perfectly fine.
213
+ - **No AI, no LLM calls, no telemetry.** It's deterministic. The same input always produces the same output. That's a feature.
214
+
215
+ ## Contributing
216
+
217
+ Found a noise pattern the tool missed? A false positive? Contributions are welcome:
218
+
219
+ 1. Fork and clone the repo.
220
+ 2. Run the tests: `python -m unittest discover -s tests -v`
221
+ 3. Add a test for your pattern in `tests/test_commithygiene.py`.
222
+ 4. Open a PR with a clear description.
223
+
224
+ The whole philosophy is "small, conservative, zero false-positive pressure." New rules need example commits that prove the signal is real.
225
+
226
+ ## License
227
+
228
+ MIT — see [LICENSE](LICENSE). Do whatever you want with it; attribution appreciated.
229
+
230
+ ---
231
+
232
+ <div align="center">
233
+
234
+ **Star it if your agent's commit history has ever made you say "what is this?"** ⭐
235
+
236
+ </div>
@@ -0,0 +1,7 @@
1
+ commithygiene.py,sha256=23A6sqHanmJsfD0RMgkLn-ce6LV2n56fXvk138rSNGg,15122
2
+ commithygiene-0.1.0.dist-info/licenses/LICENSE,sha256=Iei147ZPSFkK0YZGXX_ULbzR4x6us61Bg3puaZvHW3E,1083
3
+ commithygiene-0.1.0.dist-info/METADATA,sha256=lGbSOhJ_CjdGDNTxbSHoeRiGVb1Sf4FCerxbJ0PO4FY,9028
4
+ commithygiene-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ commithygiene-0.1.0.dist-info/entry_points.txt,sha256=oUk57IZBudT7oV4meZwphxpQD7sjLhKhvRhFsUsOd6c,53
6
+ commithygiene-0.1.0.dist-info/top_level.txt,sha256=fMa3c1cg-nrRoQA1rABkfKq_Ue5LUTKAK5WbaJeonbM,14
7
+ commithygiene-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ commithygiene = commithygiene:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 commithygiene contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ commithygiene
commithygiene.py ADDED
@@ -0,0 +1,492 @@
1
+ #!/usr/bin/env python3
2
+ """commithygiene — a zero-dependency, read-only linter for AI-mangled git history.
3
+
4
+ AI coding agents (and humans in a hurry) leave a recognizable trail of "noise
5
+ commits": ``wip``, ``fix typo``, ``fix typo again``, ``revert`` followed by
6
+ ``revert the revert``, tiny diffs to the same file over and over. Individually
7
+ harmless, together they bury meaningful change and break ``git bisect``.
8
+
9
+ ``commithygiene`` does ONE thing and does it read-only: it walks a range of git
10
+ history, scores every commit against a set of heuristics, and reports the ones
11
+ that should have been squashed before they hit the branch. It never rewrites
12
+ history, never stages files, never touches your reflog — it only reads.
13
+
14
+ Exit-code contract (CI friendly):
15
+ EXIT_OK = 0 no noise, or only warnings (without --strict)
16
+ EXIT_DRIFT = 1 noise found (always, or warnings promoted via --strict)
17
+ EXIT_ERROR = 2 usage / I/O / unexpected error
18
+
19
+ Only the Python standard library is used. Requires Python 3.10+.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import json
26
+ import os
27
+ import re
28
+ import subprocess
29
+ import sys
30
+ from dataclasses import dataclass, field, asdict
31
+ from typing import Iterable, Optional
32
+
33
+ __version__ = "0.1.0"
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Exit codes
37
+ # ---------------------------------------------------------------------------
38
+ EXIT_OK = 0
39
+ EXIT_DRIFT = 1
40
+ EXIT_ERROR = 2
41
+
42
+
43
+ class CliError(Exception):
44
+ """Raised for expected, user-facing failures (bad args, bad repo, ...)."""
45
+
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Heuristics
49
+ # ---------------------------------------------------------------------------
50
+ # Commits whose subject is pure noise. These are almost never meaningful
51
+ # checkpoints and should always be flagged. The lists are deliberately small and
52
+ # conservative: a false positive here is worse than a missed one.
53
+ NOISE_PATTERNS: tuple[re.Pattern, ...] = tuple(
54
+ re.compile(p, re.IGNORECASE)
55
+ for p in (
56
+ r"^wip\b",
57
+ r"^work in progress\b",
58
+ r"^tmp\b",
59
+ r"^temp\b",
60
+ r"^fixup!",
61
+ r"^squash!",
62
+ r"^tweak\b",
63
+ r"^tweak(s|ing)?\b",
64
+ r"^minor\b",
65
+ r"^oops\b",
66
+ r"^cleanup\b",
67
+ r"^misc\b",
68
+ r"^save point\b",
69
+ r"^checkpoint\b",
70
+ r"^.\?{1,3}$", # "?", "??", "???" etc.
71
+ r"^test commit\b",
72
+ r"^initial commit$",
73
+ r"^\d+$", # bare numbers like "123"
74
+ )
75
+ )
76
+
77
+ # A subject containing these tokens is a strong signal of churn: an edit to an
78
+ # edit, a revert, or a "real fix now" that implies a previous attempt was wrong.
79
+ CHURN_TOKENS: tuple[str, ...] = (
80
+ "again",
81
+ "revert",
82
+ "actually",
83
+ "now it works",
84
+ "for real",
85
+ "final",
86
+ "final version",
87
+ "hopefully",
88
+ "try",
89
+ "attempt",
90
+ "redo",
91
+ "another",
92
+ )
93
+
94
+ # Files whose changes rarely constitute a meaningful checkpoint on their own.
95
+ # A commit touching ONLY these is usually noise.
96
+ TRIVIAL_ONLY_PATHS: tuple[str, ...] = (
97
+ ".gitignore",
98
+ ".editorconfig",
99
+ "README.md",
100
+ "CHANGELOG.md",
101
+ "LICENSE",
102
+ "package-lock.json",
103
+ "yarn.lock",
104
+ "pnpm-lock.yaml",
105
+ "poetry.lock",
106
+ "Pipfile.lock",
107
+ )
108
+
109
+
110
+ @dataclass
111
+ class Finding:
112
+ """A single hygiene violation. ``severity`` is "error" or "warning"."""
113
+
114
+ severity: str
115
+ code: str
116
+ detail: str
117
+ commit: str
118
+ subject: str = ""
119
+
120
+ def to_dict(self) -> dict:
121
+ return {
122
+ "severity": self.severity,
123
+ "code": self.code,
124
+ "detail": self.detail,
125
+ "commit": self.commit,
126
+ "subject": self.subject,
127
+ }
128
+
129
+
130
+ @dataclass
131
+ class CommitInfo:
132
+ """What we know about a single commit, assembled from ``git show`` output."""
133
+
134
+ hash: str
135
+ subject: str
136
+ files_changed: int = 0
137
+ insertions: int = 0
138
+ deletions: int = 0
139
+ paths: list[str] = field(default_factory=list)
140
+
141
+
142
+ # ---------------------------------------------------------------------------
143
+ # Git plumbing
144
+ # ---------------------------------------------------------------------------
145
+ def _run(cmd: list[str]) -> str:
146
+ """Run a git command and return its trimmed stdout. Raises CliError."""
147
+ try:
148
+ proc = subprocess.run(
149
+ cmd,
150
+ capture_output=True,
151
+ text=True,
152
+ encoding="utf-8",
153
+ errors="replace",
154
+ )
155
+ except FileNotFoundError:
156
+ raise CliError("git executable not found on PATH")
157
+ if proc.returncode != 0:
158
+ err = (proc.stderr or "").strip()
159
+ raise CliError(err or f"git command failed: {' '.join(cmd)}")
160
+ return (proc.stdout or "").strip()
161
+
162
+
163
+ def is_git_repo() -> bool:
164
+ try:
165
+ _run(["git", "rev-parse", "--is-inside-work-tree"])
166
+ return True
167
+ except CliError:
168
+ return False
169
+
170
+
171
+ def list_commits(rng: Optional[str]) -> list[str]:
172
+ """Return commit hashes (oldest first) in the requested range."""
173
+ cmd = ["git", "log", "--format=%H"]
174
+ if rng:
175
+ cmd.append(rng)
176
+ try:
177
+ out = _run(cmd)
178
+ except CliError:
179
+ # An empty repository (no commits yet) makes `git log` fail; treat that
180
+ # as "nothing to scan" rather than leaking the raw git error.
181
+ return []
182
+ if not out:
183
+ return []
184
+ # oldest-first ordering so "again/revert" context reads naturally
185
+ return list(reversed(out.splitlines()))
186
+
187
+
188
+ def show_commit(h: str) -> CommitInfo:
189
+ """Assemble a CommitInfo for one hash using a single ``git show`` call."""
190
+ # numstat gives us per-file add/del without loading the full patch.
191
+ numstat = _run(
192
+ ["git", "show", "--numstat", "--format=%s", "--no-color", h]
193
+ ).splitlines()
194
+
195
+ subject = ""
196
+ if numstat:
197
+ subject = numstat[0].strip()
198
+
199
+ info = CommitInfo(hash=h, subject=subject)
200
+ files = 0
201
+ ins = 0
202
+ dele = 0
203
+ paths: list[str] = []
204
+ for line in numstat[1:]:
205
+ line = line.strip()
206
+ if not line:
207
+ continue
208
+ parts = line.split("\t")
209
+ # valid numstat rows look like: "<add>\t<del>\t<path>"
210
+ if len(parts) < 3:
211
+ continue
212
+ add_s, del_s, path = parts[0], parts[1], parts[2]
213
+ # binary files are reported as "-"
214
+ a = 0 if add_s == "-" else int(add_s)
215
+ d = 0 if del_s == "-" else int(del_s)
216
+ files += 1
217
+ ins += a
218
+ dele += d
219
+ if path:
220
+ paths.append(path)
221
+
222
+ info.files_changed = files
223
+ info.insertions = ins
224
+ info.deletions = dele
225
+ info.paths = paths
226
+ return info
227
+
228
+
229
+ # ---------------------------------------------------------------------------
230
+ # Rule engine
231
+ # ---------------------------------------------------------------------------
232
+ def _is_noise(subject: str) -> bool:
233
+ return any(p.search(subject) for p in NOISE_PATTERNS)
234
+
235
+
236
+ def _has_churn(subject: str) -> bool:
237
+ low = subject.lower()
238
+ return any(tok in low for tok in CHURN_TOKENS)
239
+
240
+
241
+ def _only_trivial_paths(paths: list[str]) -> bool:
242
+ if not paths:
243
+ return False
244
+ return all(
245
+ p in TRIVIAL_ONLY_PATHS
246
+ or p.endswith(".lock")
247
+ for p in paths
248
+ )
249
+
250
+
251
+ def analyze_commit(c: CommitInfo, strict: bool = False) -> list[Finding]:
252
+ """Score a single commit and return its findings (possibly empty)."""
253
+ findings: list[Finding] = []
254
+
255
+ if _is_noise(c.subject):
256
+ findings.append(
257
+ Finding(
258
+ severity="error",
259
+ code="noise-subject",
260
+ detail=f"noise commit: '{c.subject}'",
261
+ commit=c.hash,
262
+ subject=c.subject,
263
+ )
264
+ )
265
+
266
+ # "revert" + "again"/"actually" style churn is a warning (could be legit).
267
+ if _has_churn(c.subject):
268
+ findings.append(
269
+ Finding(
270
+ severity="warning",
271
+ code="churn-subject",
272
+ detail=f"possible churn commit: '{c.subject}'",
273
+ commit=c.hash,
274
+ subject=c.subject,
275
+ )
276
+ )
277
+
278
+ # A tiny diff touching only lockfiles/docs is rarely a checkpoint.
279
+ if (
280
+ c.files_changed == 1
281
+ and c.insertions + c.deletions <= 10
282
+ and _only_trivial_paths(c.paths)
283
+ ):
284
+ findings.append(
285
+ Finding(
286
+ severity="warning",
287
+ code="trivial-only",
288
+ detail=(
289
+ "tiny diff touching only trivial files "
290
+ f"({', '.join(c.paths[:2])})"
291
+ ),
292
+ commit=c.hash,
293
+ subject=c.subject,
294
+ )
295
+ )
296
+
297
+ if strict:
298
+ # Under --strict, promote warnings to errors so CI can hard-fail.
299
+ for f in findings:
300
+ if f.severity == "warning":
301
+ f.severity = "error"
302
+
303
+ return findings
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Commands
308
+ # ---------------------------------------------------------------------------
309
+ def cmd_check(args: argparse.Namespace) -> int:
310
+ if not is_git_repo():
311
+ raise CliError("not a git repository (or git not on PATH)")
312
+
313
+ hashes = list_commits(args.range)
314
+ if not hashes:
315
+ raise CliError(f"no commits found in range: {args.range or 'HEAD'}")
316
+
317
+ all_findings: list[Finding] = []
318
+ for h in hashes:
319
+ info = show_commit(h)
320
+ all_findings.extend(analyze_commit(info, strict=args.strict))
321
+
322
+ _render(all_findings, args.format, args.strict, len(hashes))
323
+ return _exit_code(all_findings, args.strict)
324
+
325
+
326
+ def cmd_report(args: argparse.Namespace) -> int:
327
+ """A project-wide health summary: noise ratio, churn, hot files."""
328
+ if not is_git_repo():
329
+ raise CliError("not a git repository (or git not on PATH)")
330
+
331
+ hashes = list_commits(args.range)
332
+ if not hashes:
333
+ raise CliError(f"no commits found in range: {args.range or 'HEAD'}")
334
+
335
+ commits = [show_commit(h) for h in hashes]
336
+ noisy = sum(1 for c in commits if _is_noise(c.subject))
337
+ churny = sum(1 for c in commits if _has_churn(c.subject))
338
+
339
+ report = {
340
+ "commits": len(commits),
341
+ "noise_commits": noisy,
342
+ "noise_ratio": round(noisy / len(commits), 3) if commits else 0.0,
343
+ "churn_commits": churny,
344
+ "churn_ratio": round(churny / len(commits), 3) if commits else 0.0,
345
+ }
346
+
347
+ if args.format == "json":
348
+ print(json.dumps(report, indent=2))
349
+ else:
350
+ _print(f"\n commit hygiene report — {args.range or 'HEAD'}\n")
351
+ _print(f" commits : {report['commits']}")
352
+ _print(f" noise commits : {report['noise_commits']} "
353
+ f"({report['noise_ratio']:.0%})")
354
+ _print(f" churn commits : {report['churn_commits']} "
355
+ f"({report['churn_ratio']:.0%})")
356
+ verdict = (
357
+ "clean" if report["noise_ratio"] < 0.05
358
+ else "noisy" if report["noise_ratio"] < 0.2
359
+ else "very noisy"
360
+ )
361
+ _print(f" verdict : {verdict}\n")
362
+
363
+ return EXIT_OK
364
+
365
+
366
+ # ---------------------------------------------------------------------------
367
+ # Rendering
368
+ # ---------------------------------------------------------------------------
369
+ _USE_COLOR = False
370
+
371
+
372
+ def _print(msg: str = "") -> None:
373
+ try:
374
+ sys.stdout.reconfigure(encoding="utf-8") # py3.7+; Windows safety
375
+ except Exception:
376
+ pass
377
+ print(msg)
378
+
379
+
380
+ def _color(code: str, s: str) -> str:
381
+ if not _USE_COLOR:
382
+ return s
383
+ return f"\033[{code}m{s}\033[0m"
384
+
385
+
386
+ def _render(
387
+ findings: list[Finding],
388
+ fmt: str,
389
+ strict: bool,
390
+ total: int,
391
+ ) -> None:
392
+ if fmt == "json":
393
+ payload = {
394
+ "commits_scanned": total,
395
+ "findings": [f.to_dict() for f in findings],
396
+ }
397
+ _print(json.dumps(payload, indent=2))
398
+ return
399
+
400
+ _print(f"\n commithygiene — scanned {total} commit(s)\n")
401
+
402
+ if not findings:
403
+ _print(_color("32", " ✓ clean — no hygiene issues found\n"))
404
+ return
405
+
406
+ for f in findings:
407
+ mark = _color("31", "✗") if f.severity == "error" else _color("33", "!")
408
+ code = f"{f.code:<16}"
409
+ _print(f" {mark} {code} {f.commit[:10]} {f.detail}")
410
+ _print("")
411
+
412
+
413
+ def _exit_code(findings: list[Finding], strict: bool) -> int:
414
+ has_error = any(f.severity == "error" for f in findings)
415
+ has_warning = any(f.severity == "warning" for f in findings)
416
+
417
+ if has_error:
418
+ return EXIT_DRIFT
419
+ if has_warning and strict:
420
+ return EXIT_DRIFT
421
+ return EXIT_OK
422
+
423
+
424
+ # ---------------------------------------------------------------------------
425
+ # CLI
426
+ # ---------------------------------------------------------------------------
427
+ def build_parser() -> argparse.ArgumentParser:
428
+ p = argparse.ArgumentParser(
429
+ prog="commithygiene",
430
+ description=(
431
+ "Read-only linter for noisy / AI-generated git commit history. "
432
+ "Finds wip, churn, and trivial commits before they hit your branch."
433
+ ),
434
+ )
435
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
436
+
437
+ # Shared flags for both subcommands. argparse only allows parent-level
438
+ # options BEFORE the subcommand, so real CLI UX needs them on each sub.
439
+ common = argparse.ArgumentParser(add_help=False)
440
+ common.add_argument("--no-color", action="store_true",
441
+ help="disable ANSI colors")
442
+
443
+ sub = p.add_subparsers(dest="command", required=True)
444
+
445
+ c_check = sub.add_parser("check", parents=[common],
446
+ help="find noisy commits in a range")
447
+ c_check.add_argument("range", nargs="?", default=None,
448
+ help="git range (default: HEAD)")
449
+ c_check.add_argument("--strict", action="store_true",
450
+ help="treat warnings as errors (exit 1)")
451
+ c_check.add_argument("--format", choices=["table", "json"],
452
+ default="table", help="output format")
453
+ c_check.set_defaults(func=cmd_check)
454
+
455
+ c_report = sub.add_parser("report", parents=[common],
456
+ help="project-wide hygiene summary")
457
+ c_report.add_argument("range", nargs="?", default=None,
458
+ help="git range (default: HEAD)")
459
+ c_report.add_argument("--format", choices=["table", "json"],
460
+ default="table", help="output format")
461
+ c_report.set_defaults(func=cmd_report)
462
+
463
+ return p
464
+
465
+
466
+ def main(argv: Optional[list[str]] = None) -> int:
467
+ global _USE_COLOR
468
+
469
+ parser = build_parser()
470
+ args = parser.parse_args(argv)
471
+
472
+ # Color only on a real TTY, never when asked not to, and honor NO_COLOR.
473
+ _USE_COLOR = (
474
+ hasattr(sys.stdout, "isatty")
475
+ and sys.stdout.isatty()
476
+ and not getattr(args, "no_color", False)
477
+ and "NO_COLOR" not in os.environ
478
+ )
479
+
480
+ try:
481
+ return args.func(args)
482
+ except CliError as e:
483
+ sys.stderr.write(f"error: {e}\n")
484
+ return EXIT_ERROR
485
+ except BrokenPipeError:
486
+ return EXIT_ERROR
487
+ except KeyboardInterrupt:
488
+ return EXIT_ERROR
489
+
490
+
491
+ if __name__ == "__main__":
492
+ sys.exit(main())