200 lines
8.7 KiB
Python
200 lines
8.7 KiB
Python
#!/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()
|