chore(gate): 门禁钩子改走 gate_python 启动器,worktree 统一插件模型

门禁解释器解耦
  44 条 language: system 钩子此前把解释器写成仓库检出的绝对路径,换机、
  换克隆目录都要重写。改为 entry 只写 scripts/gate_python.py + 脚本名,
  由启动器按候选链解析:$DTU_GATE_PYTHON > <仓库>/.venv >
  <仓库>/.gate/python > PATH。启动器用 execve 替换自身,并把选中的
  解释器经 DTU_GATE_PYTHON 传给子进程,保证脚本内再调 Python 时一致。
  实测 pre-commit 4.6.2 不展开 ${VAR};language: script 的 shebang 只做
  纯 PATH 改写,会把决定权交给 PATH 首个 python,故不采用。
  ci_local.py 预检识别启动器并报出解析结果。

并行工作树统一
  task-worktree 新增 bench-gate(强制台架验收)与 merge(编排式合并),
  删除 new/abort,done 标 DEPRECATED;台架通过记录改存
  .agents/worktrees/.bench-pass-log。keil_build.py 的 plugin_tree_owner
  识别 .agents/worktrees/<name> 并返回 pw:<name>。
  check_doc_links / gen_refgraph / check_layer_deps 的扫描排除面补上
  .agents;并行任务工作树方案重写为单一模型(7 节),取消工作树数量上限,
  删历史实测记录节,两处 §7.3 悬空引用改为按语义引用。

文档与约定
  本地工具路径环境变量方案 §5 重写为四节:谁跑在哪个 Python 上、写新钩子、
  预检对号入座、动环境五步。文档规范的变更记录例外名单由 5 篇减为 4 篇
  (本篇实际没有该节)。AGENTS.md 补委派验收口径三条。

验证:pre_commit run --all-files 48 钩子全 Passed;ci_local.py
--preflight-only 报 44 条经启动器 -> 仓库 .venv;keil-build 0 errors
0 warnings;check_doc_links --sections-strict 全通过。
This commit is contained in:
2026-11-15 19:39:56 +08:00
parent 9f68b7bc03
commit 96a3d15efc
17 changed files with 826 additions and 720 deletions
+113
View File
@@ -0,0 +1,113 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
"""门禁钩子的统一启动器:替钩子找到该用的解释器,再原样转交脚本。
被提交的 .pre-commit-config.yaml 只写 `entry: scripts/gate_python.py <脚本> [参数...]`,
不出现任何机器相关的解释器路径——换机、换克隆目录、换 PATH 首位都不必改配置。
解释器按下面的顺序找,第一个可用的胜出:
1. $DTU_GATE_PYTHON 显式覆盖(临时切版本、排查「到底跑在谁身上」)
2. <仓库>/.venv/Scripts/python.exe 约定的仓库内环境(requirements.txt 装在这里)
3. <仓库>/.gate/python/python.exe 便携解释器(可选,本仓当前未提供)
4. PATH 上的 python 最后兜底
约定纪律(改动前先读 docs/环境与部署/本地工具路径环境变量方案.md §5.1):
- 纯标准库、单文件、不 import 仓库内任何模块——它运行在「解释器还不知道是谁」的时刻;
- 解析出的解释器用环境变量 DTU_GATE_PYTHON 传给子进程,脚本内部再调 Python 时能与钩子保持一致,
否则「门禁用的版本」和「生成器用的版本」会分叉。
退出码:0 表示被调脚本自己退出为 0;启动器自身失败一律 2(与 pre-commit「环境不可用」口径一致,
见 docs/草稿约定:2 = 配置/环境问题,1 = 检查结论不通过)。
"""
from __future__ import annotations
import os
import sys
LAUNCHER_NAME = "gate_python.py"
def repo_root() -> str:
"""启动器所在仓库根(本文件位于 <仓库>/scripts/ 下)。"""
return os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def candidates(root: str) -> list[tuple[str, str]]:
"""返回 [(说明, 解释器路径)],顺序即优先级。"""
items: list[tuple[str, str]] = []
override = os.environ.get("DTU_GATE_PYTHON")
if override:
items.append(("$DTU_GATE_PYTHON", override))
items.append(("仓库 .venv", os.path.join(root, ".venv", "Scripts", "python.exe")))
items.append(("便携解释器 .gate", os.path.join(root, ".gate", "python", "python.exe")))
path_python = path_lookup("python")
if path_python:
items.append(("PATH", path_python))
return items
def path_lookup(name: str) -> str:
"""按 Windows 语义在 PATH 里找可执行文件(含 PATHEXT 展开)。"""
exts = os.environ.get("PATHEXT", ".COM;.EXE;.BAT;.CMD").split(os.pathsep)
for directory in os.environ.get("PATH", "").split(os.pathsep):
if not directory:
continue
for ext in [""] + exts:
candidate = os.path.join(directory, name + ext.lower())
if os.path.isfile(candidate):
return candidate
return ""
def pick(root: str) -> tuple[str, str] | None:
for label, exe in candidates(root):
if exe and os.path.isfile(exe):
return label, exe
return None
def fail(message: str, root: str) -> int:
print(f"[gate_python] {message}", file=sys.stderr)
print("[gate_python] 解释器候选检查结果:", file=sys.stderr)
for label, exe in candidates(root):
state = "可用" if exe and os.path.isfile(exe) else "不存在"
print(f" {label:<16} {exe or '<未设置>'} [{state}]", file=sys.stderr)
print(
"[gate_python] 修复:按下面任一方式让第 2 个候选就位——\n"
" python -m venv .venv && .venv/Scripts/python.exe -m pip install -r requirements.txt\n"
" 或临时指定:set DTU_GATE_PYTHON=<某个装了依赖的 python.exe>\n"
" 详细规程 → docs/环境与部署/本地工具路径环境变量方案.md §5",
file=sys.stderr,
)
return 2
def main(argv: list[str]) -> int:
root = repo_root()
if len(argv) < 2:
return fail("缺少目标脚本参数(用法:gate_python.py <脚本> [参数...])", root)
target = argv[1]
if not os.path.isabs(target):
target = os.path.join(root, target)
if not os.path.isfile(target):
return fail(f"目标脚本不存在:{argv[1]}", root)
chosen = pick(root)
if chosen is None:
return fail("找不到可用的解释器", root)
label, exe = chosen
if os.environ.get("DTU_GATE_VERBOSE"):
print(f"[gate_python] {label} -> {exe}", file=sys.stderr)
env = dict(os.environ)
env["DTU_GATE_PYTHON"] = exe
child_argv = [exe, target, *argv[2:]]
os.execve(exe, child_argv, env)
return 0 # 不会走到这里:execve 成功即替换进程
if __name__ == "__main__":
sys.exit(main(sys.argv))