197 lines
7.0 KiB
Python
Executable File
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())
|