# 复盘：Sync B 退役 —— 共享 git index 的回退坑 + 「自己新建的闸」的盲点

> 日期：2026-07-30 · 触发：Wrap-up 协议 §Retrospection「学到通用工程经验 / 发现新 pattern」= 必写
> 事实真源：[`_handoffs/2026-07-30-sync-b-retirement-classification.md`](../_handoffs/2026-07-30-sync-b-retirement-classification.md) §1–§9 · backlog **INFRA-F81**
> 本文只记**可迁移的经验**（教训与验证过的好方法各占一半），不重复叙述做了什么。

---

## 一、教训

### L1 — plumbing 提交（`commit-tree` + `update-ref`）必须紧跟 index 刷新，否则等于给并行 session 埋雷

**发生了什么**：`.husky/pre-commit` 的工作树里同时有我的一行改动和并行 session 正在写的 I18N-01 触发块。为了不把别人的 in-flight 改动卷进我的 commit，我用临时 `GIT_INDEX_FILE` + `git commit-tree` + `git update-ref` 精确提交（`e35fb122`）。**问题出在下一步没做**：plumbing 更新了 HEAD，却没有刷新**共享主 index** —— 于是 index 里那几个文件仍停在 e35fb122 之前的内容，相对新 HEAD 呈现为「**一次已 staged 的回退**」。并行 session 随后一次裸 `git commit`（`629a5990`）就把这些幻影条目提交了，**静默回退了我 75 行新单测 + hook regex 修复 + handoff 两行**。我识别到风险并 `git reset` 清了 index，但晚了一步。

**根因不是手滑，是机制**：`git commit -- <显式路径>` 与 plumbing 都**不更新 index**。仓库里既有的纪律「commit 用显式 pathspec」只解决了「我不卷别人」，没解决「我不给别人留雷」——这是同一个共享 index 的**另一半**。

**改成什么**：任何绕过普通 `git add`+`commit` 的精确提交（plumbing / `commit -- <path>`）**后面必须紧跟 `git reset -- <那些路径>`**，让 index 与新 HEAD 同步；然后 `git diff --cached --name-only` 应为空。本 session 后续三次提交都照此做，并在 commit message 里留了机制说明。

**检测手段**：`git diff --cached --name-only` 非空且内容看起来像「把我刚提交的东西反着改回去」= 幻影 staged revert，立刻 `git reset -- <路径>`。

### L2 — 自己新建的机械闸，最容易被自己漏检的正是「调用面」而不是「判据」

**发生了什么**：本轮新建 `scripts/check-designsync-upload-safety.mjs`（拦「会覆盖策展内容的上传路径」）。5 条判据我写得很仔细、单测正反例齐全，**全量终审却在它自己身上抓出两个假绿入口**：

1. `pnpm check:upload-safety --` —— pnpm 把字面 `--` 透传进 `argv`，于是 `paths.length === 0` 这个空输入守卫**永不成立**，脚本对着一条 `'--'` 打印「✓ PASS」+ exit 0。而 `--` 恰恰是文档推荐的调用形态，`$PATHS` 展开为空时就正好落进这个洞。
2. 路径形态未归一化 —— `ds-bundle/styles.css`、`./styles.css`、绝对路径一律 exit 0。而 DesignSync `write_files` 的入参本身是 `{path, localPath}` 两种路径并列，**粘错那份清单就能绿灯覆盖**被保护的 `styles.css`。

收尾 self-audit 又发现第三面：形态校验只挂在 CLI 分支，**库 API `checkPaths()` 仍形态盲**（程序化调用照样拿到 `[]`）。

**可迁移的结论**：给闸写单测时，天然会围绕「判据对不对」构造用例；而假绿几乎都出在**判据之外**——参数怎么进来（透传符号、空展开）、输入形态对不对（本地路径 vs 远端路径）、以及**同一个判据有几个调用面**（CLI / 库 API / hook）。所以新建闸的验收应当至少包含三问：① 空输入 / 畸形输入会不会被当成「没问题」？② 输入的**形态**错了（对的值、错的坐标系）会怎样？③ 这个判据有几个入口，**每个入口都过了吗**？

### L3 — 面向远端写的说明文字，其「指向」会随外部改名而失效

远端 B 的 `ARCHIVED.md` 与 README 横幅原本写「唯一在维护的是 **TVU Networks Design System**」。owner 当天在 UI 改名后，这个名字变成了**另一团队**项目的名字（`019dfb04`），于是那段话开始把读者往错的项目送。**改成按 `projectId` 认**（名字只作辅助），并在两处远端文件里明写「Go by the project id, not the name」。

**可迁移的结论**：任何写给「未来读者」的指路文字，**指针要用不可变的键**（id / hash / 路径），不要用可被外部随时重命名的显示名；显示名可以写，但必须与 id 并列。

---

## 二、验证过确实管用的做法

### M1 — 「四桶 + 计数等式」作为防遗漏的**机械**口径

要求 `已在目标 + 可再生 + 已放弃 + 独有 = 总数`，桶和不等于总数就不许进下一步。它把「我看了一遍觉得没漏」变成可复核算式；终审独立重算完全一致，并额外验出 `OTHER = 0`（没有文件落在四桶之外）。**下次遇到「迁移 / 退役 / 合并，要求不能丢东西」的任务直接复用这个形状。**

### M2 — 判「搬不搬」先取**目标侧的形态证据**，而不是先估工作量

结论是 114 条独有产物全不搬，但依据不是「搬起来麻烦」，而是目标项目的活源证据：`_ds_manifest.json` 全文零引用 `.prompt.md`/`.d.ts`、组件卡只加载 4 个资源且走预编译 bundle。**证据在先，结论在后**，并且判「不搬」时**强制显式登记能力损失 + 替代面**，不允许「没提到就算没了」。

### M3 — 删机制与改数据必须**同批**，且先造失败态证明闸不是空过

删 `emit-reference-to-sync-b.mjs` 会让 `audit:reference-numbering` 的 R2/R5 变红（registry 里还登记着它）。做法是：先临时移走脚本、跑闸、**看到 exit=1 且报 `[R2]`**，再同批改 registry + 删脚本。这一步的价值不在「让闸绿」，而在**证明这条判据真的在把关**。

### M4 — 归档用「只加标记、不删文件」，可逆性写进文件本身

`finalize_plan` 的 `deletes` 恒为 `[]`；README 用「原文 + 横幅」而不是覆盖；写完逐文件 `get_file` 回读亲验。附带收获：**回读顺便证明了本地对照物与远端原文字节相同**（写入前做不到，因为 `get_file` 只把内容返回 agent 上下文、落不到磁盘 —— 这条方法学限制已写进 handoff §7.2）。

### M5 — 让终审去审**自己刚建的机制**，而不是只审业务改动

本轮最有价值的一条 finding（L2）来自把「新建的闸」本身放进全量终审的范围。若终审只对着「Sync B 退役做完了没」检查，那两个假绿会带着「已加固」的错觉留在仓库里。

---

## 三、留给后续的残余

见 backlog **INFRA-F81**：per-component prompt/dts 那一层的能力损失（已登记）· `.ds-sync` 两个 ephemeral 补丁可能成死重 · 上传安全检查**没有「目标项目」概念**（清单按 A 写，对 B 的写入会误报，方向偏保守故刻意不改）。B 本身按 owner 决定**保留作备份、不删除**（不更新 / 未 publish / 他人不可见）。
