#!/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())