解读 `verify-reapply-patches` 基线漂移防护:通过 `pristine_hashes` SHA-256 校验消除更新后的误报失败
.changeset/vivid-eagles-climb.md 记录了 get-shit-done(GSD)中的一次关键缺陷修复(Bug #3657 / PR #3767):当 gsd-pristine/ 快照在备份之后被刷新到更新版本时,补丁重放校验器 verify-reapply-patches 会因拿错误的文件作为 diff 基线而把上游删除的内容误判为「用户新增行丢失」,产生大量假失败。本文将结合仓库中的 校验器实现、重放工作流 与 回归测试,完整还原这一问题产生的根因、修复机制、结构化的报告格式以及工作流中的集成方式,帮助你理解 GSD 如何在「防误报」与「绝不放过真实丢失」之间取得平衡。
一、背景:GSD 更新、本地补丁备份与三路合并
get-shit-done 是一套面向 Claude Code 的轻量 meta-prompting / 规范驱动开发系统,通过 /gsd:update 更新自身时会整体重装文件。如果用户直接改动过 GSD 的文件,安装器(bin/install.js 中的 saveLocalPatches)会在重装前按「哈希比对发现文件被修改」为依据,把修改过的文件备份到配置目录下的 gsd-local-patches/,随后用 /gsd:update --reapply(见 更新工作流 与 重放工作流)把用户改动重新合并进新版本。
这个合并过程依赖三路比较:pristine baseline(更新前 GSD 原始未改动版本)、user-modified backup(用户改动后的备份)和 newly installed version(重装后的新版本),从而区分「用户自定义(要保留)」与「版本漂移(要接受)」。
要让三路比较成立,必须拿到与备份同代的 pristine 基线。为此仓库维护了两类配套数据:
gsd-local-patches/backup-meta.json:备份元数据,其中pristine_hashes字段记录了每个被备份文件在更新前版本的 SHA-256(以相对路径为 key,正斜杠分隔,便于跨平台查找)。gsd-pristine/:安装器通过populatePristineDir把当前安装的原始文件(经copyWithPathReplacement安装变换管线)暂存后复制而来,供合并时作为基线快照(参见 install.js 中该 helper 的定义与saveLocalPatches的调用)。
问题恰恰出在「备份」与「重放」之间隔着多次 GSD 更新:一旦 backup-meta.json 记录的基线版本早于磁盘上当前 gsd-pristine/ 快照版本,合并与校验就会被错误基线误导。
二、Bug #3657 根因:旧备份 + 新 pristine 导致 diff 反转
verify-reapply-patches 的校验模型是:用「用户新增行 = diff(备份文件, pristine 基线)」反推必须存活的行,再去已合并的安装文件里逐个确认它们存在(源码 computeUserAddedLines)。
当磁盘上的 gsd-pristine/ 在备份之后被刷新到更新版本时,diff 的方向就被反转了:
- 备份文件基于 v_old 版本捕获;
- 磁盘 pristine 却是 v_new,v_old 中某些被上游删掉的行在新基线里不存在;
- 于是这些「上游在新版本删掉的行」被
computeUserAddedLines计算成「用户新增行必须存活」; - 而它们本来就不该出现在新版本合并结果里,校验因此对每一行被上游删除的内容都报告
fail_user_lines_missing,即使真实存在的用户自定义已经完好存活。
这就是 changeset 里说的 spurious failure:不是用户丢内容,而是基线版本错位引发的假报警。校验器源码的注释(见 verify-reapply-patches.cjs 中 REASON 枚举与 verifyFile 内关于 Bug #3657 的长注释)把这条推理链写得非常明确。
三、修复机制:校验 pristine 的 SHA-256,失配则跳过而不是误判
本次修复(changeset 主题、PR #3767)没有引入新参数,而是让校验器在每次把 pristine 当作 diff 基线之前,先验证它确实是 backup-meta.json 里记录的那个版本:
main()一次性读取pristine_hashes:通过 readPristineHashes 解析patchesDir/backup-meta.json;文件缺失、不可读或没有pristine_hashes字段时返回空对象——空哈希表表示「没有任何文件可做哈希校验」,不是错误。verifyFile()对每个待校验文件做哈希比对(核心逻辑见 verify-reapply-patches.cjs):- 哈希匹配:磁盘 pristine 与备份同代,作为正确基线参与 diff;
- 哈希失配:说明磁盘
gsd-pristine/已被后续 GSD 更新刷新过。此时不使用该 stale pristine,直接把文件以status: ok+ reasonok_pristine_drift_detected上报并返回——绝不基于错误基线生成假失败列表; - 无记录哈希(旧版安装器未写
backup-meta.json):维持修复前行为,直接用磁盘 pristine,保证向后兼容。
实现细节值得一提:
- 哈希用 Node
crypto的sha256对文件 UTF-8 内容求 SHA-256 hex; - 查找 key 前把
relPath的反斜杠统一替换成正斜杠(relPath.replace(/\\/g, '/')),以兼容 Windows 下path.join产生反斜杠而backup-meta.json存正斜杠的差异; - 若 pristine 目录/文件缺失或不可读,则回退到 over-broad 模式:把备份文件里每个「有意义的行」都当作必须存活的行来检查。over-broad 模式可能偏严(偏好假阳性停机),但永远不会因为另一个原因静默放行丢失内容——这正是 #2969 所要求的错误倾向方向。
四、稳定 reason 码与 JSON 报告结构
校验器的诊断面是一个被 Object.freeze 锁定的枚举 REASON,共 7 个稳定 code:
| code | 含义 |
|---|---|
ok_no_user_lines_vs_pristine |
备份与 pristine 完全一致,无用户行可校验(非失败) |
ok_no_significant_backup_lines |
无 pristine 时备份也没有有意义的行(非失败) |
ok_pristine_drift_detected |
磁盘 pristine 哈希与记录不符,跳过该文件(非失败,#3657 新增) |
fail_installed_missing |
安装文件不存在 |
fail_installed_not_regular_file |
安装路径不是普通文件 |
fail_read_error |
备份/安装文件读取失败 |
fail_user_lines_missing |
存在用户新增行未出现在合并结果中(唯一的真实失败) |
drift 不等于 failure:该校验器进程退出码仍为 0,但输出中新增了顶层聚合字段,便于上层工作流区分处理(Bug #3657 Finding 1):
{
"checked": 3,
"failures": 0,
"drifted": 1,
"drifted_files": ["agents/gsd-executor.md"],
"results": [
{ "file": "agents/gsd-executor.md", "status": "ok", "missing": [], "reason": "ok_pristine_drift_detected" }
]
}
- 每条 per-file 结果结构不变(
status/missing/reason),保持向后兼容; drifted(数字计数)与drifted_files(相对路径数组)为纯增量字段,干净运行(无任何漂移)时也恒存在且分别为0与[]。
五、命令行使用方式与退出码
校验器为无依赖的 Node 脚本,位于仓库 get-shit-done/bin/ 下(安装后位于 ${GSD_HOME}/get-shit-done/bin/,亦可通过 SDK 的 sdk/dist/cli.js verify-reapply 暴露):
node get-shit-done/bin/verify-reapply-patches.cjs \
--patches-dir <path> # gsd-local-patches/
--config-dir <path> # ~/.claude(或运行时等价配置目录)
[--pristine-dir <path>] # gsd-pristine/;缺省时退化为 over-broad 启发
[--json] # 输出结构化 JSON 而非人类可读文本
退出码约定:
0:所有用户新增行均存活,门禁通过(含「检测到 drift 并跳过」的场景);1:至少一个文件存在真实缺失(fail_user_lines_missing等失败 reason);2:用法/结构错误(如缺少--patches-dir/--config-dir、补丁目录或配置目录不存在、未知参数)。
参数解析与各失败路径的健壮性设计在源码 parseArgs 与 verifyFile 的 installed-path 检查段有完整注释说明:installed 路径必须存在、必须是普通文件、必须可读,任何一项不满足都以带诊断的 fail 结果返回,而不是直接让整个门禁崩溃丢失结构化输出。
六、工作流集成:Step 5a 的 drift 检查门
重放工作流 的 Step 5「Hunk Verification Gate」是双层门禁:
- 5a:确定性校验器(binding gate,#2969)。此前 Step 5 依赖 LLM 自由文本的
verified: yes/no逐 hunk 自报,而 #2969 追踪到大量「实际丢内容仍填 yes」的假通过,因此改为用脚本做结构化的逐行子串检查、任何 miss 都非零退出。 - 5b:Hunk Verification Table 复核(advisory gate,#1999)。以 Step 4 生成的必填表格作为纵深防御,防止脚本本身出 bug 或 pristine 缺失时回归被静默放行。
本次修复为 5a 增加了 drift check(Bug #3657 Finding 2)。关键点在于:verify-reapply-patches 检测到 drift 时退出码是 0,仅仅看退出码会漏掉「文件被跳过、未真正校验」这一事实,所以工作流改为解析 JSON 顶层字段:
DRIFTED_COUNT="$(echo "$VERIFY_OUTPUT" | node -e "...String(d.drifted||0)...")"
DRIFTED_FILES="$(echo "$VERIFY_OUTPUT" | node -e "...(d.drifted_files||[]).forEach(...)...")"
随后执行判定:若 DRIFTED_COUNT > 0,STOP 并 HALT——置 DRIFT_DETECTED=true、exit 1,不得进入 5b 或清理步骤,同时给出三条可选处理路径:
- (a) 把 pristine 快照重新锚定(re-anchor)到
backup-meta.json记录的那一版; - (b) 从备份恢复受影响文件、手工重新合并用户自定义(
cp {patches_dir}/{file} {installed_path}); - (c) 若接受上游变更,把 drift 文件的
pristine_hashes项更新为当前磁盘哈希,再跑/gsd:update --reapply用刷新后的基线复检。
值得强调的是 drift-check 在源码中被结构性约束在 If VERIFY_STATUS is non-zero 检查之前执行(因为 drift 即便在退出码为 0 时也会出现),这一顺序也由测试强制锁定。工作流 Step 2 还提供了另一条更可靠的 git-aware 基线路径:当配置目录是 git 仓库(HAS_GIT=true)时,用 pristine_hashes 里记录的哈希去历史提交中定位 blob SHA-256 匹配的基线 commit,从而绕开「磁盘快照被多轮更新覆盖」的问题。
七、回归测试如何证明「不误报、也不放过」
bug-3657-verify-reapply-patches-pristine-drift.test.cjs 用真实可运行的 fixture(临时目录 + 构造 patches/installed/pristine + backup-meta.json)对修复做了成体系验证:
- 核心回归:磁盘 pristine 为 v_new(哈希失配)、备份含 v_old 行 + 用户真实自定义行、合并结果包含 v_new 行 + 用户自定义行 → 断言退出码
0、failures: 0、per-file reason 恰为OK_PRISTINE_DRIFT_DETECTED、missing为空; - 反假阴性(counter-test):pristine 哈希匹配但用户行真实丢失 → 仍必须
exit 1并报fail_user_lines_missing,证明哈希保护不会压制真实失败; - 无
backup-meta.json(旧安装器场景):保持修复前行为,照常 diff 并捕获丢失行; - 多文件混合:一个 drift 文件 + 一个真实失败文件 → 恰好 1 个 failure,drift 与真实失败的判定相互独立、按文件隔离;
- REASON 枚举 shape-lock:用
Object.keys(REASON).sort()深比较锁定完整 7 码集合,任何后续新增都要求同步更新该断言; - 报告结构(Finding 1):单文件/多文件 drift 时
drifted计数与drifted_files聚合正确、干净运行恒为0/[]; - 工作流结构(Finding 2):断言
reapply-patches.md源文件包含Step 5a: drift check区块、引用DRIFTED_COUNT与drifted_files、设置DRIFT_DETECTED信号,且该区块必须出现在If VERIFY_STATUS is non-zero检查之前。
同一主题在仓库中还有多组配套测试可互相印证:tests/bug-2969-verify-reapply-patches.test.cjs(确定性门禁替代 LLM 自报)、tests/bug-2424-reapply-patches-baseline-detection.test.cjs(基线检测)、tests/bug-2998-pristine-dir-populated.test.cjs(gsd-pristine/ 由安装器真正落盘),共同构成从「备份-基线-合并-校验」的完整证据链。
八、小结:这套修复的设计取舍
回顾整个修复,可以提炼出几条清晰的工程原则:
- 校验器绝不信任「当前磁盘状态天然正确」——pristine 是否与备份同代,必须由
backup-meta.json里记录的 SHA-256 来裁决; - 假报警要避免,但只能以「不改变错误方向」的方式避免——drift 时跳过 stale pristine 并回退 over-broad 模式,宁可偏严也不让真实丢失静默通过;
- 门禁信号必须结构化——用冻结的 reason 枚举 + 顶层
drifted/drifted_files字段代替散落文本,让上层工作流(和测试)能够精确区分「失败」与「跳过待处理」; - 纵深防御——确定性脚本(5a)与人工/LLM 复核表(5b)互为兜底,偏好「可恢复的假阳性停机」而非「不可恢复的静默成功」。
如果你的开发环境同样面临「本地修改回填 + 上游频繁刷新基线」的场景,这套「备份时记录基线哈希、比对前先验哈希、失配即降级并显式上报」的模式是一个可以直接借鉴的实现范式;相关的完整代码、测试与工作流说明均可在本仓库的 verify-reapply-patches.cjs、reapply-patches.md 与 bug-3657 回归测试 中持续追踪。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0629
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python07
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00