Files
playbook/antigravity-awesome-skills/tools/scripts/git_change_records.py
T
2026-07-18 00:02:59 +00:00

302 lines
10 KiB
Python

#!/usr/bin/env python3
"""NUL-safe, mode-aware Git change records and snapshot helpers."""
from __future__ import annotations
import argparse
import json
import os
import posixpath
import re
import subprocess
import unicodedata
from dataclasses import asdict, dataclass
from pathlib import Path, PurePosixPath
RAW_HEADER = re.compile(
rb"^:([0-7]{6}) ([0-7]{6}) ([0-9a-f]+) ([0-9a-f]+) ([A-Z])([0-9]*)$"
)
SAFE_BLOB_MODES = {"100644"}
SUPPORTED_STATUSES = {"A", "C", "D", "M", "R", "T"}
@dataclass(frozen=True)
class ChangeRecord:
status: str
old_path: str | None
new_path: str | None
old_mode: str
new_mode: str
old_oid: str
new_oid: str
similarity: int | None = None
def to_dict(self) -> dict[str, object]:
return asdict(self)
@dataclass(frozen=True)
class TreeEntry:
path: str
mode: str
object_type: str
oid: str
def _run_git(repo: str | Path, args: list[str], *, input_bytes: bytes | None = None) -> bytes:
result = subprocess.run(
["git", *args],
cwd=str(repo),
input=input_bytes,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode != 0:
message = result.stderr.decode("utf-8", "replace").strip()
raise RuntimeError(message or f"git {' '.join(args)} failed ({result.returncode})")
return result.stdout
def resolve_commit(repo: str | Path, ref: str) -> str:
value = _run_git(repo, ["rev-parse", "--verify", f"{ref}^{{commit}}"]).strip().decode("ascii")
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", value):
raise RuntimeError(f"Git returned a non-full commit object ID for {ref!r}")
return value
def resolve_merge_base(repo: str | Path, base_ref: str, head_ref: str) -> str:
value = _run_git(repo, ["merge-base", base_ref, head_ref]).strip().decode("ascii")
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", value):
raise RuntimeError(f"Git returned a non-full merge base for {base_ref!r} and {head_ref!r}")
return value
def parse_raw_diff(payload: bytes) -> list[ChangeRecord]:
"""Parse ``git diff --raw -z`` without interpreting path bytes as separators."""
if not payload:
return []
fields = payload.split(b"\0")
if fields[-1] != b"":
raise ValueError("raw Git diff is not NUL terminated")
fields.pop()
records: list[ChangeRecord] = []
index = 0
while index < len(fields):
match = RAW_HEADER.fullmatch(fields[index])
if not match:
raise ValueError(f"malformed raw Git diff header at field {index}")
index += 1
old_mode, new_mode, old_oid, new_oid, status_raw, similarity_raw = match.groups()
if len(old_oid) not in {40, 64} or len(new_oid) != len(old_oid):
raise ValueError("raw Git diff contains a truncated or mixed-width object ID")
status = status_raw.decode("ascii")
if status not in SUPPORTED_STATUSES:
raise ValueError(f"unsupported raw Git diff status {status!r}")
if status in {"R", "C"}:
if not similarity_raw or int(similarity_raw) > 100:
raise ValueError(f"raw Git {status} record has invalid similarity")
elif similarity_raw:
raise ValueError(f"raw Git {status} record unexpectedly has similarity")
path_count = 2 if status in {"R", "C"} else 1
if index + path_count > len(fields):
raise ValueError("raw Git diff ended before all path fields")
decoded = [field.decode("utf-8", "surrogateescape") for field in fields[index : index + path_count]]
if any(not path for path in decoded):
raise ValueError("raw Git diff contains an empty path")
index += path_count
old_path = decoded[0] if status != "A" else None
new_path = decoded[-1] if status != "D" else None
records.append(
ChangeRecord(
status=status,
old_path=old_path,
new_path=new_path,
old_mode=old_mode.decode("ascii"),
new_mode=new_mode.decode("ascii"),
old_oid=old_oid.decode("ascii"),
new_oid=new_oid.decode("ascii"),
similarity=int(similarity_raw) if similarity_raw else None,
)
)
return records
def read_change_records(
repo: str | Path,
base_ref: str,
head_ref: str,
*,
merge_base: bool = True,
) -> tuple[str, str, list[ChangeRecord]]:
"""Return immutable endpoints and their complete raw change records.
``merge_base=True`` is the PR evidence mode. ``False`` is the explicit
base/head security mode and resolves both endpoints directly.
"""
head_oid = resolve_commit(repo, head_ref)
base_oid = resolve_merge_base(repo, base_ref, head_oid) if merge_base else resolve_commit(repo, base_ref)
payload = _run_git(
repo,
[
"diff",
"--raw",
"--no-abbrev",
"-z",
"-M",
"--find-copies-harder",
base_oid,
head_oid,
"--",
],
)
return base_oid, head_oid, parse_raw_diff(payload)
def read_blob(repo: str | Path, oid: str) -> bytes:
if not re.fullmatch(r"[0-9a-f]{40}|[0-9a-f]{64}", oid):
raise ValueError("blob object ID must be a full hexadecimal object ID")
return _run_git(repo, ["cat-file", "blob", oid])
def read_path(repo: str | Path, commit_oid: str, path: str) -> bytes | None:
encoded_path = path.encode("utf-8", "surrogateescape")
spec = commit_oid.encode("ascii") + b":" + encoded_path
result = subprocess.run(
["git", "cat-file", "blob", spec],
cwd=str(repo),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
check=False,
)
if result.returncode == 0:
return result.stdout
return None
def list_tree(repo: str | Path, commit_oid: str, prefix: str) -> list[TreeEntry]:
raw = _run_git(repo, ["ls-tree", "-r", "-z", "--full-tree", commit_oid, "--", prefix])
entries: list[TreeEntry] = []
for item in raw.split(b"\0"):
if not item:
continue
metadata, separator, path_bytes = item.partition(b"\t")
if not separator:
raise ValueError("malformed ls-tree record")
mode, object_type, oid = metadata.decode("ascii").split(" ", 2)
entries.append(
TreeEntry(
path=path_bytes.decode("utf-8", "surrogateescape"),
mode=mode,
object_type=object_type,
oid=oid,
)
)
return entries
def validate_repo_path(path: str) -> str | None:
if not path or path.startswith("/") or "\\" in path or "\x00" in path:
return "path is absolute, empty, contains NUL, or contains a literal backslash"
if any(unicodedata.category(character) in {"Cc", "Cs"} for character in path):
return "path contains a control character or invalid UTF-8 byte"
normalized = posixpath.normpath(path)
if normalized != path or normalized == ".." or normalized.startswith("../"):
return "path is not a normalized repository-relative path"
return None
def materialize_tree(
repo: str | Path,
commit_oid: str,
prefix: str,
destination: str | Path,
) -> list[dict[str, str]]:
"""Materialize regular non-executable blobs; report every unsafe entry."""
root = Path(destination).resolve()
root.mkdir(parents=True, exist_ok=True, mode=0o700)
unsafe: list[dict[str, str]] = []
prefix_root = PurePosixPath(prefix)
for entry in list_tree(repo, commit_oid, prefix):
reason = validate_repo_path(entry.path)
try:
relative = PurePosixPath(entry.path).relative_to(prefix_root)
except ValueError:
reason = reason or "tree entry is outside the requested prefix"
relative = None
if entry.object_type != "blob":
reason = reason or f"unsupported Git object type {entry.object_type}"
if entry.mode not in SAFE_BLOB_MODES:
mode_kind = {
"100755": "executable file",
"120000": "symlink",
"160000": "gitlink",
}.get(entry.mode, "unrecognized mode")
reason = reason or f"unsafe {mode_kind} ({entry.mode})"
if reason or relative is None or not relative.parts:
unsafe.append(
{
"path": entry.path,
"mode": entry.mode,
"oid": entry.oid,
"reason": reason or "unsafe tree entry",
}
)
continue
target = root.joinpath(*relative.parts)
target.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
resolved_parent = target.parent.resolve()
try:
resolved_parent.relative_to(root)
except ValueError:
unsafe.append(
{
"path": entry.path,
"mode": entry.mode,
"oid": entry.oid,
"reason": "destination escapes snapshot root",
}
)
continue
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
if hasattr(os, "O_NOFOLLOW"):
flags |= os.O_NOFOLLOW
descriptor = os.open(target, flags, 0o600)
try:
with os.fdopen(descriptor, "wb", closefd=False) as handle:
handle.write(read_blob(repo, entry.oid))
finally:
os.close(descriptor)
return sorted(unsafe, key=lambda item: (item["path"], item["mode"], item["oid"], item["reason"]))
def main() -> int:
parser = argparse.ArgumentParser(description="Emit complete mode-aware Git change records.")
parser.add_argument("--repo", default=".")
parser.add_argument("--base", required=True)
parser.add_argument("--head", required=True)
parser.add_argument("--mode", choices=("merge-base", "explicit"), default="merge-base")
args = parser.parse_args()
base_oid, head_oid, records = read_change_records(
args.repo, args.base, args.head, merge_base=args.mode == "merge-base"
)
print(
json.dumps(
{
"schema_version": 1,
"mode": args.mode,
"base_oid": base_oid,
"head_oid": head_oid,
"changes": [record.to_dict() for record in records],
},
indent=2,
sort_keys=True,
)
)
return 0
if __name__ == "__main__":
raise SystemExit(main())