From a4fba15eac2fe846f9dec78a06b57ea0ed1460af Mon Sep 17 00:00:00 2001 From: DKChan <524912269@qq.com> Date: Thu, 15 Oct 2026 22:35:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E9=97=B8=E6=94=B6=E6=95=9B=E6=89=B9?= =?UTF-8?q?=E5=9B=9B):=20=E5=90=88=E5=9B=9E=E9=97=B8=E5=85=A5=E5=BA=93?= =?UTF-8?q?=EF=BC=88worktree-guard=20=E6=8B=A6=20merge=20=E5=89=8D?= =?UTF-8?q?=E6=A0=A1=E9=AA=8C=E5=85=A8=E9=87=8F=E5=9B=9E=E6=89=A7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修自己的流程性错误:合回闸代码此前只改了主工作区的 .zcode/hooks/worktree-guard.sh, 从未提交——.zcode/ 是 worktree 铁律的豁免直改面,但豁免直改不等于豁免提交, 改完必须 commit 进库。结果是 master 上的 hook 仍是旧版(只拦 commit 不拦 merge), 闸机制实际未生效。 Bash 分支加第三路:merge/rebase/cherry-pick 进 master 前按目标分支反查 worktree 回执(git worktree list --porcelain 定位,兜底扫 sibling 目录), 校验三态——回执 commit == 分支 tip、非 --quick 档、无新增红项;任一不满足 exit 2。 八态实测:匹配回执放行 / commit 错拦 / quick 档拦 / 含红项拦 / 无回执拦 / worktree 内 commit 放行 / 只读命令放行 / rebase 拦。 --- .zcode/hooks/worktree-guard.sh | 100 +++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 6 deletions(-) diff --git a/.zcode/hooks/worktree-guard.sh b/.zcode/hooks/worktree-guard.sh index 60f0180e..bc7b5aac 100644 --- a/.zcode/hooks/worktree-guard.sh +++ b/.zcode/hooks/worktree-guard.sh @@ -1,6 +1,8 @@ #!/usr/bin/env bash -# worktree 铁律守卫(PreToolUse,2026-09-19 台账 87 用户立规) -# 拦两路:①Edit/Write 类落在主工作区(白名单外)且主工作区检出 master/main ②Bash 在主工作区 git commit +# worktree 铁律守卫(PreToolUse,2026-09-19 台账 87 用户立规;2026-10-15 扩合回闸) +# 拦两路:①Edit/Write 类落在主工作区(白名单外)且主工作区检出 master/main +# ②Bash 在主工作区 git commit +# ③Bash 在主工作区 git merge/rebase/cherry-pick 进 master 且无 tools/run_all_gates.sh 全量回执 # 退出码 2 = 拦截(stderr 回传模型);0 = 放行;任何解析异常一律放行(守卫是兜底,不能挡死会话收口) proj="${ZCODE_PROJECT_DIR:-$PWD}" tmp="$(mktemp)" @@ -71,6 +73,7 @@ elif tool == "Bash": cmd = ti.get("command", "") or "" toks = cmd.split() has_commit = False + has_merge = False for i, t in enumerate(toks): if t.rstrip(';') != 'git': continue @@ -80,6 +83,9 @@ elif tool == "Bash": if u == "commit": has_commit = True break + if u in ("merge", "rebase", "cherry-pick"): + has_merge = True + break if u.startswith("-"): if u in ("-C", "--git-dir", "-c") and j + 1 < len(toks): j += 2 @@ -87,8 +93,6 @@ elif tool == "Bash": j += 1 continue break - if not has_commit: - sys.exit(0) # 主工作区判定:cwd 在内,或命令文本里显式指到主工作区路径(不含 worktree 兄弟目录) norm = cmd.replace("\\", "/").lower() ref_main = re.search(r"/d/code/g-plan2(?![\w-])", norm) or re.search(r"d:/code/g-plan2(?![\w-])", norm) @@ -97,13 +101,97 @@ elif tool == "Bash": # 命令文本以路径形态(斜杠前缀)显式指向兄弟 worktree 且不含主区路径时放行——显式主区 # 路径引用优先走下方拦截判定(ref_main 命中即不豁免,防「主区提交夹带 worktree 提及」绕行)。 ref_wt = re.search(r"/g-plan2-[\w-]+", norm) - if ref_wt and not ref_main: + sibling = bool(ref_wt) and not ref_main + on_trunk = trunk_branch(proj) in TRUNK + in_main = (in_dir(cwd, proj) or bool(ref_main)) and on_trunk + if not in_main or sibling: sys.exit(0) - if (in_dir(cwd, proj) or ref_main) and trunk_branch(proj) in TRUNK: + if has_commit: sys.stderr.write( "[worktree 铁律] 主工作区(master)禁止直接 git commit——提交在任务 worktree 分支上完成," "收尾在主工作区 git merge --no-ff 合回 master。\n") sys.exit(2) + # 合回闸(2026-10-15 用户令「迭代完成前必须全量跑一遍所有测试」):merge/rebase/cherry-pick + # 进 master 前校验 tools/run_all_gates.sh 的回执——回执缺失、quick 模式、或含红项即拦。 + # 起因:iter27 台账只记 probe --click 未记 --full,t17_defer_probe 红了整个迭代无人发现, + # 根因是闸清单散落三份文档彼此不一致且无机器校验(清单唯一事实源已收敛为 run_all_gates.sh)。 + if has_merge: + # 回执在「跑闸的那个 worktree」里(闸在 worktree 跑、回执随 worktree 落地), + # 主工作区自己那份是上一次合回前的旧件。故按 merge 的目标分支反查 worktree 路径, + # 找不到就在所有 sibling worktree 里找「回执 commit == 分支 tip」的那一份。 + def git_out(*args, **kw): + return subprocess.run(["git", "-C", proj] + list(args), + capture_output=True, text=True, timeout=kw.get("timeout", 8)).stdout.strip() + + merge_branch = None + for t in toks: + u = t.rstrip(';') + if u and not u.startswith("-") and u not in ("git", "merge", "rebase", "cherry-pick"): + merge_branch = u + break + tip = git_out("rev-parse", merge_branch) if merge_branch else "" + if not tip: + tip = git_out("rev-parse", "HEAD") + + cands = [] + if merge_branch: + try: + wl = git_out("worktree", "list", "--porcelain") + blocks = [b for b in wl.split("\n\n") if b.strip()] + for b in blocks: + if ("branch refs/heads/%s" % merge_branch) in b: + head_line = [l for l in b.split("\n") if l.startswith("worktree ")] + if head_line: + cands.append(os.path.abspath(winpath(head_line[0][9:]))) + except Exception: + pass + # 兜底:主区 + 所有 sibling worktree + cands.append(proj) + parent = os.path.dirname(proj) + try: + for name in os.listdir(parent): + p = os.path.join(parent, name) + if os.path.isdir(os.path.join(p, ".git")) or os.path.isfile(os.path.join(p, ".git")): + cands.append(os.path.abspath(p)) + except Exception: + pass + + receipt = None + for base in cands: + p = os.path.join(base, "game", "tests", ".gates", "receipt.json") + if not os.path.exists(p): + continue + try: + import json + with open(p, encoding="utf-8") as f: + r = json.load(f) + except Exception: + continue + # 只认「回执 commit == 将要合入的分支 tip」的那一份 + if r.get("commit") == tip: + receipt = (base, r) + break + why = None + if receipt is None: + found = any(os.path.exists(os.path.join(b, "game", "tests", ".gates", "receipt.json")) + for b in cands) + why = ("无与分支 %s(tip %s)匹配的回执%s" + % (merge_branch or "?", tip[:8], + "(worktree 内存在回执但 commit 对不上=闸跑完后代码又变了)" if found else "")) + else: + base, r = receipt + if r.get("quick_mode"): + why = "回执是 --quick 模式(跳过渲染闸/静态扫描),收口出库必须跑全量" + elif not r.get("all_green"): + reds = [g["name"] for g in r.get("gates", []) if g.get("exit") != 0] + why = "回执含新增红项:" + ("、".join(reds) if reds else "见 receipt.json") + if why: + sys.stderr.write( + "[合回闸] 拦截 %s 进 master——%s。\n" + "在任务 worktree 内跑全量闸:tools/run_all_gates.sh(不带 --quick)," + "跑完回执自动写入该 worktree 的 game/tests/.gates/receipt.json,再合。\n" + % (cmd.split()[0], why)) + sys.exit(2) sys.exit(0) PY