#!/usr/bin/env bash # 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)" cat > "$tmp" projw="$(cygpath -w "$proj" 2>/dev/null || echo "$proj")" python - "$projw" "$(cygpath -w "$tmp" 2>/dev/null || echo "$tmp")" <<'PY' import json, os, re, subprocess, sys try: sys.stderr.reconfigure(encoding='utf-8', errors='replace') except Exception: pass def winpath(p): # MSYS 风格路径(/d/...)原生 Windows Python 不识别,统一转 Windows 形式 if isinstance(p, str) and re.match(r"^/[A-Za-z](/|$)", p): try: r = subprocess.run(["cygpath", "-w", p], capture_output=True, text=True, timeout=5) return r.stdout.strip() or p except Exception: return p return p proj = os.path.abspath(winpath(sys.argv[1])) try: with open(sys.argv[2], encoding='utf-8') as f: d = json.load(f) except Exception: sys.exit(0) tool = d.get("tool_name") or d.get("tool") or "" ti = d.get("tool_input") or {} cwd = os.path.abspath(winpath(d.get("cwd") or proj)) def in_dir(p, root): p = os.path.normcase(os.path.abspath(p)) root = os.path.normcase(os.path.abspath(root)) return p == root or p.startswith(root.rstrip('\\/') + os.sep) def trunk_branch(repo): try: r = subprocess.run(["git", "-C", repo, "branch", "--show-current"], capture_output=True, text=True, timeout=5) return r.stdout.strip() except Exception: return "" TRUNK = {"master", "main"} WHITELIST = [os.path.abspath(winpath(os.path.join(proj, "active.md"))), os.path.abspath(winpath(os.path.join(proj, ".zcode")))] if tool in ("Edit", "Write", "MultiEdit", "NotebookEdit"): fp = ti.get("file_path") or ti.get("notebook_path") or "" if not fp: sys.exit(0) fp = os.path.abspath(winpath(fp)) if not in_dir(fp, proj) or any(in_dir(fp, w) for w in WHITELIST): sys.exit(0) b = trunk_branch(proj) if b in TRUNK: sys.stderr.write( "[worktree 铁律] 主工作区(%s 分支)禁止直接改动:%s\n" "先建独立 worktree 再改:git worktree add ../g-plan2-<任务名> -b <任务分支>;" "收尾在主工作区 git merge --no-ff 合回。豁免面仅根目录 active.md 与 .zcode/。\n" % (b, fp)) sys.exit(2) 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 j = i + 1 while j < len(toks) and j - i <= 5: u = toks[j].rstrip(';') 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 continue j += 1 continue break # 主工作区判定: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) # 兄弟 worktree 豁免(2026-10-03 用户授权修复):harness 上报 cwd 恒为项目根,原判定把 # git -C commit 一并误拦,与「提交在任务 worktree 分支上完成」的放行语义相抵; # 命令文本以路径形态(斜杠前缀)显式指向兄弟 worktree 且不含主区路径时放行——显式主区 # 路径引用优先走下方拦截判定(ref_main 命中即不豁免,防「主区提交夹带 worktree 提及」绕行)。 ref_wt = re.search(r"/g-plan2-[\w-]+", norm) 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 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") # 纯文档合入豁免(2026-10-16 用户令「改了代码的合入才做测试验证,文档的合入不验」): # merge-base..分支尖 的改动不触碰 game/(游戏工程本体)=纯文档/开发工具面合入, # 跑游戏测试无意义,直接放行;触碰 game/ 的代码合入仍走下方回执校验。 if merge_branch: _base = git_out("merge-base", "HEAD", merge_branch) _touched_game = git_out("diff", "--name-only", _base, merge_branch, "--", "game/") if _base else "x" if not _touched_game: sys.exit(0) 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 rc=$? rm -f "$tmp" exit $rc