#!/usr/bin/env python3 """Generate a release diff manifest between two Nexus source snapshots.""" from __future__ import annotations import argparse import hashlib from pathlib import Path DEFAULT_EXCLUDE_NAMES = { ".git", "node_modules", ".venv-py314", ".venv-py312-codex", "venv", "__pycache__", ".ruff_cache", ".playwright-mcp", ".cursor", ".pytest_cache", "tmp", } DEFAULT_EXCLUDE_SUFFIXES = {".pyc", ".pyo"} DEFAULT_EXCLUDE_EXACT = {"2025.2", "=2025.2"} DEFAULT_EXCLUDE_PATTERNS = (".bak-",) def sha256_file(path: Path) -> str: h = hashlib.sha256() with path.open("rb") as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b""): h.update(chunk) return h.hexdigest() def snapshot_files(base: Path) -> dict[str, str]: result: dict[str, str] = {} for path in base.rglob("*"): if not path.is_file(): continue rel = path.relative_to(base).as_posix() parts = set(Path(rel).parts) if parts & DEFAULT_EXCLUDE_NAMES: continue if path.suffix in DEFAULT_EXCLUDE_SUFFIXES: continue if rel in DEFAULT_EXCLUDE_EXACT: continue if any(rel.endswith(pattern) for pattern in DEFAULT_EXCLUDE_PATTERNS): continue result[rel] = sha256_file(path) return result def main() -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--old", required=True, type=Path) parser.add_argument("--new", required=True, type=Path) parser.add_argument("--output", required=True, type=Path) args = parser.parse_args() old = args.old.resolve() new = args.new.resolve() old_files = snapshot_files(old) new_files = snapshot_files(new) added = sorted(set(new_files) - set(old_files)) deleted = sorted(set(old_files) - set(new_files)) modified = sorted(k for k in set(old_files) & set(new_files) if old_files[k] != new_files[k]) lines: list[str] = [ "# Nexus release diff manifest (2026-07-08)", "", "Baseline:", "", f"- Old snapshot: `{old}`", f"- New snapshot: `{new}`", "", "Excluded names/patterns: `.git`, `node_modules`, `.venv-*`, `venv`, `__pycache__`, `.ruff_cache`, `.playwright-mcp`, `.cursor`, `.pytest_cache`, `tmp`, `*.pyc`, `*.pyo`, `*.bak-`, `2025.2`, `=2025.2`.", "", "## Summary", "", f"- Added files: {len(added)}", f"- Modified files: {len(modified)}", f"- Deleted files: {len(deleted)}", "", ] for title, items in ( ("Added files", added), ("Modified files", modified), ("Deleted files", deleted), ): lines.extend([f"## {title}", ""]) if items: lines.extend(f"- `{item}`" for item in items) else: lines.append("- None") lines.append("") args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text("\n".join(lines), encoding="utf-8") print(args.output) print(f"added={len(added)} modified={len(modified)} deleted={len(deleted)}") return 0 if __name__ == "__main__": raise SystemExit(main())