Files
gplan-2/tools/undo_proof.py
T

160 lines
7.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""撤销证明自动化工具:一条命令完成「改前绿 → 真撤销复红 → 还原复绿」三连并出回执。
撤销证明的判据(test-conventions §8.4):只撤销该改动、跑能覆盖它的最窄测试、对应断言
必须变红、还原后复绿;撤销后仍绿=断言没钉在改动上,判缺漏。本工具把这三连机械化——
合法性属于「真撤销」(回退本批真实改动),全项目禁做变异类测试(改断言极性、置空状态、
改名键、删调用行等人为制造红=伪造证据)。
用法:
python tools/undo_proof.py --cmd "<最窄测试命令>" --revert-commit <该批提交>
python tools/undo_proof.py --cmd "<最窄测试命令>" --revert-files <f1,f2> --base <rev>
可选 --out <json 路径> 落回执;--timeout <秒> 限制单次测试命令时长(缺省 3600)。
--revert-commit:按该笔提交触过的文件集回退到其父提交版本(改动须已提交在分支上)。
--revert-files+--base:把指定文件回退到 <base>(默认 HEAD)版本——适合改动尚未单独
成笔、混在当前尖里的场景。
测试命令在仓库根以 shell 执行;单套件直跑形态见 game/tests/README.md(测试工具手册)。
流程与退出码:
① 改前基线:测试命令必须绿(红=改前就红,无证明意义,中止)。
② 真撤销:指定文件回退到基准版本 → 重跑命令,必须红(红=断言钉在该改动上)。
③ 还原:文件恢复到 HEAD → 重跑命令,必须绿。
④ 任何一步异常都先还原再退出;三步全过=退出码 0 并打印回执,否则退出码 1。
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
REPO_DEFAULT = Path(__file__).resolve().parent.parent
REPO = REPO_DEFAULT
def run(args: list[str], check: bool = True) -> str:
r = subprocess.run(["git", "-C", str(REPO)] + args,
capture_output=True, text=True, encoding="utf-8", errors="replace",
timeout=60)
if check and r.returncode != 0:
sys.stderr.write("git %s 失败:%s\n" % (" ".join(args), (r.stderr or "").strip()))
sys.exit(1)
return (r.stdout or "").strip()
def run_cmd(cmd: str, timeout: int) -> int:
r = subprocess.run(cmd, shell=True, cwd=str(REPO), capture_output=True,
text=True, encoding="utf-8", errors="replace", timeout=timeout)
if r.returncode != 0:
tail = "\n".join((r.stdout or "").splitlines()[-15:] or ["(无输出)"])
sys.stderr.write(" | 测试命令失败(exit=%d),输出尾部:\n%s\n" % (r.returncode, tail))
return r.returncode
def _in_rev(rev: str, f: str) -> bool:
r = subprocess.run(["git", "-C", str(REPO), "cat-file", "-e", "%s:%s" % (rev, f)],
capture_output=True, timeout=30)
return r.returncode == 0
def apply_rev(rev: str, files: list[str]) -> None:
# 逐文件:基准版本存在 → 恢复到该版本;基准没有(该批新增的文件)→ 从工作区删除。
for f in files:
if _in_rev(rev, f):
run(["checkout", rev, "--", f])
else:
subprocess.run(["git", "-C", str(REPO), "rm", "-q", "-f", "--", f],
capture_output=True, text=True, timeout=30)
def main() -> None:
ap = argparse.ArgumentParser(description="自动三连出撤销证明红绿回执")
ap.add_argument("--cmd", required=True, help="最窄测试命令(shell 形,仓库根执行)")
ap.add_argument("--revert-commit", default=None, help="该批提交:按其触过的文件集回退到父版本")
ap.add_argument("--revert-files", default=None, help="逗号分隔的文件清单(与 --base 配用)")
ap.add_argument("--base", default="HEAD", help="回退基准版本(默认 HEAD)")
ap.add_argument("--timeout", type=int, default=3600, help="单次测试命令时长上限(秒)")
ap.add_argument("--repo", default=None, help="仓库/worktree 路径(缺省=本脚本所在仓库)")
ap.add_argument("--out", default=None, help="回执 JSON 落盘路径")
args = ap.parse_args()
global REPO
REPO = Path(args.repo).resolve() if args.repo else REPO_DEFAULT
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
if bool(args.revert_commit) == bool(args.revert_files):
ap.error("--revert-commit 与 --revert-files 二选一")
if args.revert_commit:
files = [x.strip() for x in run(
["-c", "core.quotepath=false", "show", "--name-only", "--format=",
args.revert_commit]).splitlines() if x.strip()]
base = "%s^" % args.revert_commit
if not files:
sys.stderr.write("[撤销证明] 提交 %s 未触碰任何文件,无可撤销\n" % args.revert_commit)
sys.exit(2)
else:
files = [x.strip() for x in args.revert_files.split(",") if x.strip()]
base = args.base
dirty = run(["-c", "core.quotepath=false", "status", "--porcelain", "--"] + files)
if dirty:
sys.stderr.write("[撤销证明] 指定文件带未提交改动,撤销与还原的基准不清,先提交:\n%s\n" % dirty)
sys.exit(2)
steps: list[dict[str, object]] = []
result = "pass"
def step(name: str, expect: str, code: int) -> bool:
ok = (code == 0) if expect == "绿" else (code != 0)
steps.append({"step": name, "expect": expect, "exit": code, "ok": ok})
print("[撤销证明] %s:期望%s,实测 exit=%d → %s"
% (name, expect, code, "符合" if ok else "不符合"))
return ok
print("[撤销证明] 对象文件 %d 个;基准 %s;命令:%s" % (len(files), base, args.cmd))
try:
if not step("① 改前基线", "绿", run_cmd(args.cmd, args.timeout)):
result = "fail(改前就红——先修绿再做撤销证明)"
else:
apply_rev(base, files)
try:
if not step("② 真撤销复红", "红", run_cmd(args.cmd, args.timeout)):
result = "fail(撤销后仍绿=断言没钉在该改动上,判缺漏)"
else:
apply_rev("HEAD", files)
if not step("③ 还原复绿", "绿", run_cmd(args.cmd, args.timeout)):
result = "fail(还原后未复绿——还原面有残留,查工作区)"
finally:
apply_rev("HEAD", files)
except subprocess.TimeoutExpired:
result = "fail(测试命令超时)"
finally:
# 兜底还原:无论走到哪一步,退出前把对象文件恢复到 HEAD 态
apply_rev("HEAD", files)
residue = run(["-c", "core.quotepath=false", "status", "--porcelain", "--"] + files)
if residue:
result = "fail(退出前工作区仍有对象文件残留改动,手工核对)"
print("─" * 56)
print("[撤销证明] 结论:%s" % result)
if args.out:
with open(args.out, "w", encoding="utf-8") as f:
json.dump({"cmd": args.cmd, "base": base, "files": files,
"steps": steps, "result": result}, f, ensure_ascii=False, indent=1)
print("[撤销证明] 回执已落 %s" % args.out)
sys.exit(0 if result == "pass" else 1)
if __name__ == "__main__":
main()