change_manifest.py 5.97 KB
"""Workspace snapshotting and change-manifest/diff generation for isolated
runs (Coder/Validator nodes, or a Supervisor candidate-instance checkout).

Adapted verbatim from graphloop's change_manifest.py (2026-07-30) -- no
functional changes. See test_change_manifest.py in graphloop for the
original contract this was validated against; graphlemon should add an
equivalent test module when this is wired into a real Enabler unit.
"""
import difflib
import hashlib
import os
import posixpath
import re

IGNORED_DIRS = {".git", "__pycache__", ".pytest_cache"}
IGNORED_FILES = {".DS_Store"}


class ChangeManifestError(Exception):
    """Raised when build_change_manifest encounters untrusted input:
    a symlink under the isolated workspace, or a workspace-escaping path
    in the source snapshot."""


def _iter_files(root, reject_symlinks):
    for dirpath, dirnames, filenames in os.walk(root, followlinks=False):
        keep_dirs = []
        for name in dirnames:
            if name in IGNORED_DIRS:
                continue
            full = os.path.join(dirpath, name)
            if os.path.islink(full):
                if reject_symlinks:
                    raise ChangeManifestError(f"symlink encountered: {full}")
                continue
            keep_dirs.append(name)
        dirnames[:] = keep_dirs

        for name in filenames:
            if name in IGNORED_FILES:
                continue
            full = os.path.join(dirpath, name)
            if os.path.islink(full):
                if reject_symlinks:
                    raise ChangeManifestError(f"symlink encountered: {full}")
                continue
            if not os.path.isfile(full):
                continue
            rel = os.path.relpath(full, root).replace(os.sep, "/")
            yield rel, full


def _analyze_file(full):
    with open(full, "rb") as handle:
        data = handle.read()
    sha256 = hashlib.sha256(data).hexdigest()
    if b"\x00" in data:
        kind = "binary"
    else:
        try:
            data.decode("utf-8")
            kind = "text"
        except UnicodeDecodeError:
            kind = "binary"
    return sha256, len(data), kind


def _snapshot_files(root, reject_symlinks):
    files = {}
    for rel, full in _iter_files(root, reject_symlinks):
        sha256, size, kind = _analyze_file(full)
        files[rel] = {"sha256": sha256, "size": size, "kind": kind}
    return files


def _validate_relative_path(rel):
    if not rel or rel.startswith("/"):
        raise ChangeManifestError(f"unsafe path in snapshot: {rel!r}")
    normalized = posixpath.normpath(rel)
    if normalized == ".." or normalized.startswith("../"):
        raise ChangeManifestError(f"unsafe path in snapshot: {rel!r}")


def _safe_join(root, rel):
    full = os.path.normpath(os.path.join(root, *rel.split("/")))
    if os.path.commonpath([root, full]) != os.path.normpath(root):
        raise ChangeManifestError(f"path escapes root: {rel!r}")
    return full


def snapshot_workspace(path):
    root = os.path.realpath(path)
    return {"root": root, "files": _snapshot_files(root, reject_symlinks=False)}


def build_change_manifest(source_snapshot, isolated_path):
    source_files = source_snapshot["files"]
    for rel in source_files:
        _validate_relative_path(rel)

    isolated_root = os.path.realpath(isolated_path)
    isolated_files = _snapshot_files(isolated_root, reject_symlinks=True)

    changed_files = []
    for rel in source_files.keys() | isolated_files.keys():
        old = source_files.get(rel)
        new = isolated_files.get(rel)
        if old and new:
            if old["sha256"] == new["sha256"]:
                continue
            changed_files.append({
                "path": rel,
                "status": "modified",
                "old_hash": old["sha256"],
                "new_hash": new["sha256"],
                "kind": new["kind"],
            })
        elif new:
            changed_files.append({
                "path": rel,
                "status": "added",
                "old_hash": None,
                "new_hash": new["sha256"],
                "kind": new["kind"],
            })
        else:
            changed_files.append({
                "path": rel,
                "status": "deleted",
                "old_hash": old["sha256"],
                "new_hash": None,
                "kind": old["kind"],
            })

    changed_files.sort(key=lambda entry: entry["path"])
    return {
        "source_root": source_snapshot["root"],
        "isolated_root": isolated_root,
        "changed_files": changed_files,
    }


def _read_text_lines(full):
    with open(full, "rb") as handle:
        data = handle.read()
    return data.decode("utf-8").splitlines(keepends=True)


def unified_diff(manifest):
    changed_files = manifest["changed_files"]
    if not changed_files:
        return ""

    source_root = manifest["source_root"]
    isolated_root = manifest["isolated_root"]
    sections = []

    for entry in changed_files:
        rel = entry["path"]
        status = entry["status"]
        old_label = "/dev/null" if status == "added" else rel
        new_label = "/dev/null" if status == "deleted" else rel

        if entry["kind"] == "binary":
            sections.append(f"Binary files {old_label} and {new_label} differ\n")
            continue

        old_lines = (
            _read_text_lines(_safe_join(source_root, rel))
            if status != "added" else []
        )
        new_lines = (
            _read_text_lines(_safe_join(isolated_root, rel))
            if status != "deleted" else []
        )
        diff_lines = difflib.unified_diff(
            old_lines, new_lines, fromfile=old_label, tofile=new_label
        )
        # Collapse "@@ -l,s +l,s @@" hunk headers to a single leading "@@"
        # marker so callers can count hunks via a plain substring count.
        diff_lines = (
            re.sub(r" @@\n$", "\n", line) if line.startswith("@@") else line
            for line in diff_lines
        )
        sections.append("".join(diff_lines))

    return "".join(sections)