首页
/ get-shit-done 阶段删除深度解析:修复 `phase remove --force` 旗标位置敏感问题(Bug 3409)

get-shit-done 阶段删除深度解析:修复 `phase remove --force` 旗标位置敏感问题(Bug 3409)

2026-09-05 17:44:45作者:邵娇湘

本文围绕 changeset 记录 3409-phase-remove-force-position.md 展开,讲解 get-shit-done(GSD)规划系统中 phase remove 子命令的 --force 旗标修复细节:当旗标出现在位置参数之前时,旧版参数解析会把 --force 误当作阶段 ID,导致"假成功"响应与 STATE.md 中阶段计数(phase-count)的非预期漂移。读完本文,你将掌握该命令在 CLI 与 SDK 两条查询路径下的完整行为语义、--force 保护机制的底层实现,以及删除阶段后目录重编号、ROADMAP.md 更新与 STATE.md 计数同步的调用链。

一、changeset 记录本身说了什么

GSD 仓库采用按 PR 拆分的 CHANGELOG 片段机制:所有带用户可见变更的 PR 都会在 .changeset 目录投下一个独立 fragment 文件,发布时由脚本统一合并进顶层 CHANGELOG.md,从而避免多个 PR 同时编辑 CHANGELOG.md 产生合并冲突(机制说明见 .changeset/README.md)。

本次修复的 fragment 内容非常凝练,完整原文如下:

