4a4ad6bc5f
现状口径:/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 全过;字节卫生零漂移。
1285 lines
56 KiB
Python
1285 lines
56 KiB
Python
#!/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())
|