Files
STM32F4-Base/.dsh/skills/task-worktree/reference/reclaim-unregistered.md
T
wandering1 ecf9d3ad3d docs(task-worktree skill): 修 @T3@ 围栏腐坏,正文瘦身并把细节外置 reference/
围栏腐坏是真 bug:@T3@ 是被误提交进仓的代码围栏占位符,Markdown 渲染出
来就是一行 @T3@ 加裸命令。git log -S 定位:842d711e(2026-10-28)
SKILL.md 还有 18 个真三反引号围栏行、0 个占位符;21c68521(2026-10-29
插件桥接那笔)起变 0 围栏 / 34 个占位符。全仓只有本技能 6 个文件有此
病,draw-diagram / keil-build / ui-verify 等都用真围栏。本轮七个文件
占位符清零、围栏全部成对。

信息密度:SKILL.md 28,139 → 23,087 B(−18%,占插件写侧上限 65536 的
35%,给自进化留头量)。同一句外置理由在 C2/D/E/F/G/陷阱/验证 重复 7 次
→ 0;frontmatter 与 F 节写了两遍的插件桥接说明收进 F 节;「这不是免费
的」「挡给你看的保护」这类修辞删掉。删掉的细节全部落进对应 reference
篇——skill-size 钩子会校验指针不悬空:现 26 处链接 / 8 篇目标 / 悬空 0,
每篇都有入口。

锚点未重排:节号 A–G/C2、各节步骤号、铁律编号被 reference/ 反向引用
(铁律 3/5/7/9 与 A 第 1、5 步,C 第 5~7 步,E 第 1~6 步均结构性存在)。
只在 SKILL.md 出现的 PIPESTATUS 教训保留(reference/ 里没有,删了就丢)。

门禁:check_skill_size.py rc=0(17 个 SKILL.md 全在写侧上限内、无悬空
指针);七文件均无 BOM / CR / U+FFFD、单个尾换行。
2026-10-30 00:31:36 +08:00

15 KiB
Raw Blame History

E. 回收非登记工作树($TW list 为空 / detached HEAD / 树里有在飞或未跟踪内容)

这类树的判定特征(命中任意一条就适用本节):git worktree list 里有它、但 _wt/.state/<ID>.json 台账不存在; HEAD 是 detached(git rev-parse --abbrev-ref HEAD 打 HEAD);或树里的改动不属于本会话(别的会话留下的未提交 / 未跟踪文件)。典型来源:D 节的 --detach 净室、new 之后被手工删目录只剩台账的残骸、别人手工 git worktree add 建的分析树 (2026-10-28 实测一次回收 3 棵:verify-task16 / verify-batch / und-attr,各 89 MB,全部无台账 + detached)。

为什么 done / abort 在这里整体不适用(铁律 9):两条子命令的前置都是 load_state(),无台账 → 前置/环境错误:任务 <ID> 不存在(缺台账 …json)(exit 2)当场停住、什么也不会发生;而假如有一份残留台账, abort --force 会拿台账的 path 直接 git worktree remove —— 绕过下面第 1~4 步的全部内容保全,把别的会话的唯一版本一次删光; done 则会去找那条(多半已不存在的)task/ 分支做合并。另:本路径没有「合并」这一步,所以 done 的并集合并残留审计 在这里不发生,也没有三方对比可依据 —— 唯一性只由第 2 步的内容分桶给出。回收只能手工走本节第 1~5 步 + 第 6 步清台账。

  1. 建全景(全程只读):先看 git worktree list 的全集,再逐棵记三件事(脏行数含未跟踪 / 树顶提交 / 有无分支) 加一条台账判定,同时存下主树的拆前基线清单:
