#!/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 形分支做数卡校验)。原分支史保留在 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 形)") 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 形——非迭代批免重塑" % 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()