Files
STM32F4-Base/.dsh/skills/task-worktree/scripts/task_worktree.py
T
wandering1 4a4ad6bc5f chore(全仓): @build/@test 旧文档别名清退为技能调用名
现状口径:/keil-build 与 /serial-boot-test 是 DSH 会话的技能调用名,
不是 shell 命令;「名 + 命令行参数」的命令形态改为可执行体
run_boot_test.py 的真实调用。

- AGENTS.md:别名说明重写为「技能调用名不是 shell 命令」,两条目改
  「编译(/keil-build)」「串口回归(/serial-boot-test)」
- keil-build / serial-boot-test 两 skill 调用名与示例同步;task-worktree
  方案文档去掉 @build/@test 惯例从句;@task 保留(不在点名范围)
- 123 文件现状陈述全改;台架捕获、变更记录/时间线、rename 映射输入
  等 41 行历史证据保留旧名
- gen_memory_svg.py 6 处措辞随改,3 张 SVG 产物同批重跑
- 顺手补并行方案同行漏网计数 51→52 钩子

验证:提交期快钩子对 123 文件实跑全 Passed;check_doc_links --all
rc=0(3038 处、无法判定 0);check_mermaid --all rc=0;YAML 解析 52
钩子不变;skill-size rc=0;py_compile 全过;字节卫生零漂移。
2026-10-30 13:03:12 +08:00

