Files

815 lines
31 KiB
Python

"""Generate and apply a pack's distribution pair: `<Pack>.json` + `<Pack>.diff`.
A converted pack is not deployed as a folder. Its checked-in review artifact
is the DIFFERENCE between the pristine pack and its `v2/` tree, in two files
that together are sufficient to re-create `v2/` byte-for-byte on top of any
copy of the original. Deployment wraps exactly those two files in one zip.
<Pack>.json the manifest — for every file of the v2 tree, what to do and
how to prove it was done right:
copy v2 file is byte-identical to the pack's own file
convert v2 file derives from it: SEED with the pack's
bytes, then apply this file's hunks from the .diff
add v2 file has no counterpart: its hunks in the .diff
are a creation (a/dev/null)
delete a pack file deliberately absent from v2/
Every entry carries sha256 of what it expects and of what it
must produce, so application is verified, not hoped.
`v2/` is a COMPLETE pack, so a node module sits beside every
sibling it imports and `__file__`-relative resources resolve
exactly as they did upstream. The duplication costs nothing in
the artifact: `copy` is one manifest line, not a file body.
<Pack>.diff ordinary unified diff, human-readable — the REVIEW artifact.
Because `convert` seeds from the original, its hunks are the
conversion and nothing else; a reviewer reads the boundary
changes, not two interleaved copies of the pack.
Why a pair instead of one big diff: a plain tree diff must carry every
unchanged file as a full addition (fonts, vendored JS, untouched sources),
which buries the conversion and bloats the artifact by the size of the pack.
A binary file may be `copy` or `delete` only. A binary that genuinely changes
in conversion has no reviewable diff, which is a smell worth refusing until a
real case argues otherwise.
"""
from __future__ import annotations
import difflib
import hashlib
import io
import json
import re
import shutil
import stat
import tempfile
import zipfile
from pathlib import Path, PurePosixPath, PureWindowsPath
FORMAT = "comfy-pack-patch/1"
OPS = {"copy", "convert", "add", "delete"}
EMPTY_SHA256 = hashlib.sha256(b"").hexdigest()
SHA256_RE = re.compile(r"[0-9a-f]{64}")
HUNK_RE = re.compile(r"^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(?: .*)?$")
MAX_BUNDLE_BYTES = 96 * 1024 * 1024
MAX_BUNDLE_MEMBER_BYTES = 64 * 1024 * 1024
#: Kept out of both trees entirely: derived or environment litter, never pack
#: content. `.git` appears when a pack folder is a checkout; caches when
#: anything imported it in place.
IGNORE_DIRS = {".git", "__pycache__", ".pytest_cache", "node_modules"}
IGNORE_FILES = {".DS_Store"}
class PackPatchError(Exception):
pass
def _files(
root: Path,
*,
installed_manifest_paths: set[str] | None = None,
) -> dict[str, Path]:
out: dict[str, Path] = {}
for p in sorted(root.rglob("*")):
rel = p.relative_to(root)
rel_text = rel.as_posix()
if (
installed_manifest_paths is not None
and rel_text not in installed_manifest_paths
):
continue
if p.is_symlink():
raise PackPatchError(f"symbolic link is not pack-patch content: {p}")
if not p.is_file():
continue
if rel.parts[0] == "v2" and root.name != "v2":
continue
if any(part in IGNORE_DIRS for part in rel.parts):
continue
if rel.name in IGNORE_FILES:
continue
out[rel_text] = p
return out
def _sha(p: Path) -> str:
return hashlib.sha256(p.read_bytes()).hexdigest()
def _is_text(p: Path) -> bool:
data = p.read_bytes()
if b"\0" in data[:8192]:
return False
try:
data.decode("utf-8")
except UnicodeDecodeError:
return False
return True
def _lines(p: Path) -> list[str]:
try:
return p.read_bytes().decode("utf-8").splitlines(keepends=True)
except UnicodeDecodeError as exc:
raise PackPatchError(f"text file is not UTF-8: {p}") from exc
def _mode(p: Path) -> int:
return stat.S_IMODE(p.stat().st_mode) & 0o777
def _unified(a_lines, b_lines, a_label: str, b_label: str) -> str:
"""difflib's unified diff, with git's no-newline convention restored.
difflib yields a file's final line verbatim — WITHOUT a newline if the
file has none — and never emits git's ``\\ No newline at end of file``
marker. Concatenating such parts glues the next file's ``---`` header
onto the unterminated line. Every unterminated line therefore gets a
newline plus the marker, which the applier strips back out, keeping the
round trip byte-exact for files that do not end in a newline.
"""
out = []
for line in difflib.unified_diff(
a_lines, b_lines, fromfile=a_label, tofile=b_label, n=3
):
out.append(line)
if not line.endswith("\n"):
out.append("\n\\ No newline at end of file\n")
return "".join(out)
def pack_key(pack: Path) -> str:
"""The pack's identity: ``x`` + short git commit sha of the snapshot.
In the db layout the key IS the snapshot folder's name —
``<Pack-Name>/<xsha>/`` — so identity is read off the path and the tree
itself stays exactly upstream's bytes. ``packs.json`` beside the pack
folders records provenance (upstream URL, full commit), not identity.
"""
name = Path(pack).resolve().name
if re.fullmatch(r"x[0-9a-f]{7,12}", name):
return name
raise PackPatchError(
f"pack folder {name!r} is not a snapshot key — expected the db "
f"layout <slug>/x<short-commit-sha>/"
)
def _pack_folder(snapshot: Path) -> Path:
pack_dirs = [
directory
for directory in sorted(Path(snapshot).iterdir())
if directory.is_dir() and not directory.name.startswith(".")
]
if len(pack_dirs) != 1:
raise PackPatchError(
f"{snapshot}: a snapshot holds exactly one pack folder, "
f"found {[directory.name for directory in pack_dirs]}"
)
return pack_dirs[0]
def generate(pack: Path) -> tuple[dict, str]:
"""Build the (manifest, diff-text) pair for ``pack``.
The v2 tree is authoritative for what ships; the original tree is
authoritative for what it is a change AGAINST.
"""
snapshot = Path(pack)
pack_dir = _pack_folder(snapshot)
v2 = pack_dir / "v2"
if not v2.is_dir():
raise PackPatchError(f"{pack_dir} has no v2/ tree")
# Paths are relative to the pack folder on both sides. `_files` skips the
# `v2` child, so the pristine side is the pack as upstream shipped it.
orig = _files(pack_dir)
conv = _files(v2)
entries: list[dict] = []
diff_parts: list[str] = []
for rel in sorted(set(orig) | set(conv)):
o, c = orig.get(rel), conv.get(rel)
if c is None:
entries.append({"path": rel, "op": "delete", "base_sha256": _sha(o)})
continue
if o is None:
if not _is_text(c):
raise PackPatchError(
f"binary file added in v2 with no original: {rel} — "
f"a patch pair cannot review this; ship it in the base "
f"pack or argue the case"
)
entries.append(
{"path": rel, "op": "add", "v2_sha256": _sha(c), "mode": _mode(c)}
)
diff_parts.append(_unified([], _lines(c), "/dev/null", f"b/v2/{rel}"))
continue
base_sha, v2_sha = _sha(o), _sha(c)
if base_sha == v2_sha:
entries.append(
{"path": rel, "op": "copy", "base_sha256": base_sha, "mode": _mode(c)}
)
continue
if not (_is_text(o) and _is_text(c)):
raise PackPatchError(f"binary file differs between trees: {rel}")
entries.append(
{
"path": rel,
"op": "convert",
"base_sha256": base_sha,
"v2_sha256": v2_sha,
"mode": _mode(c),
}
)
diff_parts.append(_unified(_lines(o), _lines(c), f"a/{rel}", f"b/v2/{rel}"))
manifest = {
"format": FORMAT,
"pack": Path(pack).resolve().parent.name,
"key": pack_key(pack),
"files": entries,
"counts": {
op: sum(1 for e in entries if e["op"] == op)
for op in ("copy", "convert", "add", "delete")
},
}
return manifest, "".join(diff_parts)
def _safe_relpath(value) -> str:
if not isinstance(value, str) or not value or "\\" in value or "\0" in value:
raise PackPatchError(f"unsafe manifest path {value!r}")
path = PurePosixPath(value)
if (
path.is_absolute()
or PureWindowsPath(value).drive
or path.as_posix() != value
or ".." in path.parts
or path.parts[0] == "v2"
):
raise PackPatchError(f"unsafe manifest path {value!r}")
return value
def _validate_manifest(
snapshot: Path,
pack: Path,
manifest,
*,
allow_runtime_products: bool = False,
) -> tuple[list[dict], dict[str, Path]]:
if not isinstance(manifest, dict):
raise PackPatchError("manifest must be an object")
if manifest.get("format") != FORMAT:
raise PackPatchError(f"unknown manifest format {manifest.get('format')!r}")
expected_pack = snapshot.resolve().parent.name
if manifest.get("pack") != expected_pack:
raise PackPatchError(
f"manifest pack {manifest.get('pack')!r} does not match {expected_pack!r}"
)
expected_key = pack_key(snapshot)
if manifest.get("key") != expected_key:
raise PackPatchError(
f"manifest key {manifest.get('key')!r} does not match {expected_key!r}"
)
entries = manifest.get("files")
if not isinstance(entries, list):
raise PackPatchError("manifest files must be a list")
seen: set[str] = set()
seen_casefolded: set[str] = set()
for entry in entries:
if not isinstance(entry, dict):
raise PackPatchError("manifest file entries must be objects")
rel = _safe_relpath(entry.get("path"))
folded = rel.casefold()
if rel in seen or folded in seen_casefolded:
raise PackPatchError(f"duplicate manifest path {rel!r}")
seen.add(rel)
seen_casefolded.add(folded)
op = entry.get("op")
if op not in OPS:
raise PackPatchError(f"unknown op {op!r} for {rel}")
required = {"path", "op"}
if op in {"copy", "convert", "delete"}:
required.add("base_sha256")
if op in {"add", "convert"}:
required.add("v2_sha256")
if op != "delete":
required.add("mode")
if set(entry) != required:
raise PackPatchError(
f"manifest fields for {rel} are {sorted(entry)}, expected {sorted(required)}"
)
for field in ("base_sha256", "v2_sha256"):
if field in entry and not (
isinstance(entry[field], str) and SHA256_RE.fullmatch(entry[field])
):
raise PackPatchError(f"invalid {field} for {rel}")
if "mode" in entry and (
isinstance(entry["mode"], bool)
or not isinstance(entry["mode"], int)
or not 0 <= entry["mode"] <= 0o777
):
raise PackPatchError(f"invalid mode for {rel}")
declared_base = {
entry["path"]
for entry in entries
if entry["op"] in {"copy", "convert", "delete"}
}
base_files = _files(
pack,
installed_manifest_paths=(declared_base if allow_runtime_products else None),
)
actual_base = set(base_files)
if declared_base != actual_base:
missing = sorted(actual_base - declared_base)
extra = sorted(declared_base - actual_base)
raise PackPatchError(
f"manifest base file set differs: missing={missing}, extra={extra}"
)
expected_counts = {
op: sum(entry["op"] == op for entry in entries) for op in sorted(OPS)
}
if manifest.get("counts") != expected_counts:
raise PackPatchError(
f"manifest counts {manifest.get('counts')!r} do not match {expected_counts!r}"
)
return entries, base_files
def _parse_diff(diff_text: str) -> dict[str, tuple[str, list[str]]]:
"""Split one concatenated unified diff into per-target hunk blocks,
keyed by the pack-relative path (the ``b/v2/`` prefix stripped).
Framing counts hunk spans from the ``@@`` headers rather than pattern-
matching ``---``/``+++`` anywhere: a removed content line beginning
``-- x`` renders as ``--- x`` and would otherwise read as a file
boundary in the middle of a hunk. The header declares exactly how many
body lines follow; trusting it is both simpler and unspoofable.
"""
lines = diff_text.splitlines(keepends=True)
blocks: dict[str, tuple[str, list[str]]] = {}
i = 0
while i < len(lines):
if not lines[i].startswith("--- "):
raise PackPatchError(f"expected a file header, got {lines[i]!r}")
source = lines[i][4:].rstrip("\r\n")
if i + 1 >= len(lines) or not lines[i + 1].startswith("+++ "):
raise PackPatchError(f"file header without target at line {i + 1}")
target = lines[i + 1][4:].rstrip("\r\n")
if not target.startswith("b/v2/"):
raise PackPatchError(f"unexpected diff target {target!r}")
rel = _safe_relpath(target[len("b/v2/") :])
if rel in blocks:
raise PackPatchError(f"duplicate diff target {rel!r}")
block: list[str] = []
i += 2
while i < len(lines) and lines[i].startswith("@@"):
header = lines[i]
match = HUNK_RE.fullmatch(header.rstrip("\r\n"))
if match is None:
raise PackPatchError(f"{rel}: malformed hunk header {header!r}")
old_len = int(match.group(2)) if match.group(2) is not None else 1
new_len = int(match.group(4)) if match.group(4) is not None else 1
block.append(header)
i += 1
remaining_old, remaining_new = old_len, new_len
while (remaining_old > 0 or remaining_new > 0) and i < len(lines):
body = lines[i]
tag = body[0] if body else " "
if tag == " ":
remaining_old -= 1
remaining_new -= 1
elif tag == "-":
remaining_old -= 1
elif tag == "+":
remaining_new -= 1
elif tag == "\\":
pass
else:
raise PackPatchError(f"{target}: malformed hunk body {body!r}")
block.append(body)
i += 1
if remaining_old != 0 or remaining_new != 0:
raise PackPatchError(f"{rel}: truncated hunk body")
if i < len(lines) and lines[i].startswith("\\"):
block.append(lines[i])
i += 1
if not block:
raise PackPatchError(f"{rel}: diff header has no hunks")
blocks[rel] = (source, block)
return blocks
def _apply_hunks(base_lines: list[str], hunks: list[str], rel: str) -> list[str]:
# Git's no-newline convention: a "\ No newline at end of file" marker means
# the line BEFORE it has no terminator — the newline on that line exists
# only for the diff's own framing. Resolve markers up front so the loop
# below never sees them and body lines compare byte-exactly.
resolved: list[str] = []
for h in hunks:
if h.startswith("\\"):
if not resolved:
raise PackPatchError(f"{rel}: dangling no-newline marker")
resolved[-1] = resolved[-1].rstrip("\n")
else:
resolved.append(h)
hunks = resolved
out: list[str] = []
pos = 0
i = 0
while i < len(hunks):
line = hunks[i]
if not line.startswith("@@"):
raise PackPatchError(f"{rel}: malformed hunk header {line!r}")
header = line.split("@@")[1].strip()
old_span = header.split(" ")[0]
start = int(old_span.lstrip("-").split(",")[0])
# A zero-length old span addresses the line AFTER which to insert.
old_len = int(old_span.split(",")[1]) if "," in old_span else 1
anchor = start - 1 if old_len else start
if anchor < pos:
raise PackPatchError(f"{rel}: overlapping hunks")
out.extend(base_lines[pos:anchor])
pos = anchor
i += 1
while i < len(hunks) and not hunks[i].startswith("@@"):
h = hunks[i]
tag, body = h[0], h[1:]
if tag == " ":
if pos >= len(base_lines) or base_lines[pos] != body:
raise PackPatchError(f"{rel}: context mismatch at line {pos + 1}")
out.append(body)
pos += 1
elif tag == "-":
if pos >= len(base_lines) or base_lines[pos] != body:
raise PackPatchError(f"{rel}: removal mismatch at line {pos + 1}")
pos += 1
elif tag == "+":
out.append(body)
else:
raise PackPatchError(f"{rel}: malformed hunk line {h!r}")
i += 1
out.extend(base_lines[pos:])
return out
def apply(pack: Path, manifest: dict, diff_text: str) -> None:
"""Re-create ``pack/v2`` from a pristine pack plus the pair.
Refuses to run against a base that does not match the manifest's hashes —
a patch applied to the wrong snapshot must fail before it writes anything,
not produce a plausible near-miss.
"""
snapshot = Path(pack)
pack = _pack_folder(snapshot)
entries, base_files = _validate_manifest(snapshot, pack, manifest)
hunks_by_path = _parse_diff(diff_text)
expected_diff_paths = {
entry["path"]
for entry in entries
if entry["op"] == "convert"
or (entry["op"] == "add" and entry["v2_sha256"] != EMPTY_SHA256)
}
if set(hunks_by_path) != expected_diff_paths:
missing = sorted(expected_diff_paths - set(hunks_by_path))
extra = sorted(set(hunks_by_path) - expected_diff_paths)
raise PackPatchError(
f"diff target set differs: missing={missing}, extra={extra}"
)
by_path = {entry["path"]: entry for entry in entries}
for rel, (source, _) in hunks_by_path.items():
expected_source = "/dev/null" if by_path[rel]["op"] == "add" else f"a/{rel}"
if source != expected_source:
raise PackPatchError(
f"diff source for {rel} is {source!r}, expected {expected_source!r}"
)
# Verify the whole base first; write nothing until it all checks out.
for e in entries:
if e["op"] in ("copy", "convert", "delete"):
base = base_files[e["path"]]
if _sha(base) != e["base_sha256"]:
raise PackPatchError(
f"base file does not match the manifest: {e['path']} — "
f"this pair was generated against a different snapshot"
)
v2 = pack / "v2"
if v2.exists() or v2.is_symlink():
raise PackPatchError(f"refusing to replace existing conversion: {v2}")
staging = Path(tempfile.mkdtemp(prefix=".v2-build-", dir=snapshot))
try:
for e in entries:
rel, op = e["path"], e["op"]
target = staging / rel
if op == "delete":
continue
target.parent.mkdir(parents=True, exist_ok=True)
if op == "copy":
shutil.copy2(base_files[rel], target)
elif op == "add":
hunks = hunks_by_path.get(rel, ("", []))[1]
produced = "".join(_apply_hunks([], hunks, rel))
target.write_bytes(produced.encode("utf-8"))
elif op == "convert":
base_lines = _lines(base_files[rel])
produced = "".join(_apply_hunks(base_lines, hunks_by_path[rel][1], rel))
target.write_bytes(produced.encode("utf-8"))
else:
raise PackPatchError(f"unknown op {op!r} for {rel}")
target.chmod(e["mode"])
if "v2_sha256" in e and _sha(target) != e["v2_sha256"]:
raise PackPatchError(
f"applied result does not match the manifest: {rel}"
)
staging.replace(v2)
except BaseException:
shutil.rmtree(staging, ignore_errors=True)
raise
def _artifact_stem(manifest: dict) -> str:
pack = manifest.get("pack") if isinstance(manifest, dict) else None
key = manifest.get("key") if isinstance(manifest, dict) else None
if (
not isinstance(pack, str)
or not pack
or pack in {".", ".."}
or "/" in pack
or "\\" in pack
):
raise PackPatchError(f"invalid artifact pack name {pack!r}")
if not isinstance(key, str) or re.fullmatch(r"x[0-9a-f]{7,12}", key) is None:
raise PackPatchError(f"invalid artifact key {key!r}")
return f"{pack}-{key}"
def _zip_member(name: str, data: bytes) -> tuple[zipfile.ZipInfo, bytes]:
info = zipfile.ZipInfo(name, date_time=(1980, 1, 1, 0, 0, 0))
info.compress_type = zipfile.ZIP_DEFLATED
info.create_system = 3
info.external_attr = (stat.S_IFREG | 0o644) << 16
return info, data
def bundle(manifest: dict, diff_text: str) -> bytes:
"""Build the deterministic deployment zip containing only the pair."""
if not isinstance(diff_text, str):
raise PackPatchError("diff must be text")
stem = _artifact_stem(manifest)
manifest_bytes = (json.dumps(manifest, indent=1) + "\n").encode("utf-8")
diff_bytes = diff_text.encode("utf-8")
if max(len(manifest_bytes), len(diff_bytes)) > MAX_BUNDLE_MEMBER_BYTES:
raise PackPatchError("patch-pair member exceeds the deployment size limit")
out = io.BytesIO()
with zipfile.ZipFile(out, "w") as archive:
for info, data in (
_zip_member(f"{stem}.json", manifest_bytes),
_zip_member(f"{stem}.diff", diff_bytes),
):
archive.writestr(info, data)
return out.getvalue()
def _read_bundle(artifact: bytes) -> tuple[dict, str]:
if not isinstance(artifact, bytes):
raise PackPatchError("deployment artifact must be bytes")
if len(artifact) > MAX_BUNDLE_BYTES:
raise PackPatchError("deployment artifact exceeds the size limit")
try:
with zipfile.ZipFile(io.BytesIO(artifact)) as archive:
infos = archive.infolist()
if len(infos) != 2 or len({info.filename for info in infos}) != 2:
raise PackPatchError("deployment zip must contain exactly two files")
for info in infos:
if (
info.is_dir()
or "/" in info.filename
or "\\" in info.filename
or info.flag_bits & 1
):
raise PackPatchError(
f"invalid deployment zip member {info.filename!r}"
)
if (
info.file_size > MAX_BUNDLE_MEMBER_BYTES
or info.compress_size > MAX_BUNDLE_MEMBER_BYTES
):
raise PackPatchError(
f"deployment zip member is too large: {info.filename}"
)
names = {info.filename for info in infos}
json_names = [name for name in names if name.endswith(".json")]
diff_names = [name for name in names if name.endswith(".diff")]
if len(json_names) != 1 or len(diff_names) != 1:
raise PackPatchError(
"deployment zip must contain one .json and one .diff"
)
manifest = json.loads(archive.read(json_names[0]).decode("utf-8"))
expected_stem = _artifact_stem(manifest)
expected_names = {f"{expected_stem}.json", f"{expected_stem}.diff"}
if names != expected_names:
raise PackPatchError(
f"deployment zip names {sorted(names)} do not match the manifest"
)
diff_text = archive.read(diff_names[0]).decode("utf-8")
except PackPatchError:
raise
except (
json.JSONDecodeError,
UnicodeDecodeError,
zipfile.BadZipFile,
RuntimeError,
) as exc:
raise PackPatchError(f"invalid deployment zip: {exc}") from exc
return manifest, diff_text
def apply_bundle(pack: Path, artifact: bytes | Path) -> None:
"""Verify and apply a deployment zip to a pristine pack snapshot."""
data = Path(artifact).read_bytes() if isinstance(artifact, Path) else artifact
manifest, diff_text = _read_bundle(data)
apply(pack, manifest, diff_text)
def validate_bundle(pack: Path, artifact: bytes | Path) -> None:
"""Verify that an existing materialized conversion matches its bundle."""
data = Path(artifact).read_bytes() if isinstance(artifact, Path) else artifact
manifest, diff_text = _read_bundle(data)
snapshot = Path(pack)
pack_root = _pack_folder(snapshot)
entries, base_files = _validate_manifest(
snapshot,
pack_root,
manifest,
allow_runtime_products=True,
)
hunks_by_path = _parse_diff(diff_text)
expected_diff_paths = {
entry["path"]
for entry in entries
if entry["op"] == "convert"
or (entry["op"] == "add" and entry["v2_sha256"] != EMPTY_SHA256)
}
if set(hunks_by_path) != expected_diff_paths:
missing = sorted(expected_diff_paths - set(hunks_by_path))
extra = sorted(set(hunks_by_path) - expected_diff_paths)
raise PackPatchError(
f"diff target set differs: missing={missing}, extra={extra}"
)
by_path = {entry["path"]: entry for entry in entries}
for rel, (source, _) in hunks_by_path.items():
expected_source = "/dev/null" if by_path[rel]["op"] == "add" else f"a/{rel}"
if source != expected_source:
raise PackPatchError(
f"diff source for {rel} is {source!r}, expected {expected_source!r}"
)
for entry in entries:
if entry["op"] in {"copy", "convert", "delete"}:
if _sha(base_files[entry["path"]]) != entry["base_sha256"]:
raise PackPatchError(
f"base file does not match the manifest: {entry['path']}"
)
v2 = pack_root / "v2"
if not v2.is_dir() or v2.is_symlink():
raise PackPatchError(f"materialized pack has no regular v2 tree: {v2}")
converted = _files(v2)
expected_paths = {entry["path"] for entry in entries if entry["op"] != "delete"}
if set(converted) != expected_paths:
missing = sorted(expected_paths - set(converted))
extra = sorted(set(converted) - expected_paths)
raise PackPatchError(
f"materialized file set differs: missing={missing}, extra={extra}"
)
for entry in entries:
rel = entry["path"]
if entry["op"] == "delete":
continue
target = converted[rel]
expected_sha = entry.get("v2_sha256", entry.get("base_sha256"))
if _sha(target) != expected_sha or _mode(target) != entry["mode"]:
raise PackPatchError(f"materialized file differs from manifest: {rel}")
if entry["op"] not in {"add", "convert"}:
continue
hunks = hunks_by_path.get(rel, ("", []))[1]
base_lines = [] if entry["op"] == "add" else _lines(base_files[rel])
produced = "".join(_apply_hunks(base_lines, hunks, rel)).encode("utf-8")
if produced != target.read_bytes():
raise PackPatchError(f"diff does not reproduce materialized file: {rel}")
def validate_tree(actual: Path | str, expected: Path | str) -> None:
"""Require two pack trees to have identical files, bytes, and modes."""
actual_root = Path(actual).resolve()
expected_root = Path(expected).resolve()
if not actual_root.is_dir() or not expected_root.is_dir():
raise PackPatchError("validation diff requires two pack directories")
actual_files = _files(actual_root)
expected_files = _files(expected_root)
actual_paths = set(actual_files)
expected_paths = set(expected_files)
missing = sorted(expected_paths - actual_paths)
extra = sorted(actual_paths - expected_paths)
shared = sorted(actual_paths & expected_paths)
changed = [
path
for path in shared
if _sha(actual_files[path]) != _sha(expected_files[path])
]
modes = [
path
for path in shared
if _mode(actual_files[path]) != _mode(expected_files[path])
]
if missing or extra or changed or modes:
raise PackPatchError(
"materialized conversion differs from debug reference: "
f"missing={missing}, extra={extra}, changed={changed}, modes={modes}"
)
def main(argv: list[str]) -> int:
import argparse
ap = argparse.ArgumentParser(
description="Generate or apply a pack's .json/.diff pair"
)
sub = ap.add_subparsers(dest="cmd", required=True)
g = sub.add_parser("generate")
g.add_argument("pack", type=Path)
g.add_argument("out_dir", type=Path)
a = sub.add_parser("apply")
a.add_argument("pack", type=Path)
a.add_argument(
"pair_prefix", type=Path, help="path up to the extension: <dir>/<Pack-Name>"
)
b = sub.add_parser("bundle")
b.add_argument(
"pair_prefix", type=Path, help="path up to the extension: <dir>/<Pack-Name>"
)
b.add_argument("output", type=Path, nargs="?")
z = sub.add_parser("apply-zip")
z.add_argument("pack", type=Path)
z.add_argument("artifact", type=Path)
args = ap.parse_args(argv)
if args.cmd == "generate":
manifest, diff_text = generate(args.pack)
args.out_dir.mkdir(parents=True, exist_ok=True)
base = args.out_dir / f"{manifest['pack']}-{manifest['key']}"
base.with_suffix(".json").write_bytes(
(json.dumps(manifest, indent=1) + "\n").encode("utf-8")
)
base.with_suffix(".diff").write_bytes(diff_text.encode("utf-8"))
c = manifest["counts"]
print(
f"{manifest['pack']} {manifest['key']}: "
f"{c['convert']} converted, {c['add']} added, "
f"{c['copy']} copied, {c['delete']} deleted -> "
f"{base.with_suffix('.json').name}, {base.with_suffix('.diff').name}"
)
return 0
if args.cmd == "bundle":
manifest = json.loads(
args.pair_prefix.with_suffix(".json").read_bytes().decode("utf-8")
)
diff_text = args.pair_prefix.with_suffix(".diff").read_bytes().decode("utf-8")
output = args.output or args.pair_prefix.with_suffix(".zip")
output.write_bytes(bundle(manifest, diff_text))
print(f"wrote {output}")
return 0
if args.cmd == "apply-zip":
apply_bundle(args.pack, args.artifact)
print(f"re-created {_pack_folder(args.pack) / 'v2'} from {args.artifact}")
return 0
manifest = json.loads(
args.pair_prefix.with_suffix(".json").read_bytes().decode("utf-8")
)
diff_text = args.pair_prefix.with_suffix(".diff").read_bytes().decode("utf-8")
apply(args.pack, manifest, diff_text)
print(f"re-created {_pack_folder(args.pack) / 'v2'} from the pair")
return 0
if __name__ == "__main__":
import sys
raise SystemExit(main(sys.argv[1:]))