111 lines
3.2 KiB
Python
111 lines
3.2 KiB
Python
|
|
#!/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())
|