Files

197 lines
7.0 KiB
Python
Executable File

#!/usr/bin/env python3
"""Classify a target PSP against target HEAD, reference HEAD, and reference worktree."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import subprocess
import sys
import tempfile
from pathlib import Path
class DeltaError(RuntimeError):
pass
def run_git(repo: Path, *args: str, binary: bool = False):
return subprocess.run(
["git", "-C", str(repo), *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=not binary,
check=False,
)
def repository(path: Path, field: str) -> Path:
path = path.expanduser().resolve(strict=True)
if not path.is_dir() or path.is_symlink():
raise DeltaError(f"{field} 必须是普通目录: {path}")
top = run_git(path, "rev-parse", "--show-toplevel")
if top.returncode != 0 or Path(top.stdout.strip()).resolve() != path:
raise DeltaError(f"{field} 必须是独立 Git 仓库根目录: {path}")
head = run_git(path, "rev-parse", "--verify", "HEAD")
if head.returncode != 0:
raise DeltaError(f"{field} 缺少可追踪 HEAD")
return path
def digest(kind: str, mode: str, data: bytes) -> str:
value = hashlib.sha256()
value.update(kind.encode("ascii") + b"\0" + mode.encode("ascii") + b"\0" + data)
return value.hexdigest()
def head_snapshot(repo: Path) -> dict[str, str]:
listing = run_git(repo, "ls-tree", "-r", "-z", "--full-tree", "HEAD", binary=True)
if listing.returncode != 0:
raise DeltaError(listing.stderr.decode(errors="replace"))
result: dict[str, str] = {}
for raw in listing.stdout.split(b"\0"):
if not raw:
continue
metadata, raw_path = raw.split(b"\t", 1)
mode, kind, _object_id = metadata.decode("ascii").split(" ", 2)
path = raw_path.decode("utf-8")
if kind != "blob" or mode == "160000":
raise DeltaError(f"仓库包含不支持的特殊条目: {path}")
content = run_git(repo, "show", f"HEAD:{path}", binary=True)
if content.returncode != 0:
raise DeltaError(f"无法读取 HEAD 文件: {path}")
result[path] = digest("symlink" if mode == "120000" else "file", mode, content.stdout)
return result
def worktree_snapshot(repo: Path) -> dict[str, str]:
listing = run_git(
repo, "ls-files", "-z", "--cached", "--others", "--exclude-standard", binary=True
)
if listing.returncode != 0:
raise DeltaError(listing.stderr.decode(errors="replace"))
result: dict[str, str] = {}
for raw in listing.stdout.split(b"\0"):
if not raw:
continue
relative = raw.decode("utf-8")
path = repo / relative
if path.is_symlink():
result[relative] = digest("symlink", "120000", os.readlink(path).encode())
elif path.is_file():
mode = "100755" if os.access(path, os.X_OK) else "100644"
result[relative] = digest("file", mode, path.read_bytes())
return result
def classify(
target_head: dict[str, str],
target_worktree: dict[str, str],
reference_head: dict[str, str],
reference_worktree: dict[str, str],
) -> dict[str, object]:
categories: dict[str, list[str]] = {
"matches_reference_head": [],
"matches_reference_dirty_worktree": [],
"target_unchanged": [],
"target_specific": [],
"unclassified_conflict": [],
}
rows: list[dict[str, object]] = []
all_paths = sorted(
set(target_head) | set(target_worktree) | set(reference_head) | set(reference_worktree)
)
for path in all_paths:
th = target_head.get(path)
tw = target_worktree.get(path)
rh = reference_head.get(path)
rw = reference_worktree.get(path)
if tw == rh and (path in target_worktree or path in reference_head):
category = "matches_reference_head"
elif tw == rw and rw != rh and (
path in target_head or path in reference_head or path in target_worktree or path in reference_worktree
):
category = "matches_reference_dirty_worktree"
elif tw == th:
category = "target_unchanged"
elif tw not in {rh, rw}:
category = "target_specific"
else:
category = "unclassified_conflict"
categories[category].append(path)
rows.append(
{
"path": path,
"category": category,
"present": {
"target_head": th is not None,
"target_worktree": tw is not None,
"reference_head": rh is not None,
"reference_worktree": rw is not None,
},
}
)
return {"categories": categories, "paths": rows}
def analyze(target: Path, reference: Path) -> dict[str, object]:
target = repository(target, "target")
reference = repository(reference, "reference")
target_head = head_snapshot(target)
target_worktree = worktree_snapshot(target)
reference_head = head_snapshot(reference)
reference_worktree = worktree_snapshot(reference)
analysis = classify(target_head, target_worktree, reference_head, reference_worktree)
target_commit = run_git(target, "rev-parse", "HEAD").stdout.strip()
reference_commit = run_git(reference, "rev-parse", "HEAD").stdout.strip()
reference_dirty = reference_head != reference_worktree
return {
"schema_version": 1,
"passed": True,
"target": {"path": str(target), "head": target_commit, "dirty": target_head != target_worktree},
"reference": {
"path": str(reference),
"head": reference_commit,
"dirty": reference_dirty,
"dirty_paths": sorted(
path
for path in set(reference_head) | set(reference_worktree)
if reference_head.get(path) != reference_worktree.get(path)
),
},
**analysis,
}
def write_json(path: Path, payload: dict[str, object]) -> None:
path = path.expanduser().resolve(strict=False)
path.parent.mkdir(parents=True, exist_ok=True)
with tempfile.NamedTemporaryFile("w", encoding="utf-8", dir=path.parent, delete=False) as handle:
json.dump(payload, handle, ensure_ascii=False, indent=2)
handle.write("\n")
temporary = Path(handle.name)
os.replace(temporary, path)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--target", type=Path, required=True)
parser.add_argument("--reference", type=Path, required=True)
parser.add_argument("--output", type=Path)
args = parser.parse_args()
try:
result = analyze(args.target, args.reference)
if args.output:
write_json(args.output, result)
print(json.dumps(result, ensure_ascii=False, indent=2))
return 0
except (DeltaError, OSError, UnicodeError) as exc:
print(json.dumps({"passed": False, "error": str(exc)}, ensure_ascii=False, indent=2))
return 2
if __name__ == "__main__":
raise SystemExit(main())