MAIN="D:/Code/DTU Code/STM32F4-Base"; WTROOT="$MAIN/../_wt"
git -c core.quotePath=false worktree list                      # 全集:登记树 + 非登记树都在这份里
MAIN_SHA=$(git -C "$MAIN" rev-parse --short HEAD)              # 下面所有比对都以这一版为主线尺子
mkdir -p ci-artifacts
git -C "$MAIN" status --porcelain -uall > ci-artifacts/wt-main-before.txt   # 拆前主树基线(第 5 步逐行比)
wc -l < ci-artifacts/wt-main-before.txt                        # 记下行数(实测 0)
for t in "$WTROOT"/*/; do
  echo "--- $t"
  git -c core.quotePath=false -C "$t" status --porcelain -uall | wc -l   # 脏 + 未跟踪行数
  git -c core.quotePath=false -C "$t" log --oneline -1                   # 树顶提交
  git -C "$t" rev-parse --abbrev-ref HEAD                                # 打 HEAD = detached,压根没分支可 done
  ls "$WTROOT/.state/$(basename "$t").json" 2>/dev/null || echo "NO-LEDGER"   # 台账位置随 DTU_WT_ROOT
done
  • -uall 不是可选:默认 status --porcelain 会把未跟踪目录折叠成一行 dir/,-uall 才展开到文件级;少了它 第 2 步的分桶就漏项(实测 und-attr 唯一那份就是未跟踪 ?? scripts/check_nested_comment.py)。
  • 路径含空格(本项目 DTU Code)→ 变量整体加引号;core.quotePath=false 让中文路径原样输出,便于逐行核对。
  • 判定「是不是登记树」看两样:台账在不在 + HEAD 是否 detached。两者都指「非登记」→ 从这里开始禁止 done / abort(见本节开头),继续第 2 步。
  1. 逐文件与主线 HEAD 比内容并分三桶:IN-MAINLINE(内容等价,拆 = 零损失)/ UNIQUE-UNMERGED(主线没有这份 内容,拆 = 丢)/ OLDER-DRAFT(同路径主线已有更新版本)。落成独立只读探针跑(别用 heredoc 现写现改, 见陷阱「别用 heredoc 打补丁造探针」):
# ci-artifacts/wt_bucket_scan.py — 只读:逐文件与主线工作区/HEAD 比对,三桶归类
import os, subprocess, tempfile
MAIN = r"D:\Code\DTU Code\STM32F4-Base"
WTROOT = os.path.join(os.path.dirname(MAIN), "_wt")
TREES = ["verify-task16", "verify-batch", "und-attr"]      # ← 第 1 步全景里挑出的非登记树
SKIP_TOP = {".venv", "MDK-ARM", ".git", "ci-artifacts"}    # 联接 / 构建产物 / 忽略区,按项目调
def git(*a):
    return subprocess.run(("git", "-c", "core.quotePath=false", "-C", MAIN) + a,
                          capture_output=True, text=True, errors="replace")
def bucket(rel, tree):
    p = os.path.join(tree, rel)
    if not os.path.exists(p):
        return "OTHER", "工作树里已不存在(多为已 staged 删除)"
    tf = tempfile.NamedTemporaryFile(delete=False)
    tf.write(open(p, "rb").read()); tf.close()
    # 1) 与主线工作区(= 当前 HEAD 的检出内容)逐字节比
    if git("diff", "--no-index", "--quiet", tf.name, rel).returncode == 0:
        os.unlink(tf.name); return "IN-MAINLINE", "与主线 HEAD 逐字节相同"
    # 2) 与主线历史各版比:内容等价于某个旧 commit → 只是主线又往前走了
    hist = [x.strip() for x in git("log", "--format=%H", "HEAD", "--", rel).stdout.splitlines()]
    for sha in hist[1:]:
        old = git("show", f"{sha}:{rel}")
        if old.returncode == 0 and old.stdout == open(p, encoding="utf-8", errors="replace").read():
            os.unlink(tf.name); return "IN-MAINLINE", f"内容等价于主线历史版 {sha[:8]}"
    # 3) 主线从没这条路径 → 唯一;否则同路径主线有更新版 → 更旧草稿(再用 blob diff 定性方向)
    os.unlink(tf.name)
    if not hist:
        return "UNIQUE-UNMERGED", "主线 HEAD 与历史都没有此路径"
    return "OLDER-DRAFT", "同路径主线有更新版本(用 diff --stat <HEAD> -- <f> 定性)"
for name in TREES:
    tree = os.path.join(WTROOT, name)
    lines = subprocess.run(("git", "-c", "core.quotePath=false", "-C", tree,
                            "status", "--porcelain", "-uall"),
                           capture_output=True, text=True, errors="replace").stdout.splitlines()
    tally = {}
    for line in lines:
        rel = line[3:].split(" -> ")[-1].strip('"')     # 兼容 ?? / M / A / R 的箭头形态
        if rel.split("/")[0] in SKIP_TOP:
            continue
        b, why = bucket(rel, tree)
        tally.setdefault(b, []).append((rel, why))
    g = lambda k: len(tally.get(k, []))
    print(f"== {name}: IN-MAINLINE={g('IN-MAINLINE')} UNIQUE-UNMERGED={g('UNIQUE-UNMERGED')} "
          f"OLDER-DRAFT={g('OLDER-DRAFT')} OTHER={g('OTHER')}")
    for b in ("UNIQUE-UNMERGED", "OLDER-DRAFT", "OTHER"):
        for rel, why in tally.get(b, []):
            print(f"    {b}: {rel}  # {why}")

报告里必须逐桶给计数 + 判定证据(实测:verify-task16 IN-MAINLINE=6 / UNIQUE=0;verify-batch IN-MAINLINE=35 / UNIQUE=1;und-attr IN-MAINLINE=0 / UNIQUE=1)。两个非空桶再各钉一条命令把证据坐实:

# 「唯一未合并」是真更新还是更旧草稿?blob 级 diff + 逐行看方向
git -c core.quotePath=false -C "$MAIN" diff --stat "$MAIN_SHA" -- simulator/tests/README.md
diff <(git -C "$MAIN" show "$MAIN_SHA":simulator/tests/README.md) "$WTROOT/verify-batch/simulator/tests/README.md"
# 「树顶提交要不要保」只看一件事:是否已并入 HEAD。别拿「目录很久没动」当判据
for c in 77779d49 1d4b6a17; do
  git -C "$MAIN" merge-base --is-ancestor "$c" HEAD && echo "$c MERGED" || echo "$c NOT-merged"
done

实测:那份 simulator/tests/README.md 树内写「27 页 × 3200 B」、主线 HEAD 已是「60 张 × 3200 B」→ 更旧草稿(被取代的 早先版本),不是未合并成果;bench-21-77779d49 / bench-23-1d4b6a17 两棵 merge-base --is-ancestor 判 MERGED 且脏数 = 0 → 保留不动(不属本节,等属主自己收,报告里点名说清为什么留)。 回收判据不是「看着旧」:唯一内容如果没被提交过,git log 里根本看不见它;反过来「提交已 MERGED」也不等于可以拆—— 还得看未跟踪那一层。UNIQUE-UNMERGED 非空又确实要留 → 它进第 4 步的备份,不许靠 --force 顺手带走。 3. 拆前扫联接 / reparse point(物理安全性 = 铁律 5 的可执行判据)。存成 ci-artifacts/wt_junction_scan.py 再跑:

# 只读预扫:一个路径是不是 reparse point(Windows 目录联接在这里现形);发现即停止深入,绝不 walk 进联接
import os, ctypes
FILE_ATTRIBUTE_REPARSE_POINT = 0x400
GA = ctypes.windll.kernel32.GetFileAttributesW
def is_reparse(p):
    a = GA(p)
    return a != -1 and bool(a & FILE_ATTRIBUTE_REPARSE_POINT)
for name in ["verify-task16", "verify-batch", "und-attr", "bench-21-77779d49", "bench-23-1d4b6a17"]:
    top = os.path.join(r"D:\Code\DTU Code\_wt", name)
    hits, entries = [], 0
    for root, ds, fs in os.walk(top):
        bad = [d for d in ds if is_reparse(os.path.join(root, d))]
        if bad:
            hits += [os.path.join(root, d) + "/" for d in bad]
            ds[:] = []                    # 有联接就不再深入:防转进主树 .venv 把别人整棵树数一遍
        hits += [os.path.join(root, f) for f in fs if is_reparse(os.path.join(root, f))]
        entries += len(ds) + len(fs)
    print(f"== {name}  scanned entries={entries}")
    print("   REPARSE: none → 可直接 git worktree remove --force"
          if not hits else "   REPARSE:\n   " + "
   ".join(hits))

实测输出 == verify-task16 ... REPARSE: none(每棵的体积 / 条目数 + 零联接就是证据),而 bench-* 两棵各挂一个 .venv 联接 → 判 KEEP(本节不拆它们;将来真要拆,先 rmdir 摘掉联接再 remove)。 判据是「扫出来一个都没有」,不是「我记得没建过联接」:new --no-bootstrap 的树、别的手工 mklink /J 的树都可能带着指向主树的联接。 摘联接用 rmdir(只删联接本身;不是 rd /s,更不是 rm -rf——那会顺着联接删目标)。 非 Windows 环境改用 os.path.islink / find <tree> -type l(本项目是 Windows + Keil 工具链,按上面这条走)。 到这里才算「拆前唯一性自检 + 物理安全预扫」齐:--force 的安全前提 = 第 2 步 UNIQUE-UNMERGED 为 0 (或剩下的已全部进第 4 步备份)且这里 REPARSE: none。 4. 唯一内容先备份,再 --force 拆。UNIQUE-UNMERGED 与需要留档的 OLDER-DRAFT 一律先备份进主树 ci-artifacts/wt-leftover-backup/(ci-artifacts/ 已被 .gitignore 忽略 → 备份不会改变主树脏行数;仓库根不放一次性产物), 文件名 + 字节数必须打进输出当证据:

BK="$MAIN/ci-artifacts/wt-leftover-backup"; mkdir -p "$BK"
# 命名约定:<树名>__<原路径里的 / 换成 _>,反向可定位;台账备份统一后缀 .state-bak
cp "$WTROOT/verify-batch/simulator/tests/README.md"   "$BK/verify-batch__simulator_tests_README.md"
cp "$WTROOT/und-attr/scripts/check_nested_comment.py" "$BK/und-attr__scripts_check_nested_comment.py"
ls -l "$BK"   # 实测:verify-batch__simulator_tests_README.md 1769 bytes / und-attr__scripts_check_nested_comment.py 14827 bytes
for t in verify-task16 verify-batch und-attr; do
  git -C "$MAIN" worktree remove --force "$WTROOT/$t"; echo "$t remove_rc=$?"   # 不接管道才读得到码(见「读输出」)
done
git -C "$MAIN" worktree prune    # 顺带清 .git/worktrees 里的残籍

自检:备份条目数 = 第 2 步里判「要留档」的条目数,且每个都带字节数。这一步就是本节相对 D 第 4 步的增量—— D 的前提是「净室里只有自己的拷贝」,本节的树里混着别人在飞的东西,--force 是一次不可逆删除,先备份才谈得上安全。 5. 复验:主树一个字节没动 + 树确实没了:

git -C "$MAIN" status --porcelain -uall > ci-artifacts/wt-main-after.txt
wc -l < ci-artifacts/wt-main-before.txt; wc -l < ci-artifacts/wt-main-after.txt   # 两个数必须相同(实测 0 → 0)
diff ci-artifacts/wt-main-before.txt ci-artifacts/wt-main-after.txt               # 空输出 = 逐行一致
git -c core.quotePath=false -C "$MAIN" worktree list    # 已拆路径逐条消失;主树与判「保留」的树原样在列

行数不同 = 备份或摘除碰到了主线(多半是备份写进了未被忽略的目录,或漏摘联接)→ 立刻定位,别报「回收完成」。 第 2 步判「保留不动」的树还在 worktree list 里是预期结果,在报告里点名解释为什么留(附 MERGED 判据)。 6. 顺手清悬挂台账(branch / path 都已不存在的 _wt/.state/<ID>.json;它会让 $TW list 打出一行怪条目、并让下次 new 同 ID 报「任务 已存在」)。先给证据、后动手:

for j in "$WTROOT"/.state/*.json; do          # 台账位置随 DTU_WT_ROOT
  id=$(basename "$j" .json)
  br=$("$PY" -c "import json,sys;print(json.load(open(sys.argv[1],encoding='utf-8')).get('branch',''))" "$j")
  pp=$("$PY" -c "import json,sys;print(json.load(open(sys.argv[1],encoding='utf-8')).get('path',''))" "$j")
  git -C "$MAIN" rev-parse --verify "$br" >/dev/null 2>&1 && b=branch-OK || b=branch-MISSING
  [ -d "$pp" ] && p=path-OK || p=path-MISSING
  echo "$id  $b  $p   <- $j"
done

实测这条判据打出的正是 v5tool2 branch-MISSING path-MISSING:git rev-parse task/v5tool2-v11 报 fatal: ambiguous argument 'task/v5tool2-v11': unknown revision ...,台账 path 指的目录已无,git worktree list 里也没有这棵树。 三条齐(rev-parse fatal + path 目录不存在 + worktree list 无此树)才叫悬挂;缺任何一条都回到第 1~2 步查是不是真有东西要保。 然后备份 → 删 → 复跑两条 list 复验:

cp "$WTROOT/.state/v5tool2.json" "$BK/v5tool2.json.state-bak" && rm "$WTROOT/.state/v5tool2.json"
"$PY" "$TW" list            # 该条目消失(全空时打「(没有任务空间。用 new <ID> <slug> 建一个)」),echo RC=$? 应为 0
"$PY" "$TW" lock status     # build 空闲(TTL 1.0h)/ bench 空闲(TTL 6.0h)—— 证明清台账没牵连两把锁
ls "$WTROOT"/.state/locks/ 2>/dev/null   # 若该 ID 名下还有 .lock,先按 B 节释放,再删台账
  • 必须 cp 先备份再 rm:台账里存着 base / junctions / needs_bench / bench_pass,是真出纠纷时唯一的现场记录。
  • 悬挂条目按定义 path 已不存在 → 通常不占 MAX_CONCURRENT = 8 名额(A 第 1 步的「活跃」判据要求 path 目录还在), 但它把 $TW list 搅成怪输出、并永久挡住同 ID 复用;path 还在的占位/残留台账才占名额(早期为绕 B 节那个坑手写的占位台账就是这种)。两种都要清。
  • 仍不对这个 ID 跑 done / abort:清场会拿台账里的死 path / 死 branch 去做 git worktree remove 和删分支 (这正是 A 第 5 步说的「手工删目录留下的尾巴」,正解就是本步)。历史遗留的占位台账同理——活着时也别 done / abort,用完直接删文件(来历与现状见 B 末条)。