【免费下载链接】gsd-core
Git. Ship. Done - Core
本指南围绕 gsd-core 的worktree cleanup-wave清理机制展开,讲解其在一波并行 executor 收尾时,如何正确对待「工作树目录已被 harness 提前移除」这一常规情形:合并照常进行、陈旧的管理条目由 teardown 剪除,而不是让整波清理失败。读完你将掌握 cleanup-wave 的身份(identity)与移除(removal)判定边界、波前快照的作用,以及该行为对应的源码实现与测试证据。
引言:一次修复改变了整波清理的语义
在 gsd-core 的 execute-phase 与 quick 工作流中,多个 executor 以 git worktree 形式并行运行,每个 executor 完成后,编排器(orchestrator)需要把其分支合并回主线、再清理对应的 worktree 与临时分支。这一收尾动作被收敛为 SDK 命令worktree.cleanup-wave(调用点、quick 工作流),调用时以--manifest传入一波 executor 的清单,并以|| exit 1保证失败闭合(fail-closed)。
本次修复(changeset 类型Fixed,关联 PR 4612)解决了一个此前会让整波清理「卡死」的边界情形:当某个条目对应的 worktree 目录已被 harness(如 Claude Code 在子代理完成后立即清理其工作树)提前移除时,旧逻辑因git -C <gone-path>读取失败而将其误判为branch_mismatch并阻断合并,导致该条目无法合回主线,且其余条目也连带被搁置。修复后,只要 git 的注册信息仍把该路径绑定到清单所声明的分支,且该路径经statSync确认确实已消失(ENOENT),该条目就会被正常合并,其陈旧的管理条目由 teardown 阶段的git worktree prune剪除。
两个问题、两个证据源:身份与移除必须分开回答
cleanup-wave 对每个条目都要回答两个截然不同的问题,而源码 worktree-safety.cts 明确注释了「两个问题、两个来源,每个只回答它真正能证明的那一个」:
- 身份(IDENTITY)——「注册在这个路径上的 checkout 是不是清单所声明的分支?」答案来自
git worktree list --porcelain。git 永远不会丢掉这条绑定:即使目录被rm -rf,porcelain 输出仍会打印worktree <path>与branch refs/heads/<branch>。 - 移除(REMOVAL)——「这个目录是真的没了,还是仅仅不可读?」答案来自
statSync的 errno。只有ENOENT才代表「已移除」;EACCES、EIO等其他 errno 一律按「不可读」处理,照旧阻断。
这两个来源互不越权,并且两个方向的误判都被实测验证过(源码注释中明确标注「measured rather than reasoned about」):
- 早期实现用
fs.existsSync返回 false 推断移除,并在没有 checkout 可读时回退到refs/heads/<branch>做身份判定,这把身份从「注册在此的 checkout 在该分支上」弱化为「存在一个同名的分支」,从而可能放行一个无关的兄弟分支。 prunable并不是移除测试:实测中,当父目录权限为 000 时,git 会对一个仍然存在的 checkout 输出prunable gitdir file points to non-existent location——它无法遍历父目录,因而误报 gitdir 文件缺失。若把prunable当作「已移除」接受,就会在一个不可读的 worktree 上合并未提交的工作,而这正是 dirty 检查要拒绝的场景。statSync能把二者分开:ENOENT 是「没了」,EACCES/EIO 是「不可读」。
代码层面的实现即confirmedGone(worktree-safety.cts):它只认errno === 'ENOENT',路径能成功stat即视为存在,任何其他 errno 一律视为不可读并保持阻断。
波前快照:一次 prune 不会搁置其余条目
git worktree prune是仓库级的维护操作,它会清除所有陈旧的管理条目,而不只是当前这一个。实测表明:两个已移除的 worktree,执行一次prune,两个注册条目都会消失。因此,如果每个条目都在循环内重新读取git worktree list,那么第一个被 harness 移除的条目完成 teardown 后,后续条目的注册证据已经没了,会被误判为branch_mismatch而全部阻断——这比修复前的 bug 更糟,因为「一波并行 executor」恰恰是常态。
修复的关键设计是惰性波前快照(worktree-safety.cts):
- 在第一个真正需要身份判定的条目处,一次性读取
worktree list --porcelain并缓存为worktreeListSnapshot; - 之后整波条目复用这份快照,快照早于任何 teardown 的 prune,因此每个条目的注册信息都以「剪除之前」的状态为准;
- 惰性(lazy)保证 happy path 不额外开销:一波 worktree 全部在场时,根本不会触发这次子进程读取。
测试 worktree-safety.test.cjs 精确覆盖了这一点:两个条目,第一个已被移除(触发 prune),第二个仍要合并——断言两个条目的状态都是merged_removed,pending为空。
缺失目录的接受路径:合并照常,警告照发
当git -C <worktree> rev-parse --abbrev-ref HEAD读取失败时,修复不再直接阻断,而是先询问 git 与文件系统「为什么」:只有「git 仍把该路径绑定到清单声明的分支且目录经statSync确认 ENOENT」才放行(absentAndIdentified)。任何其他失败形态都是真实的失配:
- 该路径注册了别的分支——正是防分支调换(swap)控制要拦截的;
- git 完全没有列出该路径——清单点名了 git 无记录的东西;
- 路径能
stat成功,或stat失败但 errno 不是 ENOENT——checkout 存在或不可读,修复前阻断、修复后依旧阻断。
值得注意的是,接受路径是在失败点事后判定(disambiguate at the point of failure)而非提前预判:一个成功读取的 present worktree 走原有逻辑,分支不符照样branch_mismatch阻断,行为完全不变。
由于「harness 干净移除了已完成 executor」与「操作者或外部进程移除了该路径」在代码面前是同一个签名(git 注册仍在、statSync同为 ENOENT),接受常规情形若保持静默,会让非常规情形失去唯一的报警信号。因此接受必定伴随一条咨询性(advisory)警告ACCEPTED_ABSENT_WORKTREE(警告代码定义),它绝不作为闸门:条目照常合并,但会把 git 自己的prunable原因原文附上,供操作者调查。测试断言该警告携带detail: 'gitdir file points to non-existent location'而非转述(worktree-safety.test.cjs);git 某些版本只输出裸prunable标记时,detail为null(同文件 L2773-L2796)。
teardown 阶段的额外护栏:prune 而非强删,且须复查 ENOENT
接受为缺失的条目,在合并完成后走的是git worktree prune剪除管理条目,而不是git worktree remove --force——因为强删会删除可能已重新出现在该路径、且从未通过 rescue/dirty 检查的内容(worktree-safety.cts)。两个护栏值得注意:
- 删前复查:从身份判定到合并落地之间是一个宽窗口(base 门、deletion 门、scope 门、合并本身都可能耗时),worktree 可能在此期间重新出现。因此 teardown 前会再做一次
confirmedGone,若路径已重现,则报worktree_remove_failed并拒绝 prune 与删分支——修复前的 bug 只是阻断,这里要防的是不可恢复的状态破坏。 - prune 的副作用被快照吸收:注释明确纠正过「prune 只影响本条目」的错误说法——正因为 prune 会清掉同一波后续条目所需的注册证据,身份读取才必须前置为波前快照。
对在场的 worktree,teardown 仍是git worktree remove --force(锁定的先worktree unlock再重试);若 remove 因路径已消失而失败,则按absentAndIdentified判据决定是否改走 prune(worktree-safety.cts)。
完整判定流水线:一个条目的八道关卡
综合源码 executeWorktreeWaveCleanupPlan,一个清单条目在整波循环中依次经过:
- 身份判定:
git -C <path> rev-parse --abbrev-ref HEAD输出须等于清单branch;失败时按上述absentAndIdentified判据决定「接受为缺失」或branch_mismatch阻断(隔离)。 - base 校验:
git merge-base HEAD <branch>须命中expected_base或allowed_bases(#1265),否则base_mismatch阻断。 - 删除审计:
git diff --diff-filter=D --name-only HEAD...<branch>检出删除,仅清单declared_deletions声明过的路径被豁免,其余以branch_contains_deletions阻断(#3003)。 - scope 咨询:分支实际变更若超出
files_modified声明的范围,发出scope_out_of_declared咨询,不阻断(#2596)。 - SUMMARY 救援:把 worktree 下
.planning/*-SUMMARY.md未提交产物先复制回主线(git cat-file -e HEAD:<path>判定是否已提交),救援失败则summary_rescue_failed阻断(worktree-safety.cts)。 - dirty 检查:
git status --porcelain --untracked-files=all,已救援的 SUMMARY 路径从输出中过滤,剩余脏行以worktree_dirty阻断;此处同样在失败点用absentAndIdentified兼容「检查期间目录又被 harness 移除」的窗口。 - 合并:
git merge <branch> --no-ff --no-edit,超时判merge_timed_out(hook 运行时长的独立预算见 DEFAULT_MERGE_TIMEOUT_MS),失败先merge --abort再以rev-parse --verify MERGE_HEAD判定仓库是否仍处于合并中(repoRootStillMidMerge),是则把后续条目移入pending并中止整波(这是唯一的仓库级失败豁免,#2852);被 kill 的合并还要通过git reset --merge+stash pop --index恢复合并残留(restoreMergeResidue)。 - teardown:在场走 remove/unlock/remove,缺失走 prune,最后
git branch -D删分支;删分支失败降级为warning状态而非阻断。
每道失败关卡都遵循「隔离(isolate)」原则:单条目问题只影响该条目,其余条目继续独立评估;只有仓库级合并状态才break整波。
工作流中的实际调用方式
- execute-phase:编排器在每个 executor 返回后,用
gsd_run query worktree.record-agent把{agent_id, worktree_path, branch, expected_base, files_modified, declared_deletions}逐条写入WAVE_WORKTREE_MANIFEST(execute-phase.md),波次收尾统一调用:
gsd_run query worktree.cleanup-wave --manifest "$WAVE_WORKTREE_MANIFEST" || exit 1- quick:同样以
QUICK_WORKTREE_MANIFEST走worktree.cleanup-wave,且测试强制要求|| exit 1的失败闭合语义,SDK 的安全拒绝必须浮出水面,不得被软回退吞掉(worktree-cleanup.test.cjs)。
清单的初始化形态为{"orchestrator_root": "...", "worktrees": []}(也接受裸数组),由worktree record-agent做写时校验:字段缺失、分支不符合^(worktree-)?agent-|worktree-wf_命名空间、重复(worktree_path, branch)都会大声失败并给出恢复提示,确保写入即能被 cleanup-wave 读回(planWorktreeRecordAgent)。
验证矩阵:测试如何锁定新语义
worktree-safety.test.cjs 为本次修复建立了完整的判定矩阵:
| 场景 | 判定 | 测试位置 |
|---|---|---|
| 两个已移除条目,第一个触发 prune | 两个都merged_removed(快照生效) | L2720-L2742 |
| 缺失但注册匹配 + ENOENT | 合并 +ACCEPTED_ABSENT_WORKTREE咨询(带 git 原文) | L2744-L2771 |
裸prunable标记(无原因文本) | 接受,detail: null | L2773-L2796 |
worktree list读不到(exit 128) | branch_mismatch阻断(无注册证据即无身份) | L2798-L2809 |
| 真实缺失(git 标记 prunable) | merged_removed,不误伤 | L2811-L2824 |
结合 worktree-cleanup.test.cjs 对工作流层的约束(manifest 作用域、|| exit 1、record-agent 写时校验),本次修复形成了「SDK 判定 + 工作流契约 + 回归测试」三层闭环:常规的 harness 提前清理不再卡住整波,而任何真实失配(分支调换、不可读目录、未声明删除、脏工作树)依旧按原语义阻断——这正是本 changeset 全部四句语义承诺的落点。
小结
worktree cleanup-wave的本次修复把「目录没了」从「一律阻断」细化为「有条件接受」:身份继续由 git 自己的工作树注册信息提供(git worktree list --porcelain),移除只认statSync的 ENOENT;波前快照让一次仓库级prune不再搁置后续条目;接受路径伴随ACCEPTED_ABSENT_WORKTREE咨询警告并保留 git 原始原因;teardown 对缺失条目只 prune 不强删,且删前复查 ENOENT。整个判定链在 src/worktree-safety.cts 中可逐行追溯,并由 tests/worktree-safety.test.cjs 与 tests/worktree-cleanup.test.cjs 双向锁定。
【免费下载链接】gsd-core
Git. Ship. Done - Core
相关推荐
gsd-core 跨 Wave 依赖偏差下的 Worktree 收尾:cleanup-tail 片段解析与安全清理指南
gsd core 跨 Wave 依赖偏差下的 Worktree 收尾:cleanup tail 片段解析与安全清理指南 导读 :本文聚焦 gsd core 的并
gsd-core Worktree 清理安全加固:基于 per-wave Manifest 的失败关闭(fail-closed)机制解析
gsd core Worktree 清理安全加固:基于 per wave Manifest 的失败关闭(fail closed)机制解析 导读 本文聚焦 GSD
gsd-core Worktree 清理 CWD 漂移修复:manifest 持久化 orchestrator 根目录的源码级解析
gsd core Worktree 清理 CWD 漂移修复:manifest 持久化 orchestrator 根目录的源码级解析 Wave 清理逻辑不再因主工
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考