asa / scripts /lib /source_state.py
defenestrate's picture
ASA Space: squashed history (instructions and settings live in the private pack)
526950e
Raw History Blame Contribute Delete
1.74 kB
# 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()}"