*本文是一次工程事故的复盘记录,命令与阈值为脱敏后的等价表述。*
一、事故现场:一次常规提交,被一道闸拦下
那天我在给一个多助手协作的工作区做功能交付,改完代码准备提交,提交被仓库的 pre-commit 钩子拦了:
✗ 拒绝提交:含代码/配置改动却未同批提交《开发变更日志》这是仓库的既有纪律(改代码必须同笔留痕),于是我打开那份变更日志准备补一条记录——
然后我看到了这个:
| 版本 | 行数 | 字节 |
|---|---|---|
| Git 里已入库的版本(HEAD) | 11434 | ~2.33 MB |
| 磁盘上的当前版本(工作区) | 80 | 6414 B |
git diff --stat给出:
1 file changed, 33 insertions(+), 11387 deletions(-)净删 11354 行。文件 mtime 显示这次变化发生在凌晨 3:05。
也就是说:这份累积了近两周、1.1 万行的项目日志,在两个多小时里一直是"只剩 80 行"的状态,
而没有任何人发现——直到一道无关的闸把我推过去看它。
二、形态诊断:这不是"删了文件",是"整档重写"
看一眼剩下的 80 行就能定性:
- 保留的部分=文件头(front matter)+ 刚刚新增的 5 条记录;
- 丢掉的部分=中间全部 11354 行历史;
- 顺带证据:文件头里还多出一段重复拼接的 front matter——这是典型的"把一份新内容整体写到文件开头"的残留。
这个形态说明:写入方没有做追加,而是新建了一份内容(头+自己那几条)然后整体覆盖。
如果它是缓存里的一份旧快照,丢的就是别人的历史;如果它是刚拼的新内容,丢的就是中间所有历史。
结论:这不是删除操作,是"整档覆写"(truncate + write)。
三、为什么极难发现:三道防线同时失效
复盘下来,这次事故之所以能存活两个多小时,是因为三件事同时不成立:
- 写入不报错:整档覆写的 API 调用是成功的(文件确实写进去了),没有任何异常。
- 没有运行期监控:仓库的检查都在提交时触发,而这份被破坏的文件一直没被提交——
没提交的破坏,提交时的闸看不见。
- 看的人只看到"头部正常":打开文件,头在、新记录在、格式合法。没有人会去数行数。
第 3 点尤其值得警惕:"看起来正常"是这类事故最有效的伪装。
四、抢救:从 Git 取回,只追加新内容
发现后我没有立刻提交(提交等于把删除固化),而是先做取证与分离:
# ① 先把"事故现场"原样留档(取证 + 回滚源) cp 开发变更日志.md 留档/开发变更日志-截断现场-<时间戳>.md # ② 取回权威正文 git show HEAD:开发变更日志.md > head版.md # ③ 找出"磁盘有、HEAD 没有"的段落(按 `## ` 标题切块) # 这些是事故期间新增的记录,必须保留 # ④ 拼装:HEAD 正文 + 只把新段落追加到末尾抢救的关键取舍:只追加、不改头、不排序、不删任何行。
修复后是11475 行(比 HEAD 多 41 行 = 那 5 条新记录),git diff从"删 11387 行"变成"纯新增"。
落盘前我用了五条断言把自己钉住,任何一条不过就不写:
① 行数 ≥ HEAD 行数 ② 5 条新记录各恰好出现 1 次 ③ 文件头逐字节保留 ④ HEAD 尾部逐字节保留 ⑤ 纯新增、零删除这一步是整篇的核心纪律:修复动作本身也必须可核验。 否则"抢救"就变成"第二次覆写"。
五、根因:追加式操作被写成了覆盖式操作
事后追查写入方,根因是共享文档的写入通道被用错了:
- 该文档的纪律是append-only(只追加);
- 实际实现里,写入方从"当前内容"出发,但拿到的"当前内容"不是现读磁盘,
而是某处缓存/拼接结果,于是最终落盘的是"缓存内容 + 我的新内容"整体覆盖;
- 一旦缓存落后于磁盘(在多写者场景里几乎必然发生),中间那段历史就被静默抹掉。
这不是手滑,是机制缺陷:只要"整体覆盖"这条路径存在,多写者环境下它迟早会被踩到。
六、防线:把"不可逆后果"变成"提交时必红"
事故之后,我们给仓库补了一条机检判据(落在 pre-commit 钩子里),思路是
只做算术、不依赖语义解析:
对共享文档的候选版本 vs HEAD: 净删 = HEAD行数 - 候选行数 留存% = 候选行数 * 100 / HEAD行数 若 净删 ≥ 20 且 留存% ≤ 60% ⇒ 判为「整档截断」,拒绝提交阈值刻意取得很宽(正常的追加、搬行、轮转归档都远达不到 60% 留存线),
实际事故样本的留存率是0.7%,一眼可分。
为什么坚持"纯算术"?因为它绕不过去:任何靠"识别标题/语义"的判据都可能被格式变化绕过,
而行数比大小无法被伪装。
配套的两条纪律(比判据更重要)
- 写共享文档一律 append-only:只追加、不重排、不"顺手清理";
改完核对「字节增量 = 本次预期增量」,增量为负或行数减少就立即停手。
- 修复也要留证:先留事故现场,再从权威源(这里是 Git)取回,改动前后逐字节比对。
七、给"用 AI 助手管仓库"的几条实操建议
- 版本控制是唯一救命的兜底:这次能救回来,全靠那份 1.1 万行在 Git 里。
如果它从未入库,损失的就不是"两小时的紧张",而是全部历史。
—— 由此还引出一条:长期不入库的"共享文档",等于没有兜底。
- 把检查放在能看见的位置:运行期破坏需要运行期检查。
光有提交时闸不够——它只能拦住"把破坏提交出去",拦不住"破坏正在发生"。
我们后来补了周期性体积监控(行数/哈希),就是为了补这一段。
- 给不可逆操作一条"必红"判据:能自动判的,别指望人眼。
"没人发现"从来不是失误的原因,是没有判据的结果。
- 写共享文档的代码,先问一句:这条路径是"追加"还是"整体覆盖"?
如果答案是后者,要么改造它,要么给它加一道行数守恒断言。
八、结语
这次事故最值得记住的一点,不是"1.1 万行差点全丢",而是:
它被发现的唯一原因,是一道跟我们想找的问题完全无关的闸。
如果没有那道闸,它会安静地待在那里,直到某个更需要那份历史的人来打开它。
所以真正值得投入的,不是"更小心",而是让不依赖注意力的判据去站岗。