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:
@@ -66,7 +66,7 @@ def tracked_files():
|
||||
files = []
|
||||
for root, dirs, names in os.walk(REPO):
|
||||
dirs[:] = [d for d in dirs if d not in
|
||||
(".git", ".venv", "build", ".dsh-worktrees")];
|
||||
(".git", ".venv", "build", ".dsh-worktrees", ".agents")];
|
||||
for n in names:
|
||||
files.append(os.path.relpath(os.path.join(root, n), REPO).replace(os.sep, "/"))
|
||||
return files
|
||||
|
||||
+154
-2
@@ -61,6 +61,7 @@
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import importlib
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
@@ -343,6 +344,103 @@ def _script_self_resolves(script_rel):
|
||||
r"re-?exec|ensure_python", text))
|
||||
|
||||
|
||||
def _hook_launcher_entries():
|
||||
"""扫 .pre-commit-config.yaml,返回 [(hook_id, script_path)]。
|
||||
|
||||
收 language: system 且 entry 以 scripts/gate_python.py 开头的条目——解耦之后本仓 44 条
|
||||
都是这个形态:配置里不出现任何解释器路径,由启动器在运行时解析(见
|
||||
docs/环境与部署/本地工具路径环境变量方案.md §5.2)。
|
||||
"""
|
||||
cfg = REPO_ROOT / ".pre-commit-config.yaml"
|
||||
if not cfg.is_file():
|
||||
return []
|
||||
out = []
|
||||
cur_id = cur_entry = cur_lang = None
|
||||
for raw in cfg.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
s = raw.strip()
|
||||
if s.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^-\s+id:\s*(\S+)", s)
|
||||
if m:
|
||||
cur_id, cur_entry, cur_lang = m.group(1), None, None
|
||||
continue
|
||||
m = re.match(r"^entry:\s*(.+)$", s)
|
||||
if m:
|
||||
cur_entry = m.group(1).strip()
|
||||
continue
|
||||
m = re.match(r"^language:\s*(\S+)", s)
|
||||
if m:
|
||||
cur_lang = m.group(1)
|
||||
if cur_lang == "system" and cur_entry and cur_id:
|
||||
toks = cur_entry.split()
|
||||
if len(toks) >= 2 and toks[0].replace("\\", "/").endswith("scripts/gate_python.py"):
|
||||
out.append((cur_id, toks[1].strip("'\"")))
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _launcher_resolves():
|
||||
"""让启动器自己报出它选中的解释器,返回 (说明, 路径);解析失败返回 (None, None)。
|
||||
|
||||
直接 import 启动器模块调 candidates(),而不是跑一条子进程:启动器本来就要求纯标准库
|
||||
单文件,import 是它最轻的用法,也让「预检看到的解释器」与「钩子实际用的解释器」共用一份逻辑。
|
||||
"""
|
||||
launcher = REPO_ROOT / "scripts" / "gate_python.py"
|
||||
if not launcher.is_file():
|
||||
return None, None
|
||||
sys.path.insert(0, str(launcher.parent))
|
||||
try:
|
||||
mod = importlib.import_module("gate_python")
|
||||
picked = mod.pick(mod.repo_root())
|
||||
except Exception:
|
||||
return None, None
|
||||
finally:
|
||||
sys.path.pop(0)
|
||||
if not picked:
|
||||
return None, None
|
||||
return picked
|
||||
|
||||
|
||||
def _hook_pinned_python_entries():
|
||||
"""扫 .pre-commit-config.yaml,返回 [(hook_id, script_path, python_exe)]。
|
||||
|
||||
收 language: system 且 entry 以显式 python 可执行文件开头的条目——统一到仓库 .venv
|
||||
之后本仓 44 条都是这个形态:入口解释器由 entry 写死,与 PATH 首位无关。
|
||||
entry 形如 '"D:\\...\\python.exe" scripts/check_x.py'(双引号包住含空格的路径),
|
||||
取不到脚本时只报入口本身的问题。扫描逻辑与 _hook_bare_python_entries 同源。
|
||||
"""
|
||||
cfg = REPO_ROOT / ".pre-commit-config.yaml"
|
||||
if not cfg.is_file():
|
||||
return []
|
||||
out = []
|
||||
cur_id = cur_entry = cur_lang = None
|
||||
for raw in cfg.read_text(encoding="utf-8", errors="replace").splitlines():
|
||||
s = raw.strip()
|
||||
if s.startswith("#"):
|
||||
continue
|
||||
m = re.match(r"^-\s+id:\s*(\S+)", s)
|
||||
if m:
|
||||
cur_id, cur_entry, cur_lang = m.group(1), None, None
|
||||
continue
|
||||
m = re.match(r"^entry:\s*(.+)$", s)
|
||||
if m:
|
||||
cur_entry = m.group(1).strip()
|
||||
continue
|
||||
m = re.match(r"^language:\s*(\S+)", s)
|
||||
if m:
|
||||
cur_lang = m.group(1)
|
||||
if cur_lang == "system" and cur_entry and cur_id:
|
||||
toks = cur_entry.split()
|
||||
if not toks:
|
||||
continue
|
||||
exe = toks[0].strip("'\"")
|
||||
if re.search(r"python(3)?(\.exe)?$", exe, re.I) and (os.sep in exe or "/" in exe):
|
||||
script = toks[1].strip("'\"") if len(toks) > 1 else ""
|
||||
out.append((cur_id, script, exe))
|
||||
continue
|
||||
return out
|
||||
|
||||
|
||||
def _missing_modules(python_path, modules):
|
||||
"""用指定解释器一次性试导入这批模块,返回它缺的那些(解释器本身跑不起来返回 None)。"""
|
||||
if not modules:
|
||||
@@ -396,9 +494,32 @@ def run_hook_preflight():
|
||||
except (OSError, subprocess.TimeoutExpired):
|
||||
ver = "?"
|
||||
entries = _hook_bare_python_entries()
|
||||
pinned = _hook_pinned_python_entries()
|
||||
launched = _hook_launcher_entries()
|
||||
need_all = set()
|
||||
rows = []
|
||||
lazy_all = {}
|
||||
pinned_bad = []
|
||||
pin_kind = None
|
||||
if pinned:
|
||||
exes = sorted({os.path.normcase(e) for _h, _s, e in pinned})
|
||||
pin_kind = exes[0] if len(exes) == 1 else "<%d 个不同解释器>" % len(exes)
|
||||
if len(exes) == 1 and exes[0] != os.path.normcase(venv):
|
||||
print("[WARN] entry 里写死的解释器 = {}".format(exes[0]))
|
||||
print(" 而仓库 .venv = {} —— 两者不一致:这批钩子跑的不是这个 .venv".format(venv))
|
||||
pinned_bad = [(h, s, e) for h, s, e in pinned if not os.path.isfile(e)]
|
||||
for hook_id, script, exe in pinned:
|
||||
if not script:
|
||||
continue
|
||||
forced, hinted, _lazy = _script_third_party(script)
|
||||
need = set(forced.keys()) | set(hinted.keys())
|
||||
if not need:
|
||||
continue
|
||||
lack = _missing_modules(exe, need)
|
||||
if lack is None:
|
||||
pinned_bad.append((hook_id, script, "{} 探测失败".format(exe)))
|
||||
elif lack:
|
||||
pinned_bad.append((hook_id, script, "{} 缺 {}".format(exe, ", ".join(sorted(lack)))))
|
||||
for hook_id, script in entries:
|
||||
forced, hinted, lazy = _script_third_party(script)
|
||||
if forced or hinted:
|
||||
@@ -411,8 +532,39 @@ def run_hook_preflight():
|
||||
if missing is None:
|
||||
print("[ERROR] 探测解释器失败:{}(跑不起来,预检无法完成)".format(path_first))
|
||||
return 2
|
||||
print("[INFO] language: system 用裸 python 的条目:{} 条;其 entry 解释器 = {} {}(PATH 首位)".format(
|
||||
len(entries), path_first, ver))
|
||||
if launched:
|
||||
label, resolved = _launcher_resolves()
|
||||
if resolved is None:
|
||||
print("[WARN] scripts/gate_python.py 存在 {} 条钩子在用,但启动器解析不出可用解释器".format(
|
||||
len(launched)))
|
||||
else:
|
||||
same = os.path.normcase(resolved) == os.path.normcase(venv)
|
||||
print("[INFO] 经启动器找解释器的钩子:{} 条 -> {}({}){}".format(
|
||||
len(launched), resolved, label,
|
||||
"" if same else " ⚠ 与仓库 .venv 不一致"))
|
||||
if not same:
|
||||
print(" 这批钩子跑的不是仓库 .venv:先确认这是有意的(DTU_GATE_PYTHON 覆盖?)还是 .venv 缺失")
|
||||
if pinned:
|
||||
print("[INFO] entry 写死了解释器的钩子:{} 条,都指向 {}(入口解释器与 PATH 无关)".format(
|
||||
len(pinned), pin_kind))
|
||||
if entries:
|
||||
print("[INFO] language: system 用裸 python 的条目:{} 条;其 entry 解释器 = {} {}(PATH 首位)".format(
|
||||
len(entries), path_first, ver))
|
||||
elif launched:
|
||||
print("[INFO] language: system 用裸 python 的条目:0 条(全部经启动器或写死解释器)")
|
||||
else:
|
||||
print("[INFO] language: system 用裸 python 的条目:0 条(全部显式指定了解释器)")
|
||||
if pinned_bad:
|
||||
print("")
|
||||
print("[ERROR] 以下钩子的入口解释器不可用,跑到它时必然失败:")
|
||||
for hook_id, script, why in pinned_bad:
|
||||
print(" - {}:{} -> {}".format(hook_id, script, why))
|
||||
print(" 修法:确认 .venv 存在(重建见 docs/环境与部署/本地工具路径环境变量方案.md §5.4),"
|
||||
"或把该 entry 改回裸 python(前提见同文 §5.2)。")
|
||||
return 2
|
||||
if not entries:
|
||||
print("[OK] 预检通过:钩子入口解释器全部可用,且没有裸 python 条目")
|
||||
return 0 if not launched or _launcher_resolves()[1] else 2
|
||||
print("[INFO] 这些条目累计需要的第三方包:{};该解释器缺:{}".format(
|
||||
", ".join(sorted(need_all)) or "(无,全是纯标准库)", ", ".join(sorted(missing)) or "(无)"))
|
||||
bad = [(h, s, f) for (h, s, f, _h2, resolved) in rows
|
||||
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user