# scripts/lib/source_state.py """Read/write STATE.json (committed source-snapshot state) and compute content fingerprints.""" from __future__ import annotations import hashlib import json from pathlib import Path from typing import Optional from lib.paths import repo_root SCHEMA_VERSION = 1 def _default_state_path() -> Path: return repo_root() / "STATE.json" _STATE_PATH_FN = _default_state_path def write(data: dict) -> None: """Write STATE.json (sorted keys, 2-space indent — diff-friendly).""" data = {**data, "schema_version": SCHEMA_VERSION} _STATE_PATH_FN().write_text(json.dumps(data, indent=2, sort_keys=True), encoding="utf-8") def read() -> Optional[dict]: path = _STATE_PATH_FN() if not path.is_file(): return None return json.loads(path.read_text(encoding="utf-8")) def compute_directory_fingerprint(directory: Path) -> str: """sha256 over (relpath, content) tuples for every file in the tree, sorted. relpath uses POSIX separators so fingerprints stay stable across Windows/POSIX. Empty directories ignored — matches git's content-addressable semantics. """ if not directory.is_dir(): raise FileNotFoundError( f"compute_directory_fingerprint: not a directory: {directory}" ) h = hashlib.sha256() for fp in sorted(directory.rglob("*"), key=lambda p: p.as_posix()): # Empty directories ignored — matches git's content-addressable semantics. if not fp.is_file(): continue rel = fp.relative_to(directory).as_posix() h.update(rel.encode("utf-8")) h.update(b"\x00") h.update(fp.read_bytes()) h.update(b"\x00") return f"sha256:{h.hexdigest()}"