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

204 lines
15 KiB
Markdown
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.
### 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` 的全集,再**逐棵**记三件事(脏行数含未跟踪 / 树顶提交 / 有无分支)
加一条台账判定,同时存下主树的拆前基线清单:
```bash
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 步。
2. **逐文件与主线 HEAD 比内容并分三桶**:`IN-MAINLINE`(内容等价,拆 = 零损失)/ `UNIQUE-UNMERGED`(主线没有这份
内容,拆 = 丢)/ `OLDER-DRAFT`(同路径主线已有更新版本)。落成独立只读探针跑(别用 heredoc 现写现改,
见陷阱「别用 heredoc 打补丁造探针」):
```python
# 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)。两个非空桶再各钉一条命令把证据坐实:
```bash
# 「唯一未合并」是真更新还是更旧草稿?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` 再跑:
```python
# 只读预扫:一个路径是不是 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` 忽略 → **备份不会改变主树脏行数**;仓库根不放一次性产物),
**文件名 + 字节数必须打进输出**当证据:
```bash
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. **复验:主树一个字节没动 + 树确实没了**:
```bash
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 报「任务 <ID> 已存在」)。先给证据、后动手:
```bash
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 复验**:
```bash
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 末条)。