---
type: Fixed
pr: 3416
---
**`phase remove --force <phase>` now works correctly across both CLI and
SDK query paths (#3409)** — flag parsing no longer treats `--force` as the
phase id when the flag appears before the positional argument, preventing
false success responses and unintended `STATE.md` phase-count drift.

翻译成工程语言,这条修复包含三个关键信息点:

  1. 触发条件:用户把 --force 写在阶段号之前,例如 phase remove --force 6(而不是 phase remove 6 --force);
  2. 旧行为:参数解析把 --force 这个旗标字符串当成了位置参数(阶段 ID),后续删除流程基于一个不存在的"阶段"执行,返回了假成功响应,同时却把 STATE.md 里的阶段总数减一,造成规划状态的计数漂移(drift);
  3. 修复范围:CLI 路径与 SDK query 路径两条链路同时修复,而非只修其中一条。

二、phase remove 命令的正确用法与行为语义

phase remove 是 GSD 规划体系(.planning/ 目录)中的阶段管理命令,官方文档 CLI-TOOLS.md 中给出的规范用法为:

node gsd-tools.cjs phase remove <phase> [--force]

该命令的完整行为语义(从源码可确认):

步骤 行为 源码依据
1. 参数校验 必须且只能有一个阶段号位置参数;出现其他 --xxx 旗标直接抛 Validation 错误 phase-lifecycle.ts
2. 定位阶段目录 .planning/phases/ 下按阶段 token 匹配目录(支持 06-dashboard 这类带 slug 的目录名) phase-lifecycle.ts
3. 已执行工作保护 --force 时,若阶段目录内存在 *-SUMMARY.mdSUMMARY.md,拒绝删除 phase-lifecycle.ts
4. 删除目录 递归删除目标阶段目录 phase-lifecycle.ts
5. 重编号 对后续阶段目录与内部文件执行重编号(整数阶段 07-api06-api;小数/插入阶段 06.206.1 phase-lifecycle.ts
6. 更新 ROADMAP.md 移除对应章节、重编号后续章节,并修正 Depends on: Phase N 依赖引用 phase-lifecycle.ts
7. 更新 STATE.md 在文件锁保护下将 total_phases 减一,同步正文 "Plan: X of N" 与 "Total Phases" 字段 phase-lifecycle.ts

成功时返回结构化结果:{ removed, directory_deleted, renamed_directories, renamed_files, roadmap_updated, state_updated }(见 phase-lifecycle.ts),CLI 路径的 cmdPhaseRemove 输出同构的 JSON 结果,方便脚本消费。

三、Bug #3409 的根源:旗标与位置参数的顺序耦合

--force 旗标的本意是跳过"已执行工作保护"——当阶段目录下已存在执行产物(SUMMARY 文件)时,普通 phase remove 会拒绝并提示:

Phase 6 has 1 executed plan(s). Use --force to remove anyway.

这段保护逻辑在 CLI 路径 phase.cjs 与 SDK 路径 phase-lifecycle.ts 中是一致的:

// get-shit-done/bin/lib/phase.cjs (L1026-L1033)
// Guard against removing executed work
if (targetDir && !force) {
  const files = fs.readdirSync(path.join(phasesDir, targetDir));
  const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
  if (summaries.length > 0) {
    error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
  }
}

问题在于旧版参数解析对旗标出现位置敏感:当用户写出 phase remove --force 6 时,解析器没有识别 --force 是旗标,而是把它当作了阶段 ID 的候选值。其后果链条是:

  • 目标目录查找失败(不存在叫 --force 的阶段);
  • 但由于查找失败后流程未正确中断,ROADMAP.md 更新与 STATE.md 计数减一等下游副作用仍然执行
  • 命令最终返回"成功",STATE.mdtotal_phases 却少了一个不存在的阶段——这正是 changeset 中所说的 false success responses and unintended STATE.md phase-count drift

四、修复后的实现:位置无关的旗标解析

修复后的 SDK query 处理器 phaseRemove 采用逐 token 扫描的方式分离旗标与位置参数,旗标出现在任何位置都等价(phase-lifecycle.ts):

export const phaseRemove: QueryHandler = async (args, projectDir, workstream) => {
  let force = false;
  const positional: string[] = [];
  for (const token of args) {
    if (token === '--force') {
      force = true;
      continue;
    }
    if (token.startsWith('--')) {
      throw new GSDError(`phase remove does not support ${token}`, ErrorClassification.Validation);
    }
    positional.push(token);
  }

  if (positional.length > 1) {
    throw new GSDError('phase remove accepts exactly one phase number', ErrorClassification.Validation);
  }
  const targetPhase = positional[0];
  if (!targetPhase) {
    throw new GSDError('phase number required for phase remove', ErrorClassification.Validation);
  }
  ...

这个解析循环有三层防御,逐层对应一类非法输入:

  1. --force 是唯一的白名单旗标:无论出现在 args 数组的哪个下标,一律置位 force 并跳过,永远不会进入 positional
  2. 未知 -- 前缀旗标 fail-fast:如 --dry-run 这类不支持的旗标直接抛 Validation 分类错误,而不是被静默当作阶段号;
  3. 位置参数数量强约束:超过一个位置参数即报错,且阶段号缺失时报 phase number required——即使解析阶段被绕过,后续"目录不存在"检查(phase-lifecycle.tsPhase ${targetPhase} not found)也会在改动任何文件之前中止流程。

CLI 路径则由 cmdPhaseRemove 承担,其中 const force = options.force || false 表明旗标已由上层路由解析为结构化 options 对象,天然与位置参数解耦——两条路径经过 #3416 的修复后在语义上对齐,这正是 changeset 强调 "across both CLI and SDK query paths" 的含义。

五、回归测试:三种旗标排列组合的验证

修复随附的回归测试位于 phase-lifecycle.test.ts,针对同一个含 SUMMARY 文件的阶段目录构造了三种调用,恰好覆盖旗标的三种语义状态:

// 1. 不带 --force:必须被保护机制拦截
await expect(phaseRemove(['6'], tmpDir)).rejects.toThrow('--force');

// 2. --force 在阶段号之后(传统用法)
const result = await phaseRemove(['6', '--force'], tmpDir);
expect(data.removed).toBe('6');
expect(data.directory_deleted).toBeTruthy();

// 3. bug-3409: --force 在阶段号之前
const result = await phaseRemove(['--force', '6'], tmpDir);
expect(data.removed).toBe('6');
expect(data.directory_deleted).toBeTruthy();

第三个用例(['--force', '6'])就是 bug 复现用例:修复前该调用会因旗标被误认为阶段 ID 而无法删除目标阶段;修复后断言 removed === '6' 且目录确实被删除。此外测试套件还覆盖了相邻的失败路径,例如 ROADMAP.md 缺失时报 ROADMAP.md not foundphase-lifecycle.test.ts)、目标阶段不存在时不得改动 STATE.mdphase-lifecycle.test.ts)——后者直接锁死了"假成功导致计数漂移"这一回归方向。

六、实践建议与适用边界

基于本次修复,使用 phase remove 时建议遵循以下实践:

  • 旗标位置自由,但阶段号必须且只能有一个phase remove 6 --forcephase remove --force 6 现在完全等价;phase remove 5 6 这类多阶段调用会被 Validation 错误拒绝;
  • --force 当破坏性开关使用:它唯一的作用是放行"目录内已有 SUMMARY 执行产物"的阶段。SUMMARY 文件是 GSD 执行器完成 plan 后写入的产物,删除不可恢复,生产项目中建议先归档再强制删除;
  • 关注删除后的连锁重编号:删除阶段 6 会使 07-api06-api、ROADMAP 章节与 Depends on 引用一并下移,STATE.mdtotal_phases 与 "of N" 计数同步减一(见 phase-lifecycle.ts)。因此该命令最好在提交边界(commit 边界)附近使用,便于用版本控制回滚整个重编号结果;
  • 小数阶段同样适用phase remove 6.2 --force 会触发小数阶段重编号(06.306.2),重编号为 best-effort(失败时静默跳过),但目录删除与 ROADMAP/STATE 更新不受影响(phase-lifecycle.ts)。

从源码结构看,本修复的价值不止于单一 bug:phaseRemove 处理器被标注为 Port of cmdPhaseRemove from phase.cjs lines 597-661phase-lifecycle.ts),即 CLI 与 SDK 是同一命令的两份实现投影。任何只修一条路径的改动都会造成两条链路行为分叉——#3409 的 changeset 明确要求"both CLI and SDK query paths"同时生效,配合两侧各自的测试,构成 GSD 命令投影一致性(shell-command-projection)体系的典型范例。

七、相关文件索引

文件 说明
.changeset/3409-phase-remove-force-position.md 本 bug 的 changeset 记录(type: Fixed, PR #3416)
.changeset/README.md changeset fragment 机制说明
docs/CLI-TOOLS.md phase remove <phase> [--force] 官方命令格式
sdk/src/query/phase-lifecycle.ts SDK 路径 phaseRemove 处理器(修复后实现)
get-shit-done/bin/lib/phase.cjs CLI 路径 cmdPhaseRemove 实现
sdk/src/query/phase-lifecycle.test.ts --force 三种排列的回归测试
CHANGELOG.md phase remove <N> [--force] 能力条目
登录后查看全文
热门项目推荐
相关项目推荐