ecf9d3ad3d
围栏腐坏是真 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、单个尾换行。
204 lines
15 KiB
Markdown
204 lines
15 KiB
Markdown
### 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 末条)。
|