#!/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 = 3 LOCK_NAMES = ("build", "bench") LOCK_TTL_HOURS = 6.0 # 超过此持有时间即视为陈旧锁,下次 acquire 自动接管 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" # --------------------------------------------------------------------- 基础工具 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_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 = ""): """抢占独占资源。 ⚠ 锁**不**绑定进程存活:`lock acquire` 本身是短命进程,而用锁的烧录/构建 在另一个进程里跑。因此判「陈旧」只看**时效**(超过 LOCK_TTL_HOURS), 外加显式 `--force`;pid 仅作记录便于排障。 """ 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) if force or age > LOCK_TTL_HOURS: print("[lock] 接管 {} 锁(原持有者 {},已持有 {:.1f} 小时{})".format( name, info.get("task"), age, ",--force" if force else ",视为陈旧")) try: p.unlink() except OSError: pass continue if time.time() >= deadline: return False, info print("[lock] {} 被 {} 占用(已持有 {:.1f} 小时),等待…".format( name, info.get("task"), age)) 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, } 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 # --------------------------------------------------------------------- 子进程脚本 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,跳过内存快照同步(需先 @build)") def run_gates(cwd: Path, skip_build: bool) -> bool: ok = run_script("scripts/ci_local.py", [], cwd, "L0 + L0.5 本地等价 CI") if skip_build: print("[skip] 按 --skip-build 跳过 @build") return ok got, info = lock_acquire("build", "__done__", wait=900) if not got: print("!! build 锁被 {} 占用(UV4 不能并发构建),跳过 @build".format(info.get("task"))) return False try: if not run_script(".dsh/skills/keil-build/scripts/keil_build.py", [], cwd, "@build(0 错 0 警)"): ok = False finally: 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 :),别只看结果文件。") 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)) 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("在该目录里干活、跑 @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 ` 建一个)") 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"))) 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} 空闲".format(n)) else: age = lock_age_hours(info) mark = " [陈旧,可被自动接管]" if age > LOCK_TTL_HOURS else "" print("{:<6} {} 持有(pid={},于 {},已 {:.1f} 小时,备注 {}){}".format( n, info.get("task"), info.get("pid"), info.get("at"), age, 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 ") got, info = lock_acquire(a.name, a.task, wait=a.wait, force=a.force, note=a.note or "") if not got: print("获取失败:{} 被 {} 占用(pid={},于 {})".format( a.name, info.get("task"), info.get("pid"), info.get("at"))) return EXIT_FAIL print("已获取 {} 锁(task={} pid={})".format(a.name, a.task, os.getpid())) return EXIT_OK if a.bench_pass: if not a.task: raise EnvError("--bench-pass 需要 --task ") st = load_state(a.task) st["bench_pass"] = True save_state(st) print("[ok] 已为任务 {} 记录台架 PASS".format(a.task)) ok, msg = lock_release(a.name, a.task, force=a.force) print(msg) return EXIT_OK if ok else EXIT_FAIL 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)) for n in LOCK_NAMES: info = lock_read(n) if info: raise Fail("独占资源 {} 仍被 {} 占用(已 {:.1f} 小时),先 release".format( n, info.get("task"), lock_age_hours(info))) 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("=== 清场 ===") 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 记录;跑完 @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(" 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 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("--bench-pass", action="store_true", help="release bench 时顺带为该任务记录台架 PASS") p.set_defaults(func=cmd_lock) 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="跳过 @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) 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())