1285 lines
56 KiB
Python
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 python3
"""task-worktree — 多任务并行开发的空间管理(git worktree 生命周期)
执行体 = 本脚本(纯标准库)。规格见 docs/环境与部署/并行任务工作树方案.md,
用法与铁律见同目录 SKILL.md。
退出码:0=成功 / 1=操作失败 / 2=前置或环境错误 / 3=用法错误
"""
import argparse
import json
import os
import re
import subprocess
import sys
import tempfile
import time
from pathlib import Path
# 输出统一 UTF-8,避免被控制台代码页(cp936)转码破坏
for _stream in (sys.stdout, sys.stderr):
try:
_stream.reconfigure(encoding="utf-8")
except Exception:
pass
EXIT_OK, EXIT_FAIL, EXIT_ENV, EXIT_USAGE = 0, 1, 2, 3
BRANCH_PREFIX = "task/"
MAX_CONCURRENT = 8
LOCK_NAMES = ("build", "bench")
# 按锁分设陈旧窗口(小时)——兜底窗口应与「操作真实时长」同量级:
# build:编译是秒~分钟级(keil_build.py 自持自释,持有窗口 ≈ UV4 运行时长,
# 上限 = --timeout,默认 600s);崩溃泄漏时应在数十分钟内即可接管 → 1h。
# bench:会话级(flash → /serial-boot-test → 探针,天然几十分钟~数小时)→ 保留 6h。
# 另:锁 payload 里持有者可用 `ttl` 字段自报窗口(见 lock_acquire(ttl=...)),
# 优先于这里的默认值——这样 build 锁的窗口能自动跟随其 --timeout。
LOCK_TTL_HOURS = {"build": 1.0, "bench": 6.0}
DEFAULT_LOCK_TTL_HOURS = 6.0
JUNCTION_SPECS = (
(".venv", "Python 环境"),
("simulator/SDL2", "模拟器 SDL2 SDK"),
)
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]*$")
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
class Fail(Exception):
"""操作失败(退出码 1)"""
class EnvError(Exception):
"""前置或环境错误(退出码 2)"""
# --------------------------------------------------------------------- 仓库定位
def _main_worktree_of(start: Path):
"""借 git 反查「主工作树」路径——脚本可能被从任务工作树里调用,不能只靠 __file__"""
for extra in (["--path-format=absolute"], []):
r = subprocess.run(
["git", "-C", str(start), "rev-parse", *extra, "--git-common-dir"],
capture_output=True, text=True, encoding="utf-8", errors="replace",
stdin=subprocess.DEVNULL,
)
if r.returncode == 0 and r.stdout.strip():
common = Path(r.stdout.strip())
if not common.is_absolute():
common = (start / common).resolve()
return common.parent if common.name == ".git" else common
return None
_SCRIPT_ROOT = Path(__file__).resolve().parents[4]
REPO_ROOT = _main_worktree_of(_SCRIPT_ROOT) or _SCRIPT_ROOT
WT_ROOT = Path(os.environ.get("DTU_WT_ROOT") or (REPO_ROOT.parent / "_wt"))
BASE_BRANCH = os.environ.get("DTU_TASK_BASE", "v2-on-ui")
STATE_DIR = WT_ROOT / ".state"
LOCKS_DIR = STATE_DIR / "locks"
# DSH 的 worktree 插件(包名 dsh-task-worktree;模型工具 worktree_create、人类命令
# /worktree)把任务树建在**仓库内**这个命名空间下,与本 skill 的仓外 _wt/ 刻意分开。
# .gitignore 里预置同名规则,正是为了让插件的 ensureIgnored() 见到就跳过、
# 不在会话中途往这份受控文件里追加一行。
PLUGIN_ROOT = REPO_ROOT / ".dsh-worktrees"
PLUGIN_NAMESPACE = "worktree" # 插件侧 WORKTREE_NAMESPACE:name 即分支尾段
PLUGIN_SUBDIR = PLUGIN_ROOT / PLUGIN_NAMESPACE
PLUGIN_LOCK_PREFIX = "pw" # 插件树在锁 payload 里的持有者前缀
# --------------------------------------------------------------------- 基础工具
def git(*args, cwd=None, check=True, env=None):
cmd = ["git"] + [str(a) for a in args]
full_env = dict(os.environ)
if env:
full_env.update(env)
r = subprocess.run(
cmd, cwd=str(cwd or REPO_ROOT), capture_output=True, text=True,
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL, env=full_env,
)
if check and r.returncode != 0:
raise Fail("git {} 失败(rc={})\n{}".format(
" ".join(cmd[1:]), r.returncode, (r.stdout + r.stderr).strip()))
return r
def git_out(*args, **kw):
return git(*args, **kw).stdout.strip()
def worktree_entries():
out = git_out("worktree", "list", "--porcelain")
entries, cur = [], None
for line in out.splitlines():
if line.startswith("worktree "):
if cur:
entries.append(cur)
cur = {"path": Path(line[len("worktree "):]), "branch": None}
elif cur is None:
continue
elif line.startswith("branch refs/heads/"):
cur["branch"] = line[len("branch refs/heads/"):]
if cur:
entries.append(cur)
return entries
def main_worktree() -> Path:
"""集成点(合并发生的地方)。默认 = 主工作树;可用环境变量覆盖,便于演练/测试。"""
override = os.environ.get("DTU_INTEGRATION_WORKTREE")
if override:
p = Path(override)
if not p.is_absolute():
p = (REPO_ROOT / p).resolve()
if not p.is_dir():
raise EnvError("DTU_INTEGRATION_WORKTREE 不存在:{}".format(p))
return p
entries = worktree_entries()
if not entries:
raise EnvError("无法解析 git worktree 列表")
return entries[0]["path"]
def venv_python(tree: Path = None) -> Path:
"""优先用该工作树自己的 .venv(任务树里是指向主树的联接),否则退回主工作树"""
for root in (tree, REPO_ROOT):
if root is None:
continue
p = Path(root) / ".venv" / "Scripts" / "python.exe"
if p.is_file():
return p
return Path(sys.executable)
def run_script(script: str, args, cwd: Path, label: str, tail: int = 40) -> bool:
"""跑项目脚本并回报。输出可能上千行,只回显尾部(结论与报错都在尾部)
⚠ 脚本必须取自 **cwd 那棵树**,不能固定用 REPO_ROOT:
项目脚本普遍以 `__file__` 反推仓库根(如 `scripts/ci_local.py`、
`scripts/check_registration.py`),若拿主树的脚本、却给集成树的 cwd,
脚本仍会去校验主树——门禁就测错了对象(2026-10-12 实测踩到:
报出主树才有的 iec104 未注册缺口,而集成树里根本没那些文件)。
"""
script_path = Path(cwd) / script
if not script_path.is_file():
script_path = REPO_ROOT / script
cmd = [str(venv_python(Path(cwd))), str(script_path)] + [str(x) for x in args]
r = subprocess.run(
cmd, cwd=str(cwd), capture_output=True, text=True,
encoding="utf-8", errors="replace", stdin=subprocess.DEVNULL,
)
out = (r.stdout + r.stderr).strip()
print("[{}] {}".format("ok" if r.returncode == 0 else "!!", label))
if out:
lines = out.splitlines()
if len(lines) > tail:
print(" …(前 {} 行略)".format(len(lines) - tail))
lines = lines[-tail:]
print(" " + "\n ".join(lines))
return r.returncode == 0
# --------------------------------------------------------------------- 台账
def state_file(task_id: str) -> Path:
return STATE_DIR / (task_id + ".json")
def load_state(task_id: str) -> dict:
p = state_file(task_id)
if not p.is_file():
raise EnvError("任务 {} 不存在(缺台账 {})".format(task_id, p))
return json.loads(p.read_text(encoding="utf-8"))
def save_state(st: dict) -> None:
STATE_DIR.mkdir(parents=True, exist_ok=True)
state_file(st["id"]).write_text(
json.dumps(st, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
def all_states():
if not STATE_DIR.is_dir():
return []
out = []
for p in sorted(STATE_DIR.glob("*.json")):
try:
out.append(json.loads(p.read_text(encoding="utf-8")))
except Exception:
continue
return out
# --------------------------------------------------------------------- 独占锁
def lock_age_hours(info) -> float:
try:
return max(0.0, (time.time() - float(info.get("epoch") or 0)) / 3600.0)
except (TypeError, ValueError):
return float("inf")
def lock_ttl_hours(name: str, info=None) -> float:
"""锁 `name` 的陈旧判定窗口(小时)。
优先用锁 payload 里持有者自报的 `ttl`(例如 keil_build.py 按其 --timeout 声明),
否则回落到该锁的默认窗口(build 1h / bench 6h)。
"""
if info:
try:
v = info.get("ttl")
if v:
return max(0.0, float(v))
except (TypeError, ValueError):
pass
return LOCK_TTL_HOURS.get(name, DEFAULT_LOCK_TTL_HOURS)
def lock_is_stale(name: str, info) -> bool:
return lock_age_hours(info) > lock_ttl_hours(name, info)
def lock_path(name: str) -> Path:
return LOCKS_DIR / (name + ".lock")
def lock_read(name: str):
p = lock_path(name)
if not p.is_file():
return None
try:
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
return {"task": "?", "pid": 0, "epoch": 0, "at": "?"}
def lock_acquire(name: str, task: str, wait: int = 0, force: bool = False, note: str = "",
ttl: float = None):
"""抢占独占资源。
⚠ 锁**不**绑定进程存活:`lock acquire` 本身是短命进程,而用锁的烧录/构建
在另一个进程里跑。因此判「陈旧」只看**时效**(超过该锁的 TTL),外加显式
`--force`;pid 仅作记录便于排障。TTL 按锁分设(build 1h / bench 6h),
持有者也可用 `ttl`(小时)自报更精确的窗口(如 build 按 --timeout 声明)。
"""
LOCKS_DIR.mkdir(parents=True, exist_ok=True)
p = lock_path(name)
deadline = time.time() + max(0, wait)
while True:
try:
fd = os.open(str(p), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
except FileExistsError:
info = lock_read(name) or {}
if info.get("task") == task:
return True, info
age = lock_age_hours(info)
ttl_h = lock_ttl_hours(name, info)
if force or age > ttl_h:
print("[lock] 接管 {} 锁(原持有者 {},已持有 {:.1f} 小时 / TTL {:.1f} 小时{})".format(
name, info.get("task"), age, ttl_h, ",--force" if force else ",视为陈旧"))
try:
p.unlink()
except OSError:
pass
continue
if time.time() >= deadline:
return False, info
print("[lock] {} 被 {} 占用(已持有 {:.1f} / TTL {:.1f} 小时),等待…".format(
name, info.get("task"), age, ttl_h))
time.sleep(2)
else:
now = time.time()
payload = {
"name": name, "task": task, "pid": os.getpid(),
"host": os.environ.get("COMPUTERNAME", "?"),
"epoch": now,
"at": time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(now)),
"note": note,
"ttl": ttl,
}
os.write(fd, (json.dumps(payload, ensure_ascii=False) + "\n").encode("utf-8"))
os.close(fd)
return True, payload
def lock_release(name: str, task=None, force: bool = False):
p = lock_path(name)
if not p.is_file():
return True, "锁 {} 未被持有".format(name)
info = lock_read(name) or {}
if not force and task and info.get("task") not in (task, None):
return False, "锁 {} 由 {} 持有,与 {} 不符;强制释放请加 --force".format(
name, info.get("task"), task)
try:
p.unlink()
except OSError as e:
return False, "释放锁 {} 失败:{}".format(name, e)
return True, "已释放锁 {}(原持有者 {})".format(name, info.get("task"))
# --------------------------------------------------------------------- 目录联接
def is_reparse(p: Path) -> bool:
try:
st = os.lstat(str(p))
except OSError:
return False
return bool(getattr(st, "st_file_attributes", 0) & FILE_ATTRIBUTE_REPARSE_POINT)
def make_junction(link: Path, target: Path) -> None:
"""创建 Windows 目录联接(不需要管理员权限,也不是符号链接)
走 `mklink /J`——这是唯一不需要 SeCreateSymbolicLinkPrivilege 的目录链接形式。
⚠ 实测(2026-10-12):直接用 ctypes 调 `DeviceIoControl(FSCTL_SET_REPARSE_POINT)`
会被系统拒绝(Win32 4392 / ERROR_REPARSE_TAG_INVALID,缓冲区经 hexdump 核对无误),
而 `mklink /J` 正常,故采用后者。参数以**列表**交给 subprocess,
含空格的路径(本项目 `DTU Code`)由 subprocess 自动加引号,不经手写引号。
"""
if os.name != "nt":
raise EnvError("目录联接是 Windows 专属能力;当前平台 {}".format(os.name))
if os.path.lexists(str(link)):
raise Fail("联接位置已被占用:{}".format(link))
if not target.exists():
raise Fail("联接源不存在:{}".format(target))
link.parent.mkdir(parents=True, exist_ok=True)
r = subprocess.run(
["cmd", "/c", "mklink", "/J", str(link), str(target)],
capture_output=True, stdin=subprocess.DEVNULL,
)
if r.returncode != 0 or not is_reparse(link):
detail = (r.stdout or b"").decode("mbcs", "replace").strip()
raise Fail("创建目录联接失败:{} -> {}\n rc={} {}".format(
link, target, r.returncode, detail))
def remove_junction(link: Path) -> bool:
"""只摘掉联接本身,绝不触碰目标内容(os.rmdir = RemoveDirectoryW)"""
if not is_reparse(link):
return False
try:
os.rmdir(str(link))
return True
except OSError as e:
print("[warn] 摘除联接失败 {}:{}".format(link, e))
return False
def bootstrap(wt: Path):
"""建立共享联接。单个联接失败只告警不中止——避免留下半成品任务空间"""
made, failed = [], []
for rel, desc in JUNCTION_SPECS:
target = REPO_ROOT / rel
if not target.exists():
print("[skip] {}({})不存在,跳过联接".format(rel, desc))
continue
try:
make_junction(wt / rel, target)
except (Fail, EnvError) as e:
failed.append(rel)
print("[WARN] {} 联接失败:{}".format(rel, e))
continue
made.append(rel)
print("[ok] 联接 {} -> {}".format(rel, target))
if failed:
print()
print("!! {} 个共享联接未建成;该任务里跑 `.venv\\Scripts\\python.exe` 会找不到解释器。".format(len(failed)))
for rel in failed:
print(" 手工补:cmd /c mklink /J \"{}\\{}\" \"{}\\{}\"".format(wt, rel, REPO_ROOT, rel))
return made
# --------------------------------------------------------------------- DSH 插件工作树
#
# 插件建的树(.dsh-worktrees/worktree/<name>)缺三样东西,下面的命令逐条补。
# 回收**不在这里**:插件树由「/worktree remove|prune」收,done/abort 也不该拿去处理它。
# 1. 共享联接——没有 .venv 与 simulator 的 SDL2 SDK,插件树里「.venv\Scripts\python.exe」
# 直接找不到解释器:/keil-build、ci_local.py、所有 scripts/check_*.py 全部起跑失败;
# 2. 可见性——插件树不在台账里,「list」看不见它,8 个活跃名额也只数登记任务;
# 3. 独占资源归属——build 锁由 keil_build.py 自持自释(按 cwd 反推持有者名,
# 插件树里算成 pw:<name>),bench 锁仍要人占,占锁名得和「lock status」对得上。
def plugin_lock_owner(name: str) -> str:
"""插件树在锁 payload 里的持有者名:pw:<name>(多段名压平成 -)。
必须与 keil_build.py 的 default_lock_task() 对同一种树算出的名字一致——这样构建
自持自释留下的归属,和手工「lock acquire bench --task pw:x」才是同一个可 grep 的标识。
"""
# 与 plugin_dir_for 同样容忍「worktree/」前缀,否则同一棵树会出现两个持有者名
# (pw:refactor-logging vs pw:worktree-refactor-logging),lock status 就散了。
segs = [s for s in name.replace(os.sep, "/").strip("/").split("/") if s]
if segs and segs[0] == PLUGIN_NAMESPACE:
segs = segs[1:]
return "{}:{}".format(PLUGIN_LOCK_PREFIX, "-".join(segs) or "?")
def plugin_dir_for(name: str) -> Path:
"""插件 worktree 名 → 树目录;**只允许落在 .dsh-worktrees/worktree/ 里面**。
adopt/unadopt 会在解析出的目录上建/摘目录联接;不加这道闸,等于允许把主工作树或
任意 _wt 目录当插件树动手(联接会遮蔽真实内容,事后很难看出来)。
"""
raw = (name or "").strip().replace(os.sep, "/").strip("/")
if not raw:
raise EnvError("缺插件 worktree 名(先跑「plugin list」看现有树)")
if raw == PLUGIN_NAMESPACE or raw.startswith(PLUGIN_NAMESPACE + "/"):
raw = raw[len(PLUGIN_NAMESPACE):].strip("/")
p = (PLUGIN_SUBDIR / raw).resolve()
base = PLUGIN_SUBDIR.resolve()
if p == base or base not in p.parents:
raise EnvError("拒绝 {}:只接受 {} 里面的树".format(p, PLUGIN_SUBDIR))
return p
def junction_states(wt: Path):
"""每个共享联接的三态:link(目录联接)/ real(实体内容)/ absent(真缺)。
这是「联没联上」的**唯一**判定处——列表显示与 adopt 收尾都读它。早先列表只取样
JUNCTION_SPECS 的第一项,于是「有 .venv、缺 simulator/SDL2」的半联树被报成已联,
而 adopt 可以只成功一条(主线缺 SDL2 走 skip、联接失败走 warn)→ 说谎的正是这一列。
"""
st = {}
for rel, _desc in JUNCTION_SPECS:
link = wt / rel
if is_reparse(link):
st[rel] = "link"
elif os.path.lexists(str(link)):
st[rel] = "real"
else:
st[rel] = "absent"
return st
def junction_label(st) -> str:
"""把三态压成一列短标签:缺什么都点名,实体内容也算「齐」但不算共享。"""
tag = {rel: rel.rsplit("/", 1)[-1] for rel in st}
absent = [tag[r] for r, s in st.items() if s == "absent"]
real = [tag[r] for r, s in st.items() if s == "real"]
if absent:
return "缺 " + "+".join(absent)
return "齐(实体" + "+".join(real) + ")" if real else "已联"
def plugin_trees():
""".dsh-worktrees/worktree/ 下的工作树(事实源 = git worktree list,不读插件 manifest)。
manifest.json 是插件私有格式(MANIFEST_VERSION 1,字段随版本变),而 git 的注册表
同时覆盖「插件正常建的树」和「manifest 丢了 / 树被手工删」的残留——桥接命令要看的
是真实存在的东西。
"""
try:
base = PLUGIN_SUBDIR.resolve()
except OSError:
return []
trees = []
for ent in worktree_entries():
p = ent["path"]
try:
rp = p.resolve()
except OSError:
continue
if rp == base or base not in rp.parents:
continue
dirty = (len(git_out("status", "--porcelain", cwd=p, check=False).splitlines())
if p.is_dir() else -1)
trees.append({
"name": rp.relative_to(base).as_posix(),
"path": str(p),
"branch": ent.get("branch") or "(detached)",
"dirty": dirty,
"jn": junction_states(p),
})
return trees
def print_plugin_trees() -> int:
"""打印插件树一览(无树则静默),返回数量——「list」与「new」共用同一事实。"""
trees = plugin_trees()
if not trees:
return 0
print()
print("DSH 插件工作树({} 下;**不在本 skill 台账** → 不占 {} 个名额、done/abort "
"管不到,回收走 /worktree remove|prune):".format(PLUGIN_SUBDIR, MAX_CONCURRENT))
print("{:<26} {:<34} {:>4} {:>16}".format("名称", "分支", "脏", "联接"))
for t in trees:
print("{:<26} {:<34} {:>4} {:>16}".format(
t["name"], t["branch"], "-" if t["dirty"] < 0 else str(t["dirty"]),
junction_label(t["jn"])))
def miss(rel):
return any(t["jn"].get(rel) == "absent" for t in trees)
if miss(".venv"):
print(" 缺 .venv:进去跑 /keil-build / ci_local.py 会**静默**落到机器级解释器(无 "
"fontTools,generated-fresh 假红的老根因)")
if miss("simulator/SDL2"):
print(" 缺 simulator/SDL2:模拟器 build_sim.bat 直接报错——CMakeLists.txt 的 "
"SDL2_DIR 是裸 set,没有环境变量逃生口")
if miss(".venv") or miss("simulator/SDL2"):
print(" 补法:「plugin adopt <name>」——只建缺失的联接,不碰树里任何文件")
return len(trees)
def adopt_junctions(wt: Path):
"""只补**缺失**的共享联接(已联的跳过、实体目录绝不碰),返回 (已有, 本次新建)。"""
already, made = [], []
st = junction_states(wt)
for rel, desc in JUNCTION_SPECS:
link = wt / rel
if st[rel] == "link":
already.append(rel)
continue
if st[rel] == "real":
print("[warn] {} 已是实体目录/文件(不是联接)——不动它".format(rel))
already.append(rel)
continue
target = REPO_ROOT / rel
if not target.exists():
print("[skip] {}({})主线里不存在,跳过".format(rel, desc))
continue
try:
make_junction(link, target)
except (Fail, EnvError) as err:
print("[WARN] {} 联接失败:{}".format(rel, err))
continue
made.append(rel)
print("[ok] 联接 {} -> {}".format(rel, target))
return already, made
def cmd_plugin(a) -> int:
if a.action == "list":
if print_plugin_trees() == 0:
print("({} 下没有插件工作树)".format(PLUGIN_SUBDIR))
print(" 建法:新会话输入框选「Worktree模式」/ 让模型调 worktree_create / "
"/worktree create <name>")
print(" 建完回来「plugin adopt <name>」补共享联接,才能在里面跑 /keil-build 与门禁。")
return EXIT_OK
wt = plugin_dir_for(a.name)
if not wt.is_dir():
raise EnvError("目录不存在:{}(先用插件建树:/worktree create 或 worktree_create)".format(wt))
if a.action == "adopt":
if git("rev-parse", "--git-dir", cwd=wt, check=False).returncode != 0:
raise EnvError("{} 不是 git 工作树(别拿普通目录当插件树)".format(wt))
print("为插件树补共享联接:{}".format(wt))
already, made = adopt_junctions(wt)
owner = plugin_lock_owner(a.name)
# 复算一次:adopt 里 skip/warn/失败都不算成功,「已有 N + 新建 M」两两相加可能对不上
# 总数——就绪判定只能按**现在的实际状态**说,否则又是把没联上的说成联上了
still = [r for r, s in junction_states(wt).items() if s == "absent"]
print()
print("[ok] 联接:已有 {} 个、本次新建 {} 个".format(len(already), len(made)))
if still:
print("[warn] 仍缺 {}:主线里该路径不存在或联接失败——先补主线再复跑 adopt"
"(这棵树还不能算就绪)".format("、".join(still)))
else:
print("该目录现在可以跑 /keil-build / ci_local.py / /serial-boot-test")
print("build 锁由 keil_build.py 自持自释,持有者名就是 {}".format(owner))
print("上台架前:$TW lock acquire bench --task {} --note <说明>".format(owner))
print("下台架后:$TW lock release bench --task {}".format(owner))
print("回收仍归插件:/worktree remove {}(本 skill 不删插件树)".format(a.name))
return EXIT_OK
removed = [rel for rel, _spec in JUNCTION_SPECS if remove_junction(wt / rel)]
print("[ok] 摘掉联接:{}(只摘联接本身,绝不动目标内容与树里文件)".format(
"、".join(removed) if removed else "无(该树没有联接着)"))
return EXIT_OK
# --------------------------------------------------------------------- 子进程脚本
def regen(cwd: Path) -> None:
run_script("scripts/gen_task_table.py", ["--write"], cwd, "重生成 FreeRTOS 任务表")
if list((Path(cwd) / "MDK-ARM").glob("**/*.map")):
run_script("scripts/check_mem_numbers.py", ["--write"], cwd, "同步内存/Flash 快照")
else:
print("[skip] 未找到 .map,跳过内存快照同步(需先 /keil-build)")
def run_gates(cwd: Path, skip_build: bool) -> bool:
ok = run_script("scripts/ci_local.py", [], cwd, "静态门禁 + 主机侧纯逻辑单测 本地等价 CI")
if skip_build:
print("[skip] 按 --skip-build 跳过 /keil-build")
return ok
got, info = lock_acquire("build", "__done__", wait=900)
if not got:
print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 /keil-build".format(info.get("task")))
return False
# 告知 /keil-build「调用方已持锁」:新版 keil_build.py 自持自释(见其 docstring),
# 读到本变量即跳过,避免自我死锁;**旧版根本不看此变量、也不会自锁**,
# 故此处对「工作树里还是旧脚本」的在途任务同样安全(向后兼容)。
os.environ["DTU_LOCK_HELD"] = "build"
try:
if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd,
"/keil-build(0 错 0 警)"):
ok = False
finally:
os.environ.pop("DTU_LOCK_HELD", None)
lock_release("build", "__done__", force=True)
return ok
# --------------------------------------------------------------------- 合并辅助
def write_merge_message(st: dict, base: str, cwd: Path) -> Path:
raw = git_out("log", "--format=%s", "{}..{}".format(base, st["branch"]), cwd=cwd)
subjects = [s.strip() for s in raw.splitlines() if s.strip()]
title = "merge: {} 合入 {}".format(st["branch"], base)
if len(title) > 100:
title = title[:97] + "..."
lines = [title, "", "任务 {}({} 个提交)经 task-worktree 回收:".format(st["id"], len(subjects))]
for s in subjects:
lines.append("- " + (s[:73] + "..." if len(s) > 76 else s))
if st.get("note"):
note = st["note"]
lines.append("")
lines.append("备注:" + (note[:73] + "..." if len(note) > 76 else note))
fd, path = tempfile.mkstemp(prefix="taskwt-merge-", suffix=".txt")
os.close(fd)
Path(path).write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
return Path(path)
def audit_residue(base_before: str, branch: str, cwd: Path) -> None:
"""并集合并残留审计:双方都改过的文件会被 git 自动并成并集"""
mb = git_out("merge-base", base_before, branch, cwd=cwd)
task_files = set(filter(None, git_out("diff", "--name-only", mb, branch, cwd=cwd).splitlines()))
base_files = set(filter(None, git_out("diff", "--name-only", mb, base_before, cwd=cwd).splitlines()))
both = sorted(task_files & base_files)
if not both:
print("[ok] 没有「双方都改过」的文件,无并集残留风险")
return
print("以下 {} 个文件双方都改过,git 会自动并成并集,需人工核对:".format(len(both)))
for f in both[:30]:
print(" - " + f)
if len(both) > 30:
print(" …(其余 {} 个略)".format(len(both) - 30))
hits = []
for f in both:
p = cwd / f
if not p.is_file():
continue
try:
lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
except OSError:
continue
dups = [i + 1 for i in range(1, len(lines))
if lines[i] == lines[i - 1] and lines[i].strip()]
if dups:
hits.append((f, dups))
if hits:
print()
print("!! 连续重复行(并集合并残留嫌疑):")
for f, dups in hits:
print(" - {} 第 {} 行".format(f, ", ".join(str(d) for d in dups[:8])))
else:
print("[ok] 未检测到连续重复行")
print("提示:权威判据是 base / 两个 tip 三方对比(git show <ref>:<file>),别只看结果文件。")
def harvest_memory(st: dict) -> None:
"""任务工作树会被删除,先把它自己的记忆归并回主工作树,避免丢上下文"""
src = Path(st["path"]) / ".workbuddy-ai" / "memory"
if not src.is_dir():
return
dst_dir = REPO_ROOT / ".workbuddy-ai" / "memory"
dst_dir.mkdir(parents=True, exist_ok=True)
for f in sorted(src.glob("*.md")):
text = f.read_text(encoding="utf-8", errors="replace").strip()
if not text:
continue
dst = dst_dir / f.name
marker = "## [task-worktree 归并自任务 {} / {}]".format(st["id"], st["slug"])
with dst.open("a", encoding="utf-8", newline="\n") as fh:
fh.write("\n" + marker + "\n\n" + text + "\n")
print("[ok] 记忆归并 {} -> {}".format(f.name, dst))
def teardown_worktree(st: dict, force: bool = False, base: str = None) -> None:
wt = Path(st["path"])
base = base or st.get("base")
# 1) 先摘联接——绝不能让 git 递归进联接去删目标内容(.venv / SDL2 会被一起删掉)
for rel in st.get("junctions", []):
if remove_junction(wt / rel):
print("[ok] 已摘除联接 {}".format(rel))
# 2) 归并任务侧记忆
if wt.is_dir():
harvest_memory(st)
# 3) 移除工作树
r = git("worktree", "remove", *(["--force"] if force else []), str(wt), check=False)
if r.returncode != 0:
raise Fail("移除工作树失败:{}\n(提示:先处理未跟踪文件,或加 --force)".format(
(r.stdout + r.stderr).strip()))
# 4) 删分支。⚠ 不用 `git branch -d`——它按**环境 HEAD** 判断是否已合并,
# 集成点被 DTU_INTEGRATION_WORKTREE 改走时会误判。改为显式核对 base。
if force or git("merge-base", "--is-ancestor", st["branch"], base,
check=False).returncode == 0:
r = git("branch", "-D", st["branch"], check=False)
if r.returncode != 0:
print("[warn] 删除分支 {} 失败:{}".format(
st["branch"], (r.stdout + r.stderr).strip()))
else:
print("[ok] 已删除分支 {}".format(st["branch"]))
else:
print("[warn] 分支 {} 未并入 {},保留不删(确认可弃再 `git branch -D {}`)".format(
st["branch"], base, st["branch"]))
state_file(st["id"]).unlink(missing_ok=True)
# --------------------------------------------------------------------- 子命令
def cmd_new(a) -> int:
if not ID_RE.match(a.task_id):
raise EnvError("任务 ID 非法:{}(字母数字开头,可用 . _ -)".format(a.task_id))
if not SLUG_RE.match(a.slug):
raise EnvError("slug 非法:{}(小写字母数字与连字符)".format(a.slug))
branch = "{}{}-{}".format(BRANCH_PREFIX, a.task_id, a.slug)
wt = WT_ROOT / "{}-{}".format(a.task_id, a.slug)
if state_file(a.task_id).is_file():
raise EnvError("任务 {} 已存在({})".format(a.task_id, state_file(a.task_id)))
if os.path.lexists(str(wt)):
raise EnvError("目录已存在:{}".format(wt))
if git("rev-parse", "--verify", "--quiet", "refs/heads/" + branch, check=False).returncode == 0:
raise EnvError("分支已存在:{}".format(branch))
if git("rev-parse", "--verify", "--quiet", a.base, check=False).returncode != 0:
raise EnvError("基线分支不存在:{}".format(a.base))
active = [s for s in all_states() if Path(s["path"]).is_dir()]
if len(active) >= MAX_CONCURRENT and not a.force:
raise EnvError("已有 {} 个活跃任务(上限 {});先 done/abort,或加 --force".format(
len(active), MAX_CONCURRENT))
n_plugin = len(plugin_trees())
if n_plugin:
print("[i] 另有 {} 个 DSH 插件工作树(.dsh-worktrees/,不计入上限 {});"
"磁盘与 UV4 / 台架仍是共享资源——「plugin list」看清单".format(n_plugin, MAX_CONCURRENT))
WT_ROOT.mkdir(parents=True, exist_ok=True)
print("[1/3] 建工作树(分支 {},基线 {}{})…".format(
branch, a.base, ",跳过 LFS smudge" if a.lean else ""))
git("worktree", "add", "-b", branch, str(wt), a.base,
env={"GIT_LFS_SKIP_SMUDGE": "1"} if a.lean else None)
print("[2/3] 建立共享联接…")
junctions = [] if a.no_bootstrap else bootstrap(wt)
st = {
"id": a.task_id, "slug": a.slug, "branch": branch, "path": str(wt),
"base": a.base, "created_at": time.strftime("%Y-%m-%d %H:%M:%S"),
"junctions": junctions, "needs_bench": bool(a.needs_bench),
"bench_pass": False, "note": a.note or "", "lean": bool(a.lean),
}
save_state(st)
print("[3/3] 台账已写入 {}".format(state_file(a.task_id)))
print()
print("下一步:把新会话的工作目录切到")
print(" " + str(wt))
print("在该目录里干活、跑 /keil-build 与 ci_local.py(并行安全);")
print("要动台架先 `lock acquire bench --task {}`,完事 `lock release bench --task {} --bench-pass`。".format(
a.task_id, a.task_id))
print("回收用 `done {}`(**必须在主工作树执行**)。".format(a.task_id))
return EXIT_OK
def cmd_list(a) -> int:
states = all_states()
if not states:
print("(没有任务空间。用 `new <ID> <slug>` 建一个)")
print_plugin_trees()
return EXIT_OK
print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format(
"任务", "分支", "落后", "领先", "脏", "台架"))
for st in states:
wt = Path(st["path"])
if not wt.is_dir():
print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format(
st["id"], st["branch"], "-", "-", "-", "[工作树缺失]"))
continue
ab = git_out("rev-list", "--left-right", "--count",
"{}...{}".format(st["base"], st["branch"]), cwd=wt, check=False).split()
behind, ahead = (ab[0], ab[1]) if len(ab) == 2 else ("?", "?")
dirty = len(git_out("status", "--porcelain", cwd=wt, check=False).splitlines())
bench = "PASS" if st.get("bench_pass") else ("需测" if st.get("needs_bench") else "-")
print("{:<12} {:<32} {:>5} {:>5} {:>4} {}".format(
st["id"], st["branch"], behind, ahead, dirty, bench))
print()
for n in LOCK_NAMES:
info = lock_read(n)
if not info:
print("锁 {:<6} 空闲".format(n))
else:
print("锁 {:<6} {} 持有(已 {:.1f} 小时,于 {})".format(
n, info.get("task"), lock_age_hours(info), info.get("at")))
print_plugin_trees()
return EXIT_OK
def cmd_status(a) -> int:
st = load_state(a.task_id)
wt = Path(st["path"])
print("任务 {} ({})".format(st["id"], st["slug"]))
print("分支 {} ← 基线 {}".format(st["branch"], st["base"]))
print("工作树 {}{}".format(wt, "" if wt.is_dir() else " [缺失]"))
print("建立于 {}".format(st.get("created_at")))
if st.get("note"):
print("备注 {}".format(st["note"]))
if wt.is_dir():
dirty = git_out("status", "--porcelain", cwd=wt, check=False).splitlines()
print("未提交 {} 项".format(len(dirty)))
for line in dirty[:10]:
print(" " + line)
ab = git_out("rev-list", "--left-right", "--count",
"{}...{}".format(st["base"], st["branch"]), cwd=wt, check=False).split()
if len(ab) == 2:
print("相对基线 落后 {} / 领先 {} 个提交".format(ab[0], ab[1]))
print("台架 {}{}".format(
"需要," if st.get("needs_bench") else "不涉及,",
"已记录 PASS" if st.get("bench_pass") else "尚无 PASS 记录"))
if st.get("junctions"):
print("联接 {}".format(", ".join(st["junctions"])))
return EXIT_OK
def cmd_sync(a) -> int:
st = load_state(a.task_id)
wt = Path(st["path"])
base = a.base or st["base"]
if not wt.is_dir():
raise EnvError("工作树不存在:{}".format(wt))
if git_out("status", "--porcelain", cwd=wt, check=False):
raise Fail("任务工作树有未提交改动,先提交或 stash 再 sync")
r = git("rebase", base, cwd=wt, check=False)
if r.returncode != 0:
print((r.stdout + r.stderr).strip())
raise Fail("rebase 有冲突:在 {} 里解决后 `git rebase --continue`,放弃用 `git rebase --abort`".format(wt))
print("[ok] 任务 {} 已 rebase 到 {}".format(st["id"], base))
return EXIT_OK
def cmd_lock(a) -> int:
if a.action == "status":
for n in LOCK_NAMES:
info = lock_read(n)
if not info:
print("{:<6} 空闲(TTL {:.1f}h)".format(n, lock_ttl_hours(n)))
else:
age = lock_age_hours(info)
ttl_h = lock_ttl_hours(n, info)
mark = " [陈旧,可被自动接管]" if age > ttl_h else ""
print("{:<6} {} 持有(pid={},于 {},已 {:.1f} / TTL {:.1f} 小时,备注 {}){}".format(
n, info.get("task"), info.get("pid"), info.get("at"), age,
ttl_h, info.get("note") or "-", mark))
return EXIT_OK
if a.name not in LOCK_NAMES:
raise EnvError("未知资源 {}(可用:{})".format(a.name, " / ".join(LOCK_NAMES)))
if a.action == "acquire":
if not a.task:
raise EnvError("acquire 需要 --task <ID>")
got, info = lock_acquire(a.name, a.task, wait=a.wait, force=a.force,
note=a.note or "", ttl=a.ttl)
if not got:
print("获取失败:{} 被 {} 占用(pid={},于 {})".format(
a.name, info.get("task"), info.get("pid"), info.get("at")))
return EXIT_FAIL
print("已获取 {} 锁(task={} pid={} ttl={})".format(
a.name, a.task, os.getpid(),
"{:.2f}h".format(a.ttl) if a.ttl else "默认"))
return EXIT_OK
if a.bench_pass:
if not a.task:
raise EnvError("--bench-pass 需要 --task <ID>")
# 非登记持有者(lead 的验收窗、插件树 pw:*)没有台账。以前这里先 load_state
# 就抛「任务 X 不存在(缺台账)」,**根本走不到释放**:锁还挂着,而 done 的前置
# 会因活锁判失败——一个名字没登记,就挡住所有任务的收口。现在降级为显式告警 +
# 照常释放:PASS 没进台账是事实,但不该拿「锁放不掉」当代价。
if state_file(a.task).is_file():
st = load_state(a.task)
st["bench_pass"] = True
save_state(st)
print("[ok] 已为任务 {} 记录台架 PASS".format(a.task))
else:
print("[warn] 任务 {} 无台账({})→ **跳过 PASS 记录,仅释放锁**;要把 PASS 留在"
"台账里就得用已「new」出来的任务 ID 占锁,或把验收结论写进主干提交说明/文档"
.format(a.task, state_file(a.task)))
ok, msg = lock_release(a.name, a.task, force=a.force)
print(msg)
return EXIT_OK if ok else EXIT_FAIL
def release_stale_locks() -> None:
"""`done` 前置:陈旧锁自动接管;仍被有效占用的则拒绝收口。
「陈旧」= 持有时间 > 该锁 TTL(build 1h / bench 6h,或持有者自报的 ttl)。
崩溃/遗忘留下的锁不该阻塞收口——故自动释放而非硬失败(2026-10-12)。
"""
for n in LOCK_NAMES:
info = lock_read(n)
if not info:
continue
age = lock_age_hours(info)
ttl_h = lock_ttl_hours(n, info)
if age > ttl_h:
ok, msg = lock_release(n, force=True)
print("[i] {} 锁已陈旧({} 持有 {:.1f}h > TTL {:.1f}h),自动接管:{}".format(
n, info.get("task"), age, ttl_h, msg))
continue
raise Fail("独占资源 {} 仍被 {} 占用(已 {:.1f} / TTL {:.1f} 小时),先 release".format(
n, info.get("task"), age, ttl_h))
def cmd_done(a) -> int:
st = load_state(a.task_id)
wt = Path(st["path"])
branch = st["branch"]
base = a.base or st["base"]
main = main_worktree()
print("=== 前置检查 ===")
if not wt.is_dir():
raise EnvError("工作树不存在:{}".format(wt))
if git_out("status", "--porcelain", cwd=wt, check=False):
raise Fail("任务工作树有未提交改动,先提交")
if git_out("status", "--porcelain", cwd=main, check=False):
raise Fail("主工作树不干净({})——合并会把未提交改动卷进来".format(main))
cur = git_out("rev-parse", "--abbrev-ref", "HEAD", cwd=main)
if cur != base:
raise Fail("主工作树当前在 {},不是集成分支 {};先切过去再 done".format(cur, base))
release_stale_locks()
ahead = int(git_out("rev-list", "--count", "{}..{}".format(base, branch), cwd=wt) or "0")
already = git("merge-base", "--is-ancestor", branch, base, cwd=main, check=False).returncode == 0
if ahead == 0 and not already:
raise Fail("任务分支相对 {} 没有新提交,无需合并".format(base))
if already and ahead == 0:
print("[i] {} 已合入 {},跳过合并,直接清场".format(branch, base))
print("[i] 该路径在门禁之前就返回——不替你复绿;上一次门禁没跑到的尾巴,"
"清场后去主树跑 `postmerge`(或手工 ci_local + check_doc_links --all)")
print("=== 清场 ===")
teardown_worktree(st, force=a.force, base=base)
print("\n完成(分支此前已合入)。")
return EXIT_OK
if st.get("needs_bench") and not st.get("bench_pass"):
raise Fail("任务标记 --needs-bench 但无台架 PASS 记录;跑完 /serial-boot-test 后执行 "
"`lock release bench --task {} --bench-pass`".format(st["id"]))
print("[ok] 前置检查通过(领先 {} 共 {} 个提交)".format(base, ahead))
print("=== 合并预演(git merge-tree,不改动任何东西)===")
r = git("merge-tree", "--write-tree", base, branch, cwd=main, check=False)
if r.returncode != 0:
print((r.stdout + r.stderr).strip())
raise Fail("预演发现冲突:到任务工作树里 `git merge {}` 解决并提交后重跑 done".format(base))
tree = (r.stdout.splitlines() or ["?"])[0].strip()
print("[ok] 无冲突,预演结果树 {}".format(tree))
if a.dry_run:
print()
print("--dry-run:到此为止,未做任何改动。")
return EXIT_OK
print("=== 合入主干({},--no-ff)===".format(main))
base_before = git_out("rev-parse", "HEAD", cwd=main)
msgfile = write_merge_message(st, base, wt)
r = git("merge", "--no-ff", "--no-edit", branch, "-F", str(msgfile), cwd=main, check=False)
msgfile.unlink(missing_ok=True)
if r.returncode != 0:
raise Fail("合并失败:\n{}".format((r.stdout + r.stderr).strip()))
print((r.stdout or "").strip())
base_after = git_out("rev-parse", "HEAD", cwd=main)
print("=== 合并残留审计 ===")
audit_residue(base_before, branch, cwd=main)
print("=== 重生成受管产物 ===")
regen(main)
if a.skip_gates:
print("=== 门禁(--skip-gates 已跳过)===")
gates_ok = True
else:
print("=== 门禁 ===")
gates_ok = run_gates(main, skip_build=a.skip_build)
if not gates_ok:
print()
print("!! 门禁未全绿:工作树与分支**保留**,未清场。")
print(" 主干已含合并提交 {}".format(base_after[:12]))
print(" 可选:a) 在主工作树修正后追加提交(推荐)")
print(" ——体积凭证/台账红就用 `postmerge` 自动诊断回填,别手抄补链")
print(" b) 回退本次合并:git -C \"{}\" reset --hard {}".format(main, base_before[:12]))
return EXIT_FAIL
print("=== 清场 ===")
teardown_worktree(st, force=a.force, base=base)
print()
print("完成。主干 {} 现为 {}。".format(base, base_after[:12]))
print("push 需人工许可,本次未推送。")
return EXIT_OK
# ------------------------------------------------- 树内预检 / 合并后补绿(机械化)
HYGIENE_BINARY = (".png", ".jpg", ".gif", ".ico", ".ttf", ".bin", ".hex", ".elf",
".axf", ".o", ".crf", ".lst", ".zip", ".gz", ".pdf", ".cur")
CR_EXEMPT = (".bat", ".cmd") # .gitattributes 强制 CRLF
def _git_bytes(*args, cwd=None):
r = subprocess.run(["git"] + [str(x) for x in args], cwd=str(cwd or REPO_ROOT),
capture_output=True, stdin=subprocess.DEVNULL)
return r.stdout if r.returncode == 0 else None
def _changed_paths(cwd: Path):
q = ("-c", "core.quotePath=false") # 中文文件名不许被八进制转义出去
tracked = git(*q, "diff", "--name-only", "HEAD", cwd=cwd, check=False).stdout
others = git(*q, "ls-files", "--others", "--exclude-standard", cwd=cwd, check=False).stdout
return ([p.strip() for p in tracked.splitlines() if p.strip()],
[p.strip() for p in others.splitlines() if p.strip()])
def cmd_precheck(a) -> int:
"""树内自查(只读):字节体检 + docs 逐档门禁 + 可选提交信息检查。绝不代改。"""
cwd = Path.cwd()
(cwd / "ci-artifacts").mkdir(exist_ok=True) # 新树没有 ci-artifacts(G 节:ENOENT 坑)
tracked, untracked = _changed_paths(cwd)
print("[i] {}:改动 {} 个(tracked {} + untracked {})".format(
cwd, len(tracked) + len(untracked), len(tracked), len(untracked)))
bom3, cr, fffd = bytes.fromhex("efbbbf"), b"\r", bytes.fromhex("efbfbd")
problems, docs = 0, []
for p in tracked + untracked:
f = cwd / p
if not f.is_file() or p.lower().endswith(HYGIENE_BINARY):
continue
b = f.read_bytes()
bad = []
if cr in b and not p.lower().endswith(CR_EXEMPT):
bad.append("CR")
n = b.count(fffd)
if n:
bad.append("U+FFFD x{}".format(n))
if p in tracked:
hb = _git_bytes("show", "HEAD:" + p, cwd=cwd)
if hb is not None and hb.startswith(bom3) and not b.startswith(bom3):
bad.append("BOM 漂移(HEAD 有 BOM、工作区没了——bom-guard 会拦)")
if p.startswith("docs/") and p.endswith(".md"):
docs.append(p)
if bad:
problems += 1
print("!! {}:{}".format(p, " / ".join(bad)))
for d in docs:
if not run_script("scripts/check_doc_links.py", [d], cwd, "文档门禁 " + d, tail=6):
problems += 1
if a.msgfile:
m = Path(a.msgfile)
if not m.is_file():
raise EnvError("消息文件不存在:{}".format(m))
mb = m.read_bytes()
if mb.startswith(bom3):
problems += 1
print("!! 消息带 BOM(commit -F 要 UTF-8 无 BOM)")
if cr in mb:
problems += 1
print("!! 消息含 CRLF")
ml = mb.decode("utf-8", "replace").splitlines()
for i, ln in enumerate(ml):
lim = 100 if i == 0 else 80
if len(ln) > lim:
problems += 1
print("!! 消息第 {} 行长 {} > {}".format(i + 1, len(ln), lim))
break
for w in a.ban:
c = mb.decode("utf-8", "replace").lower().count(w.lower())
print("[i] 禁词 {!r}:{} 次".format(w, c))
if c:
problems += 1
print("完成:问题 {} 个".format(problems))
return EXIT_OK if problems == 0 else EXIT_FAIL
def cmd_postmerge(a) -> int:
"""主树合并后补绿:体积凭证自动诊断(短链/全链)+ 文档全量。列文件,不提交。
裁定外包给现有门禁:凭证红 → 先短链(--update 重写)→ 用只读的
check_mem_numbers 复检裁定数字是否真变——红了才升级全链(--write 族)。
"""
main = main_worktree()
if Path.cwd().resolve() != main.resolve():
raise EnvError("postmerge 必须在主工作树跑:{}".format(main))
if not list((main / "MDK-ARM").glob("**/*.map")):
raise EnvError("主树没有 .map——先 /keil-build 0/0 再回来(C.5 的前提)")
gate = "scripts/check_size_gate.py"
if not a.force and run_script(gate, ["--local"], main, "体积凭证检查(同码)", tail=8):
print("[ok] 凭证同码新鲜,无需补链")
else:
run_script("scripts/ram_baseline.py", ["--update"], main, "短链:重写体积凭证", tail=6)
mem_ok = (a.skip_mem or run_script("scripts/check_mem_numbers.py", [], main,
"受管区一致性复检(只读)", tail=8))
if not mem_ok:
print("[i] 快照与 .map 真漂了 → 升级全链(数字变了才动受管区)")
run_script("scripts/check_mem_numbers.py", ["--write"], main, "同步内存快照")
run_script("scripts/ram_baseline.py", ["--write"], main, "同步 RAM 预算台账")
if a.coverage:
run_script("scripts/check_test_coverage.py", ["--update-baseline"], main,
"覆盖率基线刷新", tail=6)
if not run_script(gate, ["--local"], main, "凭证复检(期望绿)", tail=8):
print("!! 凭证复检仍红——贴输出查 check_size_gate 原因,别硬提交")
return EXIT_FAIL
ok = True
if not a.skip_docs:
ok = run_script("scripts/check_doc_links.py", ["--all"], main, "文档全库检查", tail=8)
dirty = git("-c", "core.quotePath=false", "diff", "--name-only", "HEAD",
cwd=main, check=False).stdout.splitlines()
dirty = [x.strip() for x in dirty if x.strip()]
if dirty:
print()
print("[i] 回填产物(提交属 Ask first,本命令不代提交):")
for p in dirty:
print(" - " + p)
print(" 建议:git add <上面列的文件> && git commit -F <正文文件>")
return EXIT_OK if ok else EXIT_FAIL
def cmd_abort(a) -> int:
st = load_state(a.task_id)
wt = Path(st["path"])
if wt.is_dir():
dirty = git_out("status", "--porcelain", cwd=wt, check=False).splitlines()
ahead = int(git_out("rev-list", "--count",
"{}..{}".format(st["base"], st["branch"]), cwd=wt,
check=False) or "0")
if (dirty or ahead) and not a.force:
print("任务 {} 有 {} 项未提交改动、{} 个未合并提交,abort 会**永久丢弃**:".format(
st["id"], len(dirty), ahead))
for line in dirty[:20]:
print(" " + line)
raise Fail("确认丢弃请加 --force")
teardown_worktree(st, force=True)
print("\n已放弃任务 {}(工作树与分支均已删除)".format(st["id"]))
return EXIT_OK
# --------------------------------------------------------------------- 入口
def build_parser():
ap = argparse.ArgumentParser(
prog="task_worktree.py",
description="多任务并行开发的空间管理(git worktree 生命周期)")
sub = ap.add_subparsers(dest="cmd")
p = sub.add_parser("new", help="新建任务空间")
p.add_argument("task_id")
p.add_argument("slug")
p.add_argument("--base", default=BASE_BRANCH, help="基线分支(默认 %(default)s)")
p.add_argument("--lean", action="store_true",
help="跳过 Git LFS smudge(省约 38MB,参考图变成指针文件)")
p.add_argument("--no-bootstrap", action="store_true", help="不建共享联接")
p.add_argument("--needs-bench", action="store_true",
help="需台架验证;无 PASS 记录则 done 拒绝合并")
p.add_argument("--note", default="")
p.add_argument("--force", action="store_true", help="忽略并发上限")
p.set_defaults(func=cmd_new)
p = sub.add_parser("list", help="列出所有任务空间")
p.set_defaults(func=cmd_list)
p = sub.add_parser("status", help="单个任务详情")
p.add_argument("task_id")
p.set_defaults(func=cmd_status)
p = sub.add_parser("sync", help="rebase 追平主干")
p.add_argument("task_id")
p.add_argument("--base", default=None)
p.set_defaults(func=cmd_sync)
p = sub.add_parser("lock", help="独占资源锁(build / bench)")
p.add_argument("action", choices=["acquire", "release", "status"])
p.add_argument("name", nargs="?", choices=list(LOCK_NAMES))
p.add_argument("--task", default=None)
p.add_argument("--wait", type=int, default=0, help="等待秒数(0=不等待)")
p.add_argument("--force", action="store_true")
p.add_argument("--note", default="")
p.add_argument("--ttl", type=float, default=None,
help="本次持有自报的陈旧窗口(小时);缺省用该锁默认值(build 1h / bench 6h)")
p.add_argument("--bench-pass", action="store_true",
help="release bench 时顺带为该任务记录台架 PASS")
p.set_defaults(func=cmd_lock)
p = sub.add_parser("plugin", help="DSH worktree 插件的树(.dsh-worktrees/):list / adopt / unadopt")
p.add_argument("action", choices=["list", "adopt", "unadopt"])
p.add_argument("name", nargs="?", default="",
help="插件 worktree 名(可带 worktree/ 前缀或多段斜杠)")
p.set_defaults(func=cmd_plugin)
p = sub.add_parser("done", help="回收:预演 → 合入主干 → 审计 → 重生成 → 门禁 → 清场")
p.add_argument("task_id")
p.add_argument("--base", default=None)
p.add_argument("--dry-run", action="store_true", help="只预演,不做任何改动")
p.add_argument("--skip-build", action="store_true", help="跳过 /keil-build")
p.add_argument("--skip-gates", action="store_true", help="跳过全部门禁")
p.add_argument("--force", action="store_true", help="清场时强制移除工作树")
p.set_defaults(func=cmd_done)
p = sub.add_parser("abort", help="放弃任务(不合并)")
p.add_argument("task_id")
p.add_argument("--force", action="store_true")
p.set_defaults(func=cmd_abort)
p = sub.add_parser("precheck", help="树内自查(只读):字节体检 + docs 逐档门禁 + 可选消息检查")
p.add_argument("--msgfile", default=None, help="附带检查提交信息文件(BOM/CRLF/行长/禁词)")
p.add_argument("--ban", action="append", default=[], help="禁词,可重复")
p.set_defaults(func=cmd_precheck)
p = sub.add_parser("postmerge", help="主树合并后补绿:体积凭证自动诊断(短/全链)+ 文档全量;不代提交")
p.add_argument("--force", action="store_true", help="凭证新鲜也强制走一遍 --update")
p.add_argument("--coverage", action="store_true", help="附带覆盖率基线刷新")
p.add_argument("--skip-docs", action="store_true", help="跳过 check_doc_links --all")
p.add_argument("--skip-mem", action="store_true", help="只挪凭证,跳过受管区复检/全链")
p.set_defaults(func=cmd_postmerge)
return ap
def main(argv=None) -> int:
ap = build_parser()
a = ap.parse_args(argv)
if not getattr(a, "func", None):
ap.print_help()
return EXIT_USAGE
if not (REPO_ROOT / ".git").exists():
print("!! 无法定位仓库根(推导为 {})".format(REPO_ROOT), file=sys.stderr)
return EXIT_ENV
try:
return a.func(a)
except EnvError as e:
print("前置/环境错误:{}".format(e), file=sys.stderr)
return EXIT_ENV
except Fail as e:
print("失败:{}".format(e), file=sys.stderr)
return EXIT_FAIL
except KeyboardInterrupt:
print("已中断", file=sys.stderr)
return EXIT_FAIL
if __name__ == "__main__":
sys.exit(main())