Files
gplan-2/tools/reshape_cards.py
T

200 lines
8.7 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
"""一卡一提交上岸套件的重塑工具:把迭代分支的线性提交史按卡末态快照压成一卡一笔。
分支内自由提交(T 段、修复、文书随做随提),master 台账只收每张卡一笔提交(可选追加
一笔纯文档收尾)。本工具在批末「并 master 之后、跑闸之前」执行:以每张卡最后一笔提交
的文件树建新提交、逐卡链在 master 尖上;自带「重塑前后树逐字节一致」断言与 backup
分支,重塑只改账面、不改变任何文件内容。
用法:
python tools/reshape_cards.py --branch iter40 \\
--card 卡1=<该卡最后一笔提交> --card 卡2=<...> [--tail <收尾最后一笔>] [--dry-run]
可选 --msg 卡1="一行结论"(多条)补提交说明;缺省说明=卡号本身。
规则:
1. master 尖必须是分支祖先(批末先并 master;不满足即拒绝,防快照回退 master 内容)。
2. 各卡边界必须是分支第一-parent 链上的提交、按链序递增;最后一笔卡边界或收尾边界
必须恰为分支尖——否则存在未归卡提交,拒绝执行并列出。
3. 收尾段的改动必须全是文档面(game/ 下非 .md 之外的文件);含代码面先归卡。
4. 分支工作区必须干净(脏改动会被快照丢弃,拒绝执行)。
非迭代工程小批免重塑(合回闸只对 iter<N> 形分支做数卡校验)。原分支史保留在
backup/<分支名>-<时间戳> 分支上,可随时回溯。
"""
from __future__ import annotations
import argparse
import re
import subprocess
import sys
import time
from pathlib import Path
REPO = Path(__file__).resolve().parent.parent
def run(args: list[str], check: bool = True, inp: str | None = None) -> str:
r = subprocess.run(
["git", "-C", str(REPO)] + args,
capture_output=True, text=True, encoding="utf-8", errors="replace",
input=inp, 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 is_doc_face(p: str) -> bool:
# 代码面=game/ 下非 .md 的行为载体;收尾段只许文档面。
return not (p.startswith("game/") and not p.lower().endswith(".md"))
def worktree_of(branch: str) -> str | None:
wl = run(["worktree", "list", "--porcelain"])
for block in wl.split("\n\n"):
if ("branch refs/heads/%s" % branch) in block:
for line in block.splitlines():
if line.startswith("worktree "):
return line[9:].strip()
return None
def die(msg: str) -> None:
sys.stderr.write("[重塑] 拒绝执行——%s\n" % msg)
sys.exit(1)
def main() -> None:
ap = argparse.ArgumentParser(description="按卡末态快照把迭代分支重塑成一卡一笔")
ap.add_argument("--branch", required=True, help="迭代分支名(iter<N> 形)")
ap.add_argument("--card", action="append", required=True, metavar="卡1=<提交>",
help="该卡在分支上的最后一笔提交,按卡序逐卡给出")
ap.add_argument("--tail", metavar="<提交>", default=None,
help="可选收尾段(纯文档面)的最后一笔提交")
ap.add_argument("--msg", action="append", default=[], metavar='卡1="一行结论"',
help="各卡/收尾的提交说明,可多条")
ap.add_argument("--dry-run", action="store_true", help="只打印重塑计划,不动任何引用")
args = ap.parse_args()
try:
sys.stdout.reconfigure(encoding="utf-8", errors="replace")
sys.stderr.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
branch = args.branch
if not re.fullmatch(r"iter\d+", branch):
die("分支名 %s 不是 iter<N> 形——非迭代批免重塑" % branch)
master_tip = run(["rev-parse", "master"])
old_tip = run(["rev-parse", branch])
rc = subprocess.run(["git", "-C", str(REPO), "merge-base", "--is-ancestor",
master_tip, old_tip], capture_output=True).returncode
if rc != 0:
die("master 尖 %s 不是分支 %s 的祖先——先回分支并 master(git merge master)再重塑,"
"否则快照会把 master 新内容回退掉" % (master_tip[:8], branch))
wt = worktree_of(branch)
if wt:
st = run(["-C", wt, "-c", "core.quotepath=false", "status", "--porcelain"])
if st:
die("分支 %s 的工作区(%s)有未提交改动——快照取自提交树,脏改动会被丢弃;"
"先提交或储藏再重塑\n%s" % (branch, wt, st))
# 分支未检出时引用移动不影响任何工作区,无需检查脏树
base = run(["merge-base", "master", branch])
chain = [x for x in run(["rev-list", "--first-parent", "%s..%s" % (base, branch)]).splitlines() if x]
chain.reverse() # 老 → 新
if not chain:
die("分支 %s 相对 master 无自有提交,无可重塑" % branch)
idx = {sha: i for i, sha in enumerate(chain)}
msgs: dict[str, str] = {}
for m in args.msg:
k, _, v = m.partition("=")
msgs[k.strip()] = v.strip()
cards: list[tuple[str, int]] = []
for c in args.card:
label, _, sha = c.partition("=")
full = run(["rev-parse", sha.strip()])
if full not in idx:
die("卡边界 %s(%s)不在分支 %s 相对 master 的第一-parent 链上" % (label, sha, branch))
cards.append((label.strip(), idx[full]))
cards.sort(key=lambda x: x[1])
if len({i for _, i in cards}) != len(cards):
die("卡边界重复指向同一笔提交")
tail_i: int | None = None
if args.tail:
full = run(["rev-parse", args.tail.strip()])
if full not in idx:
die("收尾边界 %s 不在分支第一-parent 链上" % args.tail)
tail_i = idx[full]
if cards and tail_i <= cards[-1][1]:
die("收尾边界必须排在最后一笔卡边界之后")
last = tail_i if tail_i is not None else (cards[-1][1] if cards else -1)
if last != len(chain) - 1:
rest = chain[last + 1:]
die("最后一笔边界之后还有 %d 笔未归卡提交——归入所属卡或收尾后重试:\n %s"
% (len(rest), "\n ".join(run(["log", "--no-walk", "--format=%h %s", s]) for s in rest[:8])))
# 收尾段只许文档面
if tail_i is not None:
prev = chain[cards[-1][1]] if cards else base
files = run(["-c", "core.quotepath=false", "diff", "--name-only", prev, chain[tail_i]]).splitlines()
bad = [f for f in files if not is_doc_face(f.strip())]
if bad:
die("收尾段含代码面改动(应归卡):\n " + "\n ".join(bad[:10]))
# 建新链:master 尖起,逐卡快照
plan: list[tuple[str, str, str]] = [] # (说明, 边界 sha, 新提交占位)
segs: list[tuple[str, int]] = cards + ([("收尾", tail_i)] if tail_i is not None else [])
parent = master_tip
new_shas: list[str] = []
n_match = re.fullmatch(r"iter(\d+)", branch)
itn = n_match.group(1) if n_match else branch
for k, (label, i) in enumerate(segs, start=1):
boundary = chain[i]
tree = run(["rev-parse", "%s^{tree}" % boundary])
if label == "收尾":
subject = "iter%s-收尾" % itn
else:
subject = "iter%s-卡%d" % (itn, k)
if label in msgs:
subject += ":" + msgs[label]
cp = run(["commit-tree", tree, "-p", parent], inp=subject + "\n")
new_shas.append(cp)
parent = cp
plan.append((subject, boundary, cp))
new_tip = parent
diff = run(["diff", "--stat", old_tip, new_tip])
if diff:
die("安全断言失败:重塑前后树不一致(不应发生,请带输出报缺陷)\n%s" % diff)
print("[重塑] 计划(分支 %s,原尖 %s):" % (branch, old_tip[:8]))
for subject, boundary, cp in plan:
print(" %s ← %s" % (subject, run(["log", "--no-walk", "--format=%h %s", boundary])[:70]))
print("[重塑] 树一致断言通过;backup 分支将指向原尖 %s" % old_tip[:8])
if args.dry_run:
print("[重塑] --dry-run:未动任何引用。")
return
backup = "backup/%s-%s" % (branch, time.strftime("%Y%m%d-%H%M%S"))
run(["branch", backup, old_tip])
if wt:
run(["-C", wt, "reset", "--hard", new_tip])
else:
run(["update-ref", "refs/heads/%s" % branch, new_tip])
print("[重塑] 完成:分支 %s → %s(%d 笔卡提交);原史留 %s"
% (branch, new_tip[:8], len(cards), backup))
print("[重塑] 下一步:按本批改动域跑 tools/run_all_gates.sh,全绿后主工作区合回。")
if __name__ == "__main__":
main()