【免费下载链接】gsd-core
Git. Ship. Done - Core
本文聚焦 GSD Core 的/gsd:undo命令在--phase与--plan两种模式下提交选择逻辑的一次关键修复(issue #4465,随 PR #4472 以 changeset 记录):旧实现通过仓库级git log --all的 commit-subject 正则 grep 选择待回滚提交,存在跨里程碑污染与不可达分支误选等缺陷;新实现改为以目标阶段自身目录作为锚点(PHASE_START^..HEAD窗口),并在无法解析锚点时 fail closed。读完本文,你将掌握该提交选择窗口的完整推导过程、三条拒绝路径(归档解析、异仓库锚点、路径复用)的判定依据,以及底层select-revert-commits子命令的结构化解析原理,可直接据此评估与复现该修复的行为边界。
一、修复背景:旧选择的四个缺陷类
本次 changeset(.changeset/undo-bounded-commit-selection.md)记录的是一类Fixed变更:/gsd:undo --phase与--plan不再从不达分支选择提交,也不再锚定到早期里程碑使用过的目录上。其直接缺陷源于 gsd-core/workflows/undo.md 中两条手写的git log --oneline | grep -E ...管道。
在 src/undo-commit-selection.cts 的模块文档中,这一旧实现被归纳为四类已文档化的 bug(issue #4661):
- 正则注入:旧 grep 将未经校验的用户输入 id 直接插值进 ERE,
.与+等元字符会变成通配/量词,畸形或形状不同的 id 会静默匹配到错误阶段。 - 无锚定:grep 未锚定,一个只是"提及"某作用域的提交(如
docs(99-01): explain feat(03-01): commit convention)会被错误地当作该作用域的"声明"选中。 - 两种模式语法不一致:phase 模式的
\):后缀与 plan 模式缺失后缀,在同一个 breaking-change 提交(feat(03-01)!: breaking change)上产生分歧。 - "明显修复"是个陷阱:对
git log --oneline记录起点做锚定会静默丢弃fixup! .../Revert "..."包装提交(更糟的静默部分回滚),且在color.ui=always下返回零匹配(ANSI 码先于 sha)。
此外还有最危险的缺陷——选择没有里程碑边界与可达性边界:git log --oneline --no-merges --all的--all会遍历所有引用,因此一条HEAD不可达的提交也可能被选中并喂给git revert --no-commit,从而暂存对前一里程碑文件的删除。测试文件 tests/undo-commit-selection-4465.test.cjs 的负向对照组精确复现了这一行为:修复前选择行在multiMilestoneFixture上会把废弃分支的feat(03-02): abandoned experiment与已归档里程碑的feat(03-01): implement auth endpoint一并选中。
二、修复核心:以阶段目录为锚的提交窗口
新实现将 issue #3995 中已在 gsd-core/workflows/code-review.md 使用的PHASE_START锚点移植到--phase与--plan两种模式,并彻底移除--all,改为 fail closed。
锚点推导逻辑(两种模式共享,见 gsd-core/workflows/undo.md 的gather_commits步骤):
PHASE_START=$(git -C "${PROJECT_ROOT:-.}" log --format="%H" --diff-filter=A -- "${PHASE_DIR}" 2>/dev/null | tail -1) # 只有 HEAD 自身历史内的提交才能作为锚点 if [ -n "$PHASE_START" ] && ! git merge-base --is-ancestor "$PHASE_START" HEAD 2>/dev/null; then PHASE_START=""; fi UNDO_RANGE="" if [ -n "$PHASE_START" ]; then if git rev-parse "${PHASE_START}^" >/dev/null 2>&1; then UNDO_RANGE="${PHASE_START}^..HEAD" else # PHASE_START 是根提交,无父提交可排除;${PHASE_START}..HEAD 会丢弃 PHASE_START 本身 UNDO_RANGE="HEAD" fi fi几个关键设计点:
- 窗口上界为 HEAD:选择区间是
${PHASE_START}^..HEAD,杜绝了旧实现的--all无界搜索——不可达分支的提交根本不在窗口内。 - 根提交特判:
${PHASE_START}..HEAD会排除PHASE_START本身;当锚点是根提交时,其没有父提交可排除,因此必须退化为UNDO_RANGE="HEAD",否则首个阶段提交会被静默丢弃,导致合法的撤销被拒绝。 - 项目根来源统一:
PHASE_DIR由find-phase返回,是相对项目根(PROJECT ROOT)的路径;而 path-scoped 的 git 调用以 git 的 cwd 读取路径。测试专门覆盖了从sub/dir子目录调用的情况(测试用例from a subdirectory),确保所有PHASE_DIR作用域内的 git 调用都以git -C "${PROJECT_ROOT:-.}"从项目根执行。
测试文件还验证了窗口边界的两侧行为:
limit-1用例:在PHASE_DIR创建之前提交的同作用域提交(feat(03-01): stray pre-phase commit)被排除在窗口之外;而PHASE_START本身(docs(03-01): add phase plan)被包含。single-milestone用例:单一里程碑场景下,阶段内全部提交(含docs(03): phase summary、fix(03-02)、feat(03-02)、feat(03-01))被选中,后续阶段(04-search)的提交不被选中。
三、三条拒绝路径:归档、异仓库与路径复用
锚点本身无法区分两类"前一里程碑"路由,因此工作流引入了三条显式拒绝路径,全部 fail closed。
3.1 归档解析拒绝(PHASE_DIR_ARCHIVED)
find-phase的歧义检查不会跨搜索目录:它先搜索活跃的phases/目录,再按版本升序搜索每个milestones/v<X.Y>-phases/目录,matches.length > 1检查位于cmdFindPhase(src/phase.cts)的每次searchDir循环内部。因此,一个不再活跃的阶段号会静默解析到拥有该阶段的旧归档里程碑。
锚定到那里有两个方向的错误:最老的添加该路径的提交是归档移动提交,该阶段真正的开发工作早于窗口被排除在外;而窗口从归档点向前延伸,会匹配到后续里程碑的同号阶段。测试在双归档里程碑 fixture 上复现:未加守卫时--phase 03选中了 v2.0 的feat(03-01): add search index,却排除了 v1.0 自己的feat(03-01): implement auth endpoint。守卫如下:
PHASE_DIR_ARCHIVED="" case "${PHASE_DIR}" in */milestones/v[0-9]*-phases/*|milestones/v[0-9]*-phases/*|*/milestones/ws-*/phases/*|milestones/ws-*/phases/*) PHASE_DIR_ARCHIVED="${PHASE_DIR}"; PHASE_DIR="" ;; esac注意模式必须匹配归档布局而非裸milestones标记:一个名为milestones的 workstream 或项目会被裸*/milestones/*误判为归档而拒绝活跃阶段(测试用例a workstream named "milestones" is still live验证了这一点)。这里覆盖了listArchiveVersionDirs(src/phase-locator.cts)枚举的两种布局——扁平v<X.Y>-phases/<phase>与workstream complete写入的ws-<name>-<date>/phases/<phase>。
3.2 异仓库锚点拒绝(PHASE_DIR_FOREIGN)
在sub_repos项目中,.planning/位于父仓库而代码在子仓库;从子目录调用时PROJECT_ROOT是父目录,锚点读到的提交是子仓库从未见过的。守卫比较公共 git 目录(对象数据库)的物理路径,而非 per-worktree 的 git 目录:
PHASE_DIR_FOREIGN="" if [ -n "${PHASE_DIR}" ] && [ -n "${PROJECT_ROOT}" ]; then _gd_here=$(_d=$(git rev-parse --git-common-dir 2>/dev/null) && [ -n "$_d" ] && cd "$_d" && pwd -P) || _gd_here="" _gd_root=$(cd "${PROJECT_ROOT}" 2>/dev/null && _d=$(git rev-parse --git-common-dir 2>/dev/null) && [ -n "$_d" ] && cd "$_d" && pwd -P) || _gd_root="" if [ -z "$_gd_here" ] || [ "$_gd_here" != "$_gd_root" ]; then PHASE_DIR_FOREIGN="${PROJECT_ROOT}"; PHASE_DIR="" fi fi使用--git-common-dir而非--absolute-git-dir是刻意的:gsd-tools会把无自身.planning/的 linked worktree 映射到主 worktree(resolveMainWorktreeCwd),那是同一个仓库、持有 linked worktree 拥有的全部提交;若按 per-worktree 比较会误拒合法的撤销。测试覆盖了 linked worktree 在阶段之前/之后分支两种情形。
防御纵深:即使移除此守卫,锚点 fence 自身的merge-base --is-ancestor检查仍会把不在本仓库历史内的PHASE_START置空,窗口不会扩大到 HEAD(测试用例defense in depth)。
3.3 路径复用拒绝(PHASE_DIR_REUSED)
一个活跃目录的名字若与已归档里程碑下的目录同名(后续里程碑复用编号与 slug,重建了完全相同的字面路径),--diff-filter=A不跟随重命名,最老的添加提交属于前一个占用者。归档守卫看不到它——find-phase返回的是活跃目录。解决方式不是布局 glob,而是询问 git 历史本身:该路径是否曾在HEAD历史中"变空"又回来:
PHASE_DIR_REUSED=""; PHASE_DIR_LIVE="" if [ -n "${PHASE_DIR}" ]; then for _c in $(git -C "${PROJECT_ROOT:-.}" log -m --no-renames --diff-filter=D --format=%H -- "${PHASE_DIR}" 2>/dev/null); do if [ -z "$(git -C "${PROJECT_ROOT:-.}" ls-tree -d "$_c" -- "${PHASE_DIR}" 2>/dev/null)" ]; then PHASE_DIR_LIVE="${PHASE_DIR}"; PHASE_DIR_REUSED="$_c"; PHASE_DIR=""; break fi done fi--no-renames:确保移出被解读为该路径上的删除;-m:合并清空该路径也能被看到;ls-tree -d在该提交上检查:区分"目录消失"与"普通删除的计划文件"。
ls-tree -d的语义很重要——阶段内普通删除一个PLAN.md不会触发拒绝(测试用例a dropped plan file验证整个阶段仍可选中),而目录级清空会。反向控制测试(false-positive control)确保归档下同名文件、畸形里程碑目录都不会误拒从未被清空的活跃路径。
四、选择是结构化解析而非子串 grep
上述窗口确定后,COMMITS的筛选不再走grep -E,而是gsd_run query select-revert-commits子命令(src/undo-commit-selection.cts 的selectCommitsByDeclaredScope):
COMMITS=$(gsd_run query select-revert-commits --phase "${TARGET_PHASE}" --range "${UNDO_RANGE}" --raw || true)其核心原理:
- 同一份锚定文法:复用 scripts/release-notes/conventional-title.cjs 中 changelog/PR-title 门禁用的
HEADER_RE = /^([a-z]+)(\([^)]*\))?(!)?:/i,通过require()加载(该文件位于scripts/,在src的rootDir之外且无.d.ts,故不能用类型化的import = require)。绝不 fork 第二份文法副本。 - 精确字符串相等:解析每个候选提交主题的
(scope)组后,与目标 id 做精确字符串比较。任何时刻都不从用户输入构建正则(validatePhaseNumber之后即如此,见 src/security.cts),因此畸形 id 无法造成旧 grep 那样的通配式过匹配。 - 单层解包:
unwrapSubject剥掉一层fixup!/squash!/Revert "..."包装(与 git 自身--fixup/--squash/git revert产生的包装形状一致)。双层包装(Revert "fixup! feat(03-01): work")解包后仍不可解析、不选中——从不错选,这是文档化的已知限制而非缺陷。 - 模式差异:phase 模式额外接受
<targetId>-<rest>前缀作用域(阶段内 plan 作用域提交仍选中,对应旧 grep 的-NN容忍);plan 模式仅精确相等,这使feat(03-01)!: breaking change在两种模式下选择一致,消除了旧 grep 对的分歧。 - 零填充:未填充的
3会补零为03再比较(对应旧 grep 的0*容忍);letter-first 的自定义 id(如PROJ-42)从不补零,避免剥掉有意义的合法前缀。plan id 则按两个 phase 数字形状的分段分别补零(3-1→03-01)——这是对旧 plan grep 的刻意放宽。
该模块刻意不触碰文件系统与子进程:它只操作调用方已获取的{sha, subject}记录(如git log --format=%H%x00%s),因此可脱离 git 与文件系统进行单元测试(见 tests/undo-commit-selection.test.cjs)。
五、超过 50 个提交:报告截断,绝不静默截断
两种模式都要求:选择超过 50 个提交时,显示计数并停止,而不是用head -50静默封顶——部分阶段回滚留下的树状态比"全部回滚或都不回滚"更糟:
Phase ${TARGET_PHASE} selects ${N} commits (>50). Refusing to revert a partial phase. Use /gsd:undo --plan NN-MM per plan, or /gsd:undo --last N.测试L同时锁定拒绝文案与"可执行代码中不得出现| head -50"两层约束。
六、依赖检查的工作流感知
dependency_check步骤从工作流解析的规划根读取而非硬编码.planning/:
PLANNING_DIR=$(gsd_run query planning inspect --pick generated_from.planning_root --raw 2>/dev/null) [ -n "$PLANNING_DIR" ] || PLANNING_DIR=".planning"在活跃 workstream 下,该根是.planning/workstreams/<ws>/而非根项目的.planning/(测试断言了这一点),因此活跃 workstream 的 ROADMAP 与阶段目录被正确咨询。测试还验证了无.planning时的字面回退路径。
七、已知残留与安全方向保证
文档明确列出了修复后仍存在的边界,全部为安全方向(拒绝/少选而非误选/多选):
- 修订范围是血缘而非时序:
PHASE_START^..HEAD排除可达于PHASE_START^的一切;长寿命旁支若在阶段之前创建、携带匹配作用域并在PHASE_START之后合入,则可达于HEAD却非PHASE_START^的祖先,仍可被选中。这严格窄于被替换的无界搜索,但非零。 - 重命名的阶段目录少选:锚点是
--diff-filter=A于当前路径且不跟随重命名,目录移动后最老的添加是移动提交,真实开发提交落在窗口外——撤销过少或拒绝,从不过多。 - 合并提交引入的目录解析不到锚点:默认
git log不遍历合并 diff,目录在合并决议中首次出现时PHASE_START为空、两种模式 fail closed。安全方向,拒绝而非误选。 - 碰撞检查只读历史:单个同时"移走并重建"的提交无空窗可寻(会漏);旁支清空而合并保留的路径会被过拒(拒绝过多只费一次
--last N,拒绝过少则回滚他人工作——因此检查以空窗本身为键)。 - 并发 workstream 无判别器:提交主题契约
type({phase}-{plan})无 workstream 标记,并发同号阶段主题不可区分(引用 #3995:"Message subjects demonstrably do not carry enough information to identify a phase"),confirm_revert是最终兜底。
八、执行与验证:git revert --no-commit 永不 reset
修复保持了原有的安全执行属性:git revert --no-commit逐提交执行(逆时间序),脏树守卫先行拒绝未提交更改,冲突时先git revert --abort再git reset HEAD与git restore .清理,最后以单个revert(...)提交收尾。成功标准(gsd-core/workflows/undo.md 的<success_criteria>)明确:git reset --hard在任何情况下都绝不被使用。
对该修复的行为验证集中在 tests/undo-commit-selection-4465.test.cjs:该测试分两层——形状断言(对部署文本中的 bash fence 做子串断言,锁定--all缺席、PHASE_START锚定拼写、UNDO_RANGE边界等)与行为执行(将undo.md的真实 bash fence 在真实 git fixture 上通过bash -c运行,使用真实gsd-tools.cjs,与 #2308、#2352 的先例一致)。子串匹配无法区分活调用与死调用,一次真实运行可以。
结语
本次修复把/gsd:undo --phase/--plan的提交选择从"无界 grep"重构为"锚定窗口 + 结构化解析 + 三层拒绝"的模型:窗口由阶段自身目录的首次添加提交锚定并以上界 HEAD 收束,归档解析、异仓库与路径复用三条路由全部 fail closed,select-revert-commits以精确字符串比较取代正则插值。整体上它严格收窄了旧行为——从不会比无界搜索选得更多——而残留边界全部落在安全方向(拒绝或少选,从不错选、从不跨里程碑)。如需回滚一个已归档或已复用路径的阶段,/gsd:undo --last N的显式选择是文档指定的替代路由。
【免费下载链接】gsd-core
Git. Ship. Done - Core
相关推荐
jQuery Gridly API 参考:从基础配置到高级回调函数全解析
jQuery Gridly API 参考:从基础配置到高级回调函数全解析 想要为你的网站添加优雅的网格拖放和调整大小功能吗?jQuery Gridly 是一个强
前端UI库/组件gsd-core UI Safety Gate 误报修复:从子串匹配到词边界锚定正则的工程实践
gsd core UI Safety Gate 误报修复:从子串匹配到词边界锚定正则的工程实践 导读 gsd core (Git. Ship. Done — C
GSD 多 Agent 并行化策略:跨边界并行、边界内串行,以及里程碑级并行编排的工程实践
GSD 多 Agent 并行化策略:跨边界并行、边界内串行,以及里程碑级并行编排的工程实践 导读 并行化是让编码 Agent 长时自主工作的关键加速手段,但错误
人工智能AI Agent代码智能体Agent 编排CLIAI 应用
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考