Files
gplan-2/.zcode/hooks/worktree-guard.sh
T
DKChan a4fba15eac feat(闸收敛批四): 合回闸入库(worktree-guard 拦 merge 前校验全量回执)
修自己的流程性错误:合回闸代码此前只改了主工作区的 .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 拦。
2026-10-15 22:35:06 +08:00

201 lines
8.9 KiB
Bash
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 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 <worktree> 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")
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