""" Append-only discussion checker. Validates that staged changes to discussion Markdown files only modify YAML front matter; the body must remain append-only (no deletions or edits). """ from __future__ import annotations import argparse import re import subprocess import sys from pathlib import Path from typing import Tuple DIFF_HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@") def _run_git_show(ref: str, path: Path) -> str | None: """Return file contents for ref:path, or None if unavailable.""" result = subprocess.run( ["git", "show", f"{ref}:{path.as_posix()}"], capture_output=True, text=True, ) if result.returncode != 0: return None return result.stdout def _find_front_matter_end(text: str | None) -> int: """Return 1-based line number of closing --- in YAML front matter.""" if not text: return 0 lines = text.splitlines() if not lines or lines[0].strip() != "---": return 0 for idx, line in enumerate(lines[1:], start=2): if line.strip() == "---": return idx return len(lines) def _get_staged_diff(path: Path) -> str: result = subprocess.run( ["git", "diff", "--cached", "--unified=0", "--", path.as_posix()], capture_output=True, text=True, check=False, ) # git diff exits 0 regardless of diff presence return result.stdout def _is_allowed(diff_text: str, header_end: int) -> Tuple[bool, str]: """ Return (allowed, offending_line) for staged diff. header_end is the line number of the closing front matter delimiter in the previous version (0 if no front matter detected). """ current_old_line = None for line in diff_text.splitlines(): if line.startswith("diff --git") or line.startswith("index "): continue if line.startswith("--- ") or line.startswith("+++ "): continue if line.startswith("\\"): continue if line.startswith("@@"): match = DIFF_HUNK_RE.match(line) if match: current_old_line = int(match.group(1)) else: current_old_line = None continue if current_old_line is None: continue prefix = line[:1] if prefix == "-": # header_end == 0 means no header detected => forbid deletions if header_end == 0 or current_old_line > header_end: return False, line current_old_line += 1 elif prefix == " ": current_old_line += 1 elif prefix == "+": # additions do not advance old line counter pass return True, "" def check_append_only(path: Path) -> Tuple[bool, str]: diff_text = _get_staged_diff(path) if not diff_text.strip(): return True, "" old_content = _run_git_show("HEAD", path) header_end = _find_front_matter_end(old_content) allowed, offending = _is_allowed(diff_text, header_end) return allowed, offending def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser( description="Ensure discussion files are append-only outside YAML header." ) parser.add_argument("path", help="Discussion file path (relative or absolute)") args = parser.parse_args(argv) raw_path = Path(args.path) path = raw_path cwd = Path.cwd().resolve() if raw_path.is_absolute(): try: path = raw_path.resolve().relative_to(cwd) except ValueError: sys.stderr.write( f"[pre-commit] Error: {raw_path} is outside the repository root\n" ) return 1 allowed, offending_line = check_append_only(path) if allowed: return 0 sys.stderr.write( "[pre-commit] Error: Discussion files must be append-only; " f"modification detected outside YAML header:\n {offending_line}\n" ) return 1 if __name__ == "__main__": raise SystemExit(main())