☰
GSD Core 安全回滚的提交选择边界:/gsd:undo 从无界 grep 到里程碑锚定的演进
2026/9/28 3:46:45 网站建设 项目流程

【免费下载链接】gsd-core

Git. Ship. Done - Core

项目地址:https://gitcode.com/gh_mirrors/ge/gsd-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):

  1. 正则注入:旧 grep 将未经校验的用户输入 id 直接插值进 ERE,.与+等元字符会变成通配/量词,畸形或形状不同的 id 会静默匹配到错误阶段。
  2. 无锚定:grep 未锚定,一个只是"提及"某作用域的提交(如docs(99-01): explain feat(03-01): commit convention)会被错误地当作该作用域的"声明"选中。
  3. 两种模式语法不一致:phase 模式的\):后缀与 plan 模式缺失后缀,在同一个 breaking-change 提交(feat(03-01)!: breaking change)上产生分歧。
  4. "明显修复"是个陷阱:对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时的字面回退路径。

七、已知残留与安全方向保证

文档明确列出了修复后仍存在的边界,全部为安全方向(拒绝/少选而非误选/多选):

  1. 修订范围是血缘而非时序:PHASE_START^..HEAD排除可达于PHASE_START^的一切;长寿命旁支若在阶段之前创建、携带匹配作用域并在PHASE_START之后合入,则可达于HEAD却非PHASE_START^的祖先,仍可被选中。这严格窄于被替换的无界搜索,但非零。
  2. 重命名的阶段目录少选:锚点是--diff-filter=A于当前路径且不跟随重命名,目录移动后最老的添加是移动提交,真实开发提交落在窗口外——撤销过少或拒绝,从不过多。
  3. 合并提交引入的目录解析不到锚点:默认git log不遍历合并 diff,目录在合并决议中首次出现时PHASE_START为空、两种模式 fail closed。安全方向,拒绝而非误选。
  4. 碰撞检查只读历史:单个同时"移走并重建"的提交无空窗可寻(会漏);旁支清空而合并保留的路径会被过拒(拒绝过多只费一次--last N,拒绝过少则回滚他人工作——因此检查以空窗本身为键)。
  5. 并发 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

项目地址:https://gitcode.com/gh_mirrors/ge/gsd-core
点击查看免费下载

相关推荐

上一篇:在Windows 10上运行Android子系统:WSA-Windows-10安装指南
下一篇:Notepad++ Markdown 实时预览插件:MarkdownViewer++ 新手完整指南,5 分钟装好即用

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询