CascadingDev/automation/append_only.py

138 lines
4.0 KiB
Python

"""
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())