Files
6krrt/scripts/verify_commit.py
adlee-was-taken d6c4eb4f8f fix(scripts): verify_commit.py caps failure detail at 20 lines
Defect o: When tests FAIL, the detail is at most 20 lines total.
- Fixed ERROR prefix: pytest prints 'ERROR ' (not 'ERRORS ')
- Truncation with '... and N more' when summary exceeds 20 lines
- Fallback (last 20 lines) unchanged
2026-10-04 06:06:46 -04:00

552 lines
20 KiB
Python
Executable File

#!/usr/bin/env python3
"""
verify_commit.py — check a commit for validity, cleanliness, and test health.
Design A checks::
commit — the given SHA is a valid, non-empty commit.
clean — working tree is clean (or only untracked).
tests — pytest passes on files touched by the commit.
lint — ruff lint on touched files (optional, off by default).
Tests and lint operate on the committed tree at the given SHA
(``git archive``), not on the working tree.
Output is one line per check in the format ``PASS|FAIL|SKIP <check>: <detail>``.
Total output is at most 40 lines; on FAIL the detail section is at most 20 lines.
Usage::
python3 scripts/verify_commit.py [options] SHA
Flags::
--repo DIR Git repository root (default: current directory).
--base REF Base ref to diff against (default: SHA^).
--full Run the full pytest suite instead of selective.
--require-clean Fail if tracked files are modified.
--no-lint Skip lint checks entirely.
--python PATH Python interpreter (default: sys.executable).
--ruff-cmd PATH Ruff command (default: uvx ruff@0.16.9).
"""
import argparse
import io
import os
import re
import shlex
import subprocess
import sys
import tarfile
import tempfile
# ---------------------------------------------------------------------------
# Argument parsing
# ---------------------------------------------------------------------------
def parse_args(argv: "list[str] | None" = None) -> argparse.Namespace:
"""Parse command-line arguments."""
p = argparse.ArgumentParser(description="Verify a commit")
p.add_argument("--repo", default=".",
help="Git repository root (default: .)")
p.add_argument("--base", default=None,
help="Base ref to diff against (default: SHA^)")
p.add_argument("--full", action="store_true",
help="Run the full pytest suite instead of selective")
p.add_argument("--require-clean", action="store_true",
help="Fail if tracked files are modified")
p.add_argument("--no-lint", action="store_true",
help="Skip lint checks")
p.add_argument("--python", default=sys.executable,
help="Python interpreter (default: sys.executable)")
p.add_argument("--ruff-cmd", default="uvx ruff@0.16.9",
help="Ruff command (default: uvx ruff@0.16.9)")
p.add_argument("sha", help="Commit SHA to verify")
return p.parse_args(argv)
# ---------------------------------------------------------------------------
# Pure function: select_tests
# ---------------------------------------------------------------------------
def select_tests(
changed: "list[str]",
test_files: "list[str]",
read: "callable[[str], str]",
) -> "list[str]":
"""Select test files affected by changed source files.
Rules (applied to each path in *changed*):
1. A changed test file (``tests/test_*.py``) selects itself.
2. A changed source module (``src/<mod>.py``) selects every test file
that references ``import <mod>``, ``from src import <mod>``, or
``from src.<mod> import …``.
The *read* callable receives an absolute or relative path and returns
the file content as a string.
"""
selected: list[str] = []
seen: set[str] = set()
for path in changed:
# --- Direct test file match -------------------------------------------
if path.startswith("tests/") and path.endswith(".py"):
if path not in seen:
selected.append(path)
seen.add(path)
continue
# --- Source module: src/<mod>.py ------------------------------------
if path.startswith("src/") and path.endswith(".py"):
mod = path[4:-3] # "src/foo.py" -> "foo"
if not mod:
continue # guard against "src/.py"
_select_tests_for_module(mod, test_files, read, selected, seen)
return selected
def _select_tests_for_module(
mod: str,
test_files: "list[str]",
read: "callable[[str], str]",
selected: "list[str]",
seen: "set[str]",
) -> None:
"""Append test files that import *mod* to *selected*."""
import_bare = re.compile(rf"\bimport\s+{re.escape(mod)}\b")
from_src = re.compile(rf"\bfrom\s+src\s+import\s+{re.escape(mod)}\b")
from_src_dot = re.compile(rf"\bfrom\s+src\.{re.escape(mod)}\b")
for tf in test_files:
if tf in seen:
continue
try:
content = read(tf)
except OSError:
continue
if import_bare.search(content) or from_src.search(content) or from_src_dot.search(content):
selected.append(tf)
seen.add(tf)
# ---------------------------------------------------------------------------
# Git helpers
# ---------------------------------------------------------------------------
def _git(*args: str, repo: str = ".", capture: bool = True) -> "tuple[int, str, str]":
"""Run a git command and return ``(returncode, stdout, stderr)``."""
cmd: list[str] = ["git", "-C", repo] + list(args)
try:
r = subprocess.run(
cmd,
capture_output=capture,
text=True,
timeout=30,
check=False,
)
return r.returncode, r.stdout or "", r.stderr or ""
except subprocess.TimeoutExpired:
return -1, "", "timeout"
except FileNotFoundError:
return -2, "", "git not found"
# ---------------------------------------------------------------------------
# Archive helpers
# ---------------------------------------------------------------------------
def _extract_tree(repo: str, sha: str) -> "tempfile.TemporaryDirectory | None":
"""Extract the committed tree at *sha* into a temporary directory.
Returns a ``TemporaryDirectory`` (access its ``.name``) or ``None``
on failure. Caller is responsible for cleanup.
"""
try:
r = subprocess.run(
["git", "-C", repo, "archive", "--format=tar", sha],
capture_output=True,
timeout=30,
check=False,
)
except (subprocess.TimeoutExpired, FileNotFoundError):
return None
if r.returncode != 0 or not r.stdout:
return None
tmp = tempfile.TemporaryDirectory(prefix="verify_commit_")
with tarfile.open(fileobj=io.BytesIO(r.stdout), mode="r:") as tar:
tar.extractall(path=tmp.name)
return tmp
# ---------------------------------------------------------------------------
# Per-check functions
# ---------------------------------------------------------------------------
def check_commit(repo: str, sha: str) -> "tuple[str, str]":
"""Return ``(status, detail)`` for the *commit* check, resolving *sha*."""
rc, out, _err = _git("rev-parse", "--verify", f"{sha}^{{commit}}", repo=repo)
if rc != 0:
return "FAIL", f"error: Cannot resolve commit '{sha}'"
resolved = out.strip()
if not resolved:
return "FAIL", f"error: Empty commit at '{sha}'"
return "PASS", resolved[:12]
def check_clean(repo: str, require_clean: bool) -> "tuple[str, str]":
"""Return ``(status, detail)`` for the *clean* check."""
rc, out, _err = _git("status", "--porcelain", repo=repo)
if rc != 0:
return "FAIL", "git status failed"
lines = [l for l in out.splitlines() if l.strip()]
if not lines:
return "PASS", "Clean working tree"
if not require_clean:
n = len(lines)
return "PASS", f"{n} dirty file(s) (--require-clean not set)"
modified = [l for l in lines if not l.startswith("??")]
untracked = [l for l in lines if l.startswith("??")]
if not modified:
return "PASS", f"Only untracked file(s) ({len(untracked)})"
names = [m[3:] for m in modified[:5]]
suffix = f" … and {len(modified) - 5} more" if len(modified) > 5 else ""
return "FAIL", f"Modified tracked file(s): {', '.join(names)}{suffix}"
def check_known_failures() -> "tuple[str, str]":
"""Return ``(status, detail)`` for known-failures registration.
The file may contain only comment lines (``#``) — that is equivalent
to "no known failures" and returns PASS.
"""
script_dir = os.path.dirname(os.path.abspath(__file__))
kf_path = os.path.join(script_dir, "verify_known_failures.txt")
if not os.path.exists(kf_path):
return "SKIP", "verify_known_failures.txt missing"
with open(kf_path, encoding="utf-8") as f:
raw = f.read()
entries = [
l for l in raw.splitlines()
if l.strip() and not l.strip().startswith("#")
]
if not entries:
return "PASS", "No known failures registered"
return "SKIP", f"{len(entries)} known failure(s) registered"
# ---------------------------------------------------------------------------
# Lint helpers
# ---------------------------------------------------------------------------
def _strip_coords(line: str) -> str:
"""Strip line/col coords from a ruff line (e.g. 'file.py:10:5: F401 ...' -> 'file.py: F401 ...')."""
parts = line.split(":", 3)
if len(parts) == 4:
return f"{parts[0]}: {parts[3].strip()}"
return line.strip()
def new_findings(before: list[str], after: list[str]) -> list[str]:
"""Return findings present in 'after' but not in 'before', ignoring line/col shifts."""
before_set = { _strip_coords(l) for l in before if l.strip() }
after_set = { _strip_coords(l) for l in after if l.strip() }
diff = after_set - before_set
return sorted(diff)
# ---------------------------------------------------------------------------
# Lint
# ---------------------------------------------------------------------------
def check_lint(
changed: "list[str]",
ruff_cmd: str,
repo: str,
base: str,
sha: str,
) -> "tuple[str, list[str]]":
"""Return ``(status, detail_lines)`` for lint.
Lints only touched Python files. Reports FAIL if new findings are introduced
compared to the base ref, ignoring line/column shifts. Both baseline and
current findings are read from the committed tree via ``git show``.
"""
if not changed:
return "PASS", ["Nothing to lint"]
py_files = [f for f in changed if f.endswith(".py")]
if not py_files:
return "PASS", ["No Python files to lint"]
all_new_findings: list[str] = []
# Finding pattern: <path>:<line>:<col>: <CODE> <message>
# Example: src/foo.py:10:5: F401 `os` imported but unused
finding_pattern = re.compile(r"^[^:\n]+:\d+:\d+: [A-Z\d]+ .+$")
def filter_findings(lines: list[str]) -> list[str]:
return [l for l in lines if l.strip() and finding_pattern.match(l.strip())]
for path in py_files:
# 1. Baseline findings: git show <base>:<path> | ruff check --stdin-filename <path> -
before_findings = []
try:
rc_base, out_base, _err_base = _git("show", f"{base}:{path}", repo=repo)
if rc_base == 0 and out_base:
lint_cmd = shlex.split(ruff_cmd) + ["check", "--output-format=concise", "--stdin-filename", path, "-"]
r_before = subprocess.run(
lint_cmd,
input=out_base,
capture_output=True,
text=True,
timeout=15,
check=False
)
if r_before.returncode == 127:
return "SKIP", ["ruff command not found (127), skipping lint"]
before_findings = filter_findings((r_before.stdout or "").splitlines())
except (FileNotFoundError, subprocess.TimeoutExpired):
pass
# 2. Current findings: git show <sha>:<path> | ruff check --stdin-filename <path> -
rc_curr, out_curr, _err_curr = _git("show", f"{sha}:{path}", repo=repo)
if rc_curr != 0 or not out_curr:
# File not present at SHA (e.g. deleted); no current findings
current_findings: list[str] = []
else:
lint_cmd = shlex.split(ruff_cmd) + ["check", "--output-format=concise", "--stdin-filename", path, "-"]
try:
r_curr = subprocess.run(
lint_cmd,
input=out_curr,
capture_output=True,
text=True,
timeout=15,
check=False
)
if r_curr.returncode == 127:
return "SKIP", ["ruff command not found (127), skipping lint"]
current_findings = filter_findings((r_curr.stdout or "").splitlines())
except FileNotFoundError:
return "SKIP", ["ruff command not found, skipping lint"]
except subprocess.TimeoutExpired:
return "SKIP", ["lint timed out, skipping"]
# 3. Compare
new = new_findings(before_findings, current_findings)
if new:
all_new_findings.extend([f"New in {path}: {f}" for f in new])
if not all_new_findings:
return "PASS", ["No new lint findings"]
return "FAIL", all_new_findings
# ---------------------------------------------------------------------------
# Test helpers
# ---------------------------------------------------------------------------
def get_changed_files(repo: str, base: str, sha: str) -> "list[str]":
"""Return list of file paths changed between *base* and *sha*.
Falls back to ``ls-tree`` when *base* does not exist -- useful for a
repo with a single (root) commit.
"""
rc, out, _err = _git("diff", "--name-only", base, sha, repo=repo)
if rc == 0 and out.strip():
return [l for l in out.splitlines() if l.strip()]
# base ref may not exist (root commit); list all tracked files at sha
rc, out, _err = _git("ls-tree", "-r", "--name-only", sha, repo=repo)
if rc == 0 and out.strip():
return [l for l in out.splitlines() if l.strip()]
return []
def get_test_files(repo: str, sha: str) -> "list[str]":
"""Return list of tracked test file paths at *sha*."""
rc, out, _err = _git("ls-tree", "-r", "--name-only", sha, "tests/", repo=repo)
if rc != 0:
return []
return [l for l in out.splitlines() if l.strip() and l.endswith(".py")]
def _make_file_reader(repo: str) -> "callable[[str], str]":
"""Return a *read* callable suitable for ``select_tests``."""
def _read(path: str) -> str:
full = os.path.join(repo, path)
with open(full, encoding="utf-8") as f:
return f.read()
return _read
def run_tests_for_sha(
repo: str,
sha: str,
base: str,
python: str,
full: bool,
) -> "tuple[str, list[str]]":
"""Run pytest on the committed tree at *sha*.
Extracts the tree to a temporary directory via ``git archive``, selects
tests based on changed files between *base* and *sha*, and runs pytest
inside the extracted tree.
"""
tmp_obj = _extract_tree(repo, sha)
if tmp_obj is None:
return "SKIP", ["Could not extract archive for", sha[:12]]
tmp = tmp_obj.name
changed = get_changed_files(repo, base, sha)
all_tests = get_test_files(repo, sha)
if not all_tests:
return "SKIP", ["No tracked test files"]
if full:
selected = all_tests
else:
read = _make_file_reader(tmp)
selected = select_tests(changed, all_tests, read)
if not selected:
return "SKIP", ["No tests selected by changed files"]
return _run_pytest(selected, tmp, python)
def _run_pytest(
test_files: "list[str]",
repo: str,
python: str,
) -> "tuple[str, list[str]]":
"""Run pytest on *test_files* and return ``(status, detail_lines)``."""
test_paths = [os.path.join(repo, t) for t in test_files]
cmd: list[str] = [
python, "-m", "pytest",
"-q", "--tb=short", "--no-header",
] + test_paths
env = dict(os.environ)
existing = env.get("PYTHONPATH", "")
env["PYTHONPATH"] = (
f"{repo}:{os.path.join(repo, 'src')}"
f"{':' + existing if existing else ''}"
)
try:
r = subprocess.run(cmd, capture_output=True, text=True, timeout=180,
env=env, check=False)
except subprocess.TimeoutExpired:
return "FAIL", ["Tests timed out (180s)"]
output = (r.stdout or "") + (r.stderr or "")
lines = [l for l in output.splitlines() if l.strip()]
if r.returncode == 0:
summary = lines[-1] if lines else "All tests passed"
return "PASS", [summary]
summary_lines = [l for l in lines if l.startswith(("FAILED ", "ERROR "))]
count_line = next((l for l in reversed(lines) if "failed" in l.lower() and "passed" in l.lower()), "")
if count_line and count_line not in summary_lines:
summary_lines.append(count_line)
if not summary_lines:
return "FAIL", lines[-20:]
# Cap at 20 total lines.
if len(summary_lines) > 20:
if count_line and count_line == summary_lines[-1]:
n_truncated = len(summary_lines) - 20
summary_lines = summary_lines[:18] + [f"... and {n_truncated} more"]
else:
n_truncated = len(summary_lines) - 19
summary_lines = summary_lines[:19] + [f"... and {n_truncated} more"]
return "FAIL", summary_lines
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main(argv: "list[str] | None" = None) -> int:
"""Entry point. Returns 0 on success, 1 on any FAIL, 2 on usage error."""
args = parse_args(argv)
repo = os.path.abspath(args.repo)
sha = args.sha
# Resolve SHA — the *commit* check handles this, and we use it early
# to fail fast on unresolvable SHAs (exit 2, usage error).
rc, out, _err = _git("rev-parse", "--verify", f"{sha}^{{commit}}", repo=repo)
if rc != 0:
detail = _err[:120].strip() if _err.strip() else "unknown revision"
print(f"FAIL commit: error: Cannot resolve commit '{sha}': {detail}")
parser = argparse.ArgumentParser(description="Verify a commit")
parser.print_usage()
return 2
resolved = out.strip()
if not resolved:
print(f"FAIL commit: error: Empty commit at '{sha}'")
argparse.ArgumentParser(description="Verify a commit").print_usage()
return 2
base = args.base if args.base is not None else f"{resolved}^"
results: list[tuple[str, str, str | list[str]]] = []
# 1. known_failures ----------------------------------------------------
status, detail = check_known_failures()
results.append(("known_failures", status, detail))
# 2. commit -----------------------------------------------------------
status, detail = check_commit(repo, sha)
results.append(("commit", status, detail))
# 3. clean -------------------------------------------------------------
status, detail = check_clean(repo, args.require_clean)
results.append(("clean", status, detail))
# 4. lint (unless --no-lint) -------------------------------------------
if not args.no_lint:
changed = get_changed_files(repo, base, resolved)
status, detail_lines = check_lint(changed, args.ruff_cmd, repo, base, resolved)
results.append(("lint", status, detail_lines))
# 5. tests -------------------------------------------------------------
status, detail_lines = run_tests_for_sha(repo, resolved, base, args.python, args.full)
results.append(("tests", status, detail_lines))
# ── Render output ─────────────────────────────────────────────────────
lines_out: list[str] = []
for check, status, detail in results:
if isinstance(detail, list):
if len(detail) <= 1:
d = detail[0] if detail else ""
lines_out.append(f"{status} {check}: {d}")
else:
lines_out.append(f"{status} {check}:")
for d in detail[:20]:
lines_out.append(f" {d}")
else:
lines_out.append(f"{status} {check}: {detail}")
# Enforce 40-line output budget
for line in lines_out[:40]:
print(line)
any_fail = any(s == "FAIL" for _, s, _ in results)
return 1 if any_fail else 0
if __name__ == "__main__":
sys.exit(main())