113 lines
4.5 KiB
Bash
113 lines
4.5 KiB
Bash
#!/usr/bin/env bash
|
||
# worktree 铁律守卫(PreToolUse,2026-09-19 台账 87 用户立规)
|
||
# 拦两路:①Edit/Write 类落在主工作区(白名单外)且主工作区检出 master/main ②Bash 在主工作区 git commit
|
||
# 退出码 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
|
||
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.startswith("-"):
|
||
if u in ("-C", "--git-dir", "-c") and j + 1 < len(toks):
|
||
j += 2
|
||
continue
|
||
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)
|
||
# 兄弟 worktree 豁免(2026-10-03 用户授权修复):harness 上报 cwd 恒为项目根,原判定把
|
||
# git -C <worktree> commit 一并误拦,与「提交在任务 worktree 分支上完成」的放行语义相抵;
|
||
# 命令文本以路径形态(斜杠前缀)显式指向兄弟 worktree 且不含主区路径时放行——显式主区
|
||
# 路径引用优先走下方拦截判定(ref_main 命中即不豁免,防「主区提交夹带 worktree 提及」绕行)。
|
||
ref_wt = re.search(r"/g-plan2-[\w-]+", norm)
|
||
if ref_wt and not ref_main:
|
||
sys.exit(0)
|
||
if (in_dir(cwd, proj) or ref_main) and trunk_branch(proj) in TRUNK:
|
||
sys.stderr.write(
|
||
"[worktree 铁律] 主工作区(master)禁止直接 git commit——提交在任务 worktree 分支上完成,"
|
||
"收尾在主工作区 git merge --no-ff 合回 master。\n")
|
||
sys.exit(2)
|
||
|
||
sys.exit(0)
|
||
PY
|
||
rc=$?
|
||
rm -f "$tmp"
|
||
exit $rc
|