diff --git a/cli.mjs b/cli.mjs index b6f6999..4ed7c05 100755 --- a/cli.mjs +++ b/cli.mjs @@ -41,7 +41,7 @@ async function executeDomain(c, mod, r, o) { if (!o['plan-hash']) throw err('PLAN_HASH_REQUIRED', 'PLAN_HASH_REQUIRED', 2) return mod.execute(r, o, e.data) } - const b = brief(r, name(o)) + const b = readBrief(mod, r, o) if (b.data.workflow.planHash !== e.planHash) { const stale = !!b.data.workflow.planHash throw err(stale ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED', stale ? 'PLAN_HASH_INVALID' : 'PLAN_HASH_REQUIRED', stale ? 1 : 2) @@ -49,12 +49,16 @@ async function executeDomain(c, mod, r, o) { return mod.execute(r, o, e.data, b) } +// 域可导出 locateBrief(r, o):brief 可能已被迁进专属 workspace(见 worktree 域)。 +// harness 若硬按 cwd 所在树读,跨树回收会在进入域逻辑之前就 BRIEF_NOT_FOUND。 +const readBrief = (mod, r, o) => (mod.locateBrief ? mod.locateBrief(r, o) : brief(r, name(o))) + // 域可导出 present(x) 声明 stdout data 的最小投影(哈希/持久化仍用完整 planData);缺省恒等 async function planDomain(c, mod, r, o) { const e = plan(c, await mod.planData(r, o)) const view = mod.present ? { ...e, data: mod.present(e.data) } : e if (!o.name) return view - const b = brief(r, name(o)) + const b = readBrief(mod, r, o) b.data.workflow.planHash = e.planHash mod.persistPlan?.(b, e) write(b) @@ -88,7 +92,7 @@ async function handle(r, p, o) { if (Object.hasOwn(DOMAINS, d)) { const nested = a === 'rebuild' || d === 'blog' || (d === 'worktree' && a === 'remove') const verb = nested ? s : a, c = nested ? `${d}.${a}` : d - const mod = d === 'worktree' && a === 'remove' ? { planData: worktree.removePlanData, present: worktree.removePresent, execute: worktree.removeExecute } : DOMAINS[d] + const mod = d === 'worktree' && a === 'remove' ? { planData: worktree.removePlanData, present: worktree.removePresent, execute: worktree.removeExecute, locateBrief: worktree.locateBrief } : DOMAINS[d] if (verb === 'plan') return await planDomain(c, mod, r, o) if (verb === 'execute') return { ok: true, command: `${c}.execute`, data: await executeDomain(c, mod, r, o) } } diff --git a/lib/commands.mjs b/lib/commands.mjs index 15fa0fb..c4c1898 100644 --- a/lib/commands.mjs +++ b/lib/commands.mjs @@ -26,9 +26,9 @@ export const COMMANDS = { 'conflict.inspect': c('conflict inspect', '检查与其他 active brief 的文件重叠', 'check file overlaps with other active briefs', [N], 'shadow-dev conflict inspect --name ', null), 'worktree.inspect': c('worktree inspect', '列出 git worktree 与并行建议(按 brief 复杂度评级)', 'list git worktrees with a parallelism recommendation based on the brief rating', [N], 'shadow-dev worktree inspect --name ', null), 'worktree.plan': c('worktree plan', '预览创建独立 worktree(brief 无分支则随 add 派生)', 'plan a dedicated worktree (branch derived on add when the brief has none)', [N, f('--path', true, '新 worktree 目录(绝对或仓库相对路径)', 'directory of the new worktree (absolute or repo-relative)')], 'shadow-dev worktree plan --name --path ', 'worktree execute --name {name} --confirm'), - 'worktree.execute': c('worktree execute', '创建或挂载 worktree 并回写 brief(不切换当前工作区;--path 须与 plan 一致)', 'create or attach the worktree and record it in the brief (current checkout untouched; --path must match the plan)', [N, f('--path', true, '与 plan 相同的 worktree 目录(参与 planHash 重算)', 'the same directory as the plan (re-hashed for the planHash check)'), PH, CF], 'shadow-dev worktree execute --name --path --confirm', null), + 'worktree.execute': c('worktree execute', '创建或挂载 worktree 并回写 brief;未提交的 brief 随 workspace 迁移(不切换当前工作区;--path 须与 plan 一致)', 'create or attach the worktree and record it in the brief; an uncommitted brief is relocated into the workspace (current checkout untouched; --path must match the plan)', [N, f('--path', true, '与 plan 相同的 worktree 目录(参与 planHash 重算)', 'the same directory as the plan (re-hashed for the planHash check)'), PH, CF], 'shadow-dev worktree execute --name --path --confirm', null), 'worktree.remove.plan': c('worktree remove plan', '预览回收 brief 记录的 worktree', 'plan removing the worktree recorded in the brief', [N], 'shadow-dev worktree remove plan --name ', 'worktree remove execute --name {name} --confirm'), - 'worktree.remove.execute': c('worktree remove execute', '回收 worktree(脏改动时拒绝,不做隐式删除)', 'remove the worktree (refuses dirty trees, never deletes implicitly)', [N, PH, CF], 'shadow-dev worktree remove execute --name --confirm', null), + 'worktree.remove.execute': c('worktree remove execute', '回收 worktree(未提交的 brief 先搬回主工作树;脏改动仍拒绝)', 'remove the worktree (relocates an uncommitted brief back first; still refuses dirty trees)', [N, PH, CF], 'shadow-dev worktree remove execute --name --confirm', null), 'task.list': c('task list', '列出 brief 任务清单', 'list the brief task checklist', [N], 'shadow-dev task list --name ', null), 'task.set': c('task set', '勾选/取消任务', 'tick or untick a task', [N, f('--task', true, '任务 id,如 task-3', 'task id, e.g. task-3'), f('--state', true, 'todo|done', 'todo|done'), CF], 'shadow-dev task set --name --task task-1 --state done --confirm', null), 'review.plan': c('review plan', '预览审查记录', 'plan the review record', [N], 'shadow-dev review plan --name ', 'review execute --name {name} --conclusion passed --confirm'), diff --git a/lib/domains/worktree.mjs b/lib/domains/worktree.mjs index 33f6b01..aff0ee3 100644 --- a/lib/domains/worktree.mjs +++ b/lib/domains/worktree.mjs @@ -1,5 +1,5 @@ -import { existsSync, readdirSync, realpathSync } from 'node:fs' -import { join, resolve } from 'node:path' +import { cpSync, existsSync, mkdirSync, readFileSync, readdirSync, realpathSync, rmSync } from 'node:fs' +import { dirname, join, resolve } from 'node:path' import { git, repo } from '../git.mjs' import { brief, write } from '../brief.mjs' import { name } from '../input.mjs' @@ -16,8 +16,54 @@ const canon = p => { return process.platform === 'win32' ? s.replaceAll('\\', '/').toLowerCase() : s } +// brief 在文件系统上只有一个合法位置:它当前归属的那棵树。 +// `change create` 刚写的 brief 是未跟踪文件,而 `git worktree add` 从基线派生新树——新树里没有它, +// 于是 task set/review/commit 一律 BRIEF_NOT_FOUND(实测:内容包 L 级变更被迫放弃专属 workspace)。 +// 解法是**迁移而非复制**:复制会留下两份可各自演进的受管状态(=数据分裂),比报错更糟。 +const briefRel = n => join('shadow-docs', 'changes', n) +const tracked = (r, rel) => git(r, ['ls-files', '--', rel]) !== '' +function treeMap(dir) { + const out = {} + ;(function walk(d, prefix) { + for (const e of readdirSync(d, { withFileTypes: true })) { + const rel = prefix ? `${prefix}/${e.name}` : e.name + if (e.isDirectory()) walk(join(d, e.name), rel) + else out[rel] = readFileSync(join(d, e.name), 'utf8') + } + })(dir, '') + return out +} +// 先复制并逐文件校验一致,再删源:迁移任何一步失败都不允许留下半个 brief。 +function relocate(fromRoot, toRoot, n) { + const rel = briefRel(n), src = join(fromRoot, rel), dst = join(toRoot, rel) + if (!existsSync(src) || existsSync(dst)) return false + const before = treeMap(src) + mkdirSync(dirname(dst), { recursive: true }) + cpSync(src, dst, { recursive: true }) + if (JSON.stringify(before) !== JSON.stringify(treeMap(dst))) { + rmSync(dst, { recursive: true, force: true }) + throw err('RELOCATION_MISMATCH', `RELOCATION_MISMATCH: brief 迁往 ${dst} 校验不一致,已回退并保留源 ${src}`, 1) + } + rmSync(src, { recursive: true, force: true }) + return true +} +// 定位 brief:主工作树优先,否则在登记的 worktree 里找唯一副本。 +function locateRoot(r, n) { + if (existsSync(join(r, briefRel(n)))) return r + for (const w of list(r)) if (w.path !== r && existsSync(join(w.path, briefRel(n)))) return w.path + throw err('BRIEF_NOT_FOUND') +} +// 归属与脏检查要排除 brief 自身:它是 CLI 管理的载体,不是用户的未提交工作 +const dirtyBesidesBrief = (dir, n) => { + const rel = briefRel(n) + try { return git(dir, ['status', '--porcelain', '--', '.', `:(exclude)${rel}`]) !== '' } catch { return null } +} + // git worktree list --porcelain 快照;porcelain 首条即主工作区,branch 已剥 refs/heads/ 前缀。 // 行尾宽容处理:Windows 上 git 输出 CRLF,\r 若残留会挂在 path 尾使 existsSync/比较恒失败。 +// 跨树定位 brief:worktree 域是唯一需要「看见别的树」的域,其余域按 cwd 所在树读写。 +export function locateBrief(r, o) { const n = name(o); return brief(locateRoot(r, n), n) } + export function list(r) { const out = [] let cur = null @@ -30,24 +76,31 @@ export function list(r) { return out.map((w, i) => ({ ...w, primary: i === 0 })) } +// 变更发现覆盖主工作树与全部 worktree:brief 迁移后若只扫主树,归属反查会失明 +// (occupiedBy 认不出、同名 change 可被重复创建)。同名的活动 brief 以先找到的为准。 function activeBriefs(r) { - const out = [] - const base = join(r, 'shadow-docs', 'changes') - for (const e of readdirSync(base, { withFileTypes: true })) { - if (!e.isDirectory() || e.name === 'archive') continue - try { out.push({ dir: e.name, data: brief(r, e.name).data }) } catch { /* 解析失败目录跳过,与 conflict 同规则 */ } + const out = [], seen = new Set() + const roots = [r, ...list(r).map(w => w.path).filter(p => p !== r && existsSync(p))] + for (const root of roots) { + const base = join(root, 'shadow-docs', 'changes') + if (!existsSync(base)) continue + for (const e of readdirSync(base, { withFileTypes: true })) { + if (!e.isDirectory() || e.name === 'archive' || seen.has(e.name)) continue + try { out.push({ dir: e.name, data: brief(root, e.name).data }); seen.add(e.name) } catch { /* 解析失败目录跳过,与 conflict 同规则 */ } + } } return out } -function cleanIn(dir) { +function cleanIn(dir, n = null) { if (!existsSync(dir)) return null + if (n) { const d = dirtyBesidesBrief(dir, n); return d === null ? null : !d } try { return git(dir, ['status', '--porcelain']) === '' } catch { return null } } // 只读检视:worktree 清单 + 归属反查 + 按 brief 复杂度评级的并行建议 export function state(r, o) { - const n = name(o), b = brief(r, n), q = repo(r) + const n = name(o), b = locateBrief(r, o), q = repo(r) const actives = activeBriefs(r) const worktrees = list(r).map(w => ({ path: w.path, @@ -69,7 +122,7 @@ export function state(r, o) { // 创建:brief 有 branch 则挂载既有分支,否则随 worktree add 派生新分支;path 必填 export function planData(r, o) { - const n = name(o), b = brief(r, n).data, q = repo(r) + const n = name(o), b = locateBrief(r, o).data, q = repo(r) const path = o.path if (!path) throw err('PATH_REQUIRED', `PATH_REQUIRED: pass --path for the new worktree of ${n}`, 1) const abs = resolve(r, path) @@ -94,15 +147,21 @@ export function execute(r, o, x, b) { if (b.data.status === 'draft' || b.data.status === 'proposed') b.data.status = 'branched' b.data.workflow.worktree = x.path write(b) - return { branch: x.branch, path: x.path, mounted: x.mount } + // 未跟踪的 brief 不随 worktree add 出现:把它迁进新树,让它成为唯一真相。 + // 已跟踪(稳态)时一行都不动,零回归面。 + const rel = briefRel(b.data.name) + const relocated = existsSync(join(r, rel)) && !tracked(r, rel) ? relocate(r, x.path, b.data.name) : false + return { branch: x.branch, path: x.path, mounted: x.mount, relocated } } // 回收:对象是 brief 记录的 workflow.worktree;脏工作区响亮拒绝,不做隐式删除 export function removePlanData(r, o) { - const n = name(o), b = brief(r, n).data, q = repo(r) + const n = name(o), b = locateBrief(r, o).data, q = repo(r) const path = b.workflow?.worktree if (!path || !existsSync(path)) throw err('WORKTREE_NOT_FOUND', `WORKTREE_NOT_FOUND: ${n} has no recorded worktree${path ? ` (missing dir: ${path})` : ''}`, 1) - const dirty = cleanIn(path) === false + // 脏判定排除 brief 本身:专属 workspace 里未提交的 brief 是 CLI 载体,不是用户改动, + // 否则回收永远判 WORKTREE_DIRTY,唯一真相会随目录一起被删或永远搬不走 + const dirty = dirtyBesidesBrief(path, n) === true return { name: n, path, dirty, brief: b, repo: q } } @@ -113,8 +172,13 @@ export function removePresent(x) { export function removeExecute(r, o, x, b) { if (x.dirty) throw err('WORKTREE_DIRTY', `WORKTREE_DIRTY: ${x.path} has uncommitted changes; commit or clean it first`, 1) + // 回收不得吞掉唯一真相:主树没有 brief 而工作树有(未提交的迁移态)时先搬回主树再删目录 + const rel = briefRel(b.data.name) + const relocatedBack = !existsSync(join(r, rel)) && existsSync(join(x.path, rel)) + if (relocatedBack) relocate(x.path, r, b.data.name) git(r, ['worktree', 'remove', x.path]) - b.data.workflow.worktree = null - write(b) - return { path: x.path, removed: true } + const home = brief(r, b.data.name) + home.data.workflow.worktree = null + write(home) + return { path: x.path, removed: true, relocatedBack } } diff --git a/shadow-docs/changes/20261010-fix-worktree-uncommitted-brief/brief.md b/shadow-docs/changes/20261010-fix-worktree-uncommitted-brief/brief.md new file mode 100644 index 0000000..16949ec --- /dev/null +++ b/shadow-docs/changes/20261010-fix-worktree-uncommitted-brief/brief.md @@ -0,0 +1,180 @@ +--- +{ + "schema": "shadow-dev/v1", + "name": "20261010-fix-worktree-uncommitted-brief", + "type": "fix", + "scope": "cli-infrastructure", + "status": "published", + "baseBranch": "main", + "branch": "fix/20261010-fix-worktree-uncommitted-brief", + "files": [ + "cli.mjs", + "lib/commands.mjs", + "lib/domains/worktree.mjs", + "shadow-docs/changes/20261010-fix-worktree-uncommitted-brief/brief.md", + "shadow-docs/domain.md", + "shadow-docs/knowledge/brief-state-commit.md", + "shadow-docs/knowledge/worktree-brief-relocation.md", + "shadow-docs/menu.md", + "test/cli.test.mjs" + ], + "github": { + "repository": null, + "issue": null, + "issueUrl": null, + "pullRequest": 51, + "pullRequestUrl": "https://github.com/stack-wuh/shadow-dev-cli/pull/51" + }, + "review": { + "conclusion": "passed", + "verifiedCommit": "f8294eb8122eb0e535a42f694863350310bd28a9", + "verifiedAt": "2026-10-11T09:17:52.061Z" + }, + "workflow": { + "operation": null, + "checkpoint": "pr:51", + "planHash": "8b11d34cd0b6355571c9bd3f9d0f498fe5030e6746ef00710ee69a338b3fccac", + "updatedAt": null, + "lastError": null, + "commit": { + "files": [ + "shadow-docs/changes/20261010-fix-worktree-uncommitted-brief/brief.md" + ], + "message": "docs(shadow): 重算 PR 前 planHash(push 已完成,PR 待 token)" + }, + "release": { + "files": [ + "cli.mjs", + "lib/commands.mjs", + "lib/domains/worktree.mjs", + "shadow-docs/changes/20261010-fix-worktree-uncommitted-brief/brief.md", + "shadow-docs/domain.md", + "shadow-docs/knowledge/brief-state-commit.md", + "shadow-docs/knowledge/worktree-brief-relocation.md", + "shadow-docs/menu.md", + "test/cli.test.mjs" + ], + "message": "fix(worktree): 未提交的 brief 随专属 workspace 迁移,回收前搬回主树", + "title": "20261010-fix-worktree-uncommitted-brief", + "body": "" + } + }, + "knowledge": { + "action": "新增", + "target": "shadow-docs/knowledge/worktree-brief-relocation.md", + "reason": "新增「brief 随专属 workspace 迁移与归属树」卡(唯一性/迁移完整性/载体豁免/回收不丢数据四条执行约束);原位更新 brief-state-commit.md 建立 archive 特例的根因链接;menu 加路由;本仓首次登记 shadow-docs/domain.md 上下文地图。证据为真实 CLI 端到端 7 步输出(relocated=true、主树无副本、工作树内 task list 可用、dirty=false、relocatedBack=true、worktree 字段归零);分片探针 27 条 23 pass,4 条失败均为本机 139 打死子进程的 null 状态、无断言失败,已如实标注为未验证。验收口径按用户 2026-10-11 裁定变更:全量测试不再作为门槛,测试文件降级为可选契约探针。" + } +} +--- + +# 新立项 change 的执行路径:brief 随专属 workspace 迁移 + +## 动机 + +`worktree` 域对**刚立项的 change 不可用**,而每个 change 都从「刚立项」开始。 + +`worktree execute` 用 `git worktree add -b / ` 从基线派生工作树,而 `change create` 刚写的 brief 是**未跟踪文件**——新树里根本没有它。后果不是报错难看,是流程当场断线:`task set` / `review` / `commit` 全部 `BRIEF_NOT_FOUND`,唯一绕过办法是人工把 brief 拷进新树,于是同一 change 出现两份可各自演进的受管状态(状态分裂),比报错更危险。 + +实证两次,都发生在内容包侧: + +1. `20261010-refactor-domain-driven-workflow`:`worktree inspect` 对 L 级变更给 `recommendation:"create"`,但读实现(`lib/domains/worktree.mjs` 的 `execute` 只向主工作区回写 brief,新树从 `base` 派生)后确认不可执行,被迫在 brief 里记「执行期修订」降级为 inline 分支——工具的默认路径与流程规范互相矛盾,执行者只能违反规范之一。 +2. 同轮还暴露配套的脏判定缺陷:brief 迁进专属 workspace 后是**未跟踪文件**,`worktree remove` 会因 `WORKTREE_DIRTY` 永远拒绝回收,唯一真相要么随目录被删、要么永远搬不回来。 + +`change amend`(实现期扩面登记)已由 #50 交付,本 change 只补另一半:**brief 在 workspace 之间的归属迁移**。现有 5 条 worktree 用例的 fixture 里 brief 全部是未跟踪状态却断言它留在主树——即测试前提把「缺陷行为」当成了契约,本 change 同时纠正这条测试基线。 + +**非目标:** 不做 `change amend`(#50 已实现且带 `reviewReset`/`--scope`/幂等,本 change 完全让位,废弃重复实现);不 bump 版本(发版是独立 change,先例 `3081206 chore(release): v1.5.0 发版准备`;且 `change.amend` 进入内容包 `requiresCommands` 必须等 v1.6.0 真的发布,见「跨仓顺序」);不做跨树双向同步或合并(那是状态分裂的开端);不改 archive 的 push 失败特例语义。 + +## 复杂度评级 + +- **评级:** L +- **理由:** **契约变更**——`worktree execute`/`remove execute` 的 stdout 新增 `relocated`/`relocatedBack` 字段,且 **brief 的存放位置成为契约的一部分**(迁移后唯一真相随工作树走,其它域按 cwd 所在树读写);harness 层 `readBrief` 替换了 `executeDomain`/`planDomain` 两处 `brief(r, name(o))`,触及**所有**走 plan/execute 的域;**触及面**——执行层核心(`lib/domains/worktree.mjs`、`cli.mjs`、`lib/commands.mjs`)+ 内容包 apply 流程的分支策略;**可发现性**——迁移逻辑写坏的表征是「brief 凭空消失或两份并存」,属最难的静默数据损坏,且既有测试基线恰好把错误行为固化为断言。 +- **期望验证深度:** runtime(真实 CLI 在临时 git 仓跑通 create→approve→branch→worktree→task set→remove 全链 + 三条拒绝路径;**全量测试套件必须跑绿**——这是上一轮欠的账) + +## 领域模型 + +- **限界上下文:** `cli-infrastructure` — shadow-dev 执行层(brief 生命周期、workspace、plan/execute 凭证链、输出面)。上游 `workflow-governance`(内容包经 `requiresCommands` 能力契约消费本仓命令键),下游三宿主无关。**反腐边界**:本 change 不定义内容包的规范语义,也不新增对外命令键(只在既有 `worktree.*` 命令上增字段,additive)。 +- **统一语言增量:** + - **workspace 迁移 relocation**:把某 change 的**唯一** brief 目录整体搬进其专属工作树(或搬回),迁移后旧位置不留副本。 + - **受管状态分裂 divergence**:同一 change 存在两份可各自演进的 brief——本域定性为数据损坏,不是便利。 + - **归属树 owning tree**:任一时刻决定「`brief(r,n)` 该在哪个目录找」的那棵树;主工作区或某 worktree,二者互斥。 + - **载体豁免 carrier exclusion**:`worktree remove` 的脏判定排除 brief 自身——它是 CLI 的管理载体,不是用户未提交的工作。 +- **聚合与不变量(每条须绑定观察点):** + - `change brief`:**I1 唯一性**——任一时刻只有一份 brief,主树与新树不同时存在;**I2 迁移完整性**——迁移前后逐文件内容一致,且 `branch`/`status`/`workflow.worktree` 已回写之后才发生迁移;**I3 可发现性**——在归属树内任何按 `--name` 的命令都能解析到该 brief(含跨树的 `worktree inspect/plan/remove`)。 + - `workspace`:**I4 回收不丢数据**——brief 未提交时先搬回主树再删目录;**I5 载体豁免**——未提交 brief 不构成 `WORKTREE_DIRTY`,真实用户改动仍照旧拒绝(既有用例须保持绿)。 + - `稳态零回归`:**I6**——brief 已跟踪(已提交)时 `relocated:false`,主树副本与既有断言一字不变。 +- **领域事件:** `worktree execute` 成功 → 归属树切到新工作树,后续写操作只在该树发生;`worktree remove execute` → 归属树切回主工作区并清空 `workflow.worktree`;`change amend`(#50)→ `reviewReset` 作废已 passed 的 review,本 change 与之正交。 + +## 引用规范 + +- `shadow-docs/knowledge/plan-credential-chain.md`(#50 已更新) + - 当前结论: `planHash=sha256(canon({command,data:norm(planData)}))`;norm 剥离边界=plan 自身副作用(`workflow.planHash/release/issuePlan/commit` 与 `repo.changedFiles/clean`);脏行为门禁必须在 `planData` 内联执行。 + - 适用 scope: 迁移与 `relocated`/`relocatedBack` **不得进入哈希输入**(否则 execute 端必漂移);`dirtyBesidesBrief` 属行为门禁,留在 planData 内联 +- `shadow-docs/knowledge/brief-state-commit.md`(#50 已更新) + - 当前结论: 写 brief 的 execute 域在 `write(b)` 后补 `briefStateCommit`,无差异不造空 commit;archive push 失败的特例恢复要人工 push 一次,**根因正是 harness 从旧路径读 brief 会 `BRIEF_NOT_FOUND`**。 + - 适用 scope: 本 change 的 `locateBrief`/`readBrief` 是该特例的通用解法方向,但**不改 archive 语义**,只在 brief 记明关系 +- `shadow-docs/knowledge/brief-frontmatter-crlf.md` + - 当前结论: frontmatter 行尾契约与 autocrlf 处理;解析失败必须响亮 `BRIEF_FRONTMATTER_REQUIRED`;#50 补了 CRLF 回写 LF 的用例。 + - 适用 scope: 迁移用文件级复制,不得改写行尾;迁移后新树内 brief 必须仍可解析 +- `shadow-docs/knowledge/cli-output-contract.md` + - 当前结论: stdout 恒单行 JSON 契约,人类层走 stderr;新增字段属 additive,须同步契约测试。 + - 适用 scope: `worktree.execute`→`relocated`,`worktree.remove.execute`→`relocatedBack`,二者进 help 摘要与用例断言 +- `shadow-docs/knowledge/install-distribution.md` · 内容包 `knowledge/distribution-capability-contract.md` + - 当前结论: 兼容判定用能力名;**跨仓演进顺序=执行层先发布支持、内容包随后声明**。 + - 适用 scope: 本 change 不新增命令键,故两侧声明均不变;发版归独立 change +- `rules/behavior.md` §6(内容包:不要删你不懂的东西)/ §15(不要用历史覆盖 active Knowledge) + - 当前结论: 删除/替换存量前先查引用链搞清楚为什么存在。 + - 适用 scope: 既有 5 条 worktree 用例的断言基线要逐条核对,不能整批改写掩盖真实回归 + +## 决策 + +- **选型:** **A. 迁移而非复制,归属树唯一,且 harness 尊重域提供的跨树定位器**。 + 1. `lib/domains/worktree.mjs`:新增 `briefRel`/`tracked`/`treeMap`/`relocate`/`locateRoot`/`locateBrief`/`dirtyBesidesBrief`。`execute` 在状态回写**之后**,若主树该 brief 为未跟踪则整目录迁入新树(先逐文件校验一致再删源,不一致则回退目标并抛 `RELOCATION_MISMATCH` 保留源),输出 `relocated`;已跟踪时一行不动(I6)。`removeExecute` 在 `git worktree remove` **之前**把未提交的 brief 搬回主树,输出 `relocatedBack`;脏判定改走 `dirtyBesidesBrief`(载体豁免,I5)。`state`/`planData`/`removePlanData` 统一经 `locateBrief` 取 brief,`activeBriefs` 覆盖主树与全部工作树(防止归属反查失明与同名 change 被重复创建)。 + 2. `cli.mjs`:`readBrief(mod, r, o)`——域导出 `locateBrief` 时优先用它,否则退回 `brief(r, name(o))`。这是必须的一步:harness 在 `executeDomain`/`planDomain` 里硬按 cwd 所在树读 brief,跨树回收会在进入域逻辑**之前**就 `BRIEF_NOT_FOUND`。其余 13 个域不改(它们按归属树读写正是设计意图)。 + 3. 测试基线纠正:既有 5 条用例逐条核对(brief 未跟踪是 fixture 的真实状态,迁移后它们的断言仍成立——回收用例靠 `relocatedBack` 复原),新增 3 条迁移用例锁 I1/I2/I4/I5,并把「稳态」用例改为**真正提交 brief** 后断言 `relocated:false`(原用例把未跟踪误当稳态)。 + 4. 知识闭环:新增 `shadow-docs/knowledge/worktree-brief-relocation.md`(唯一性/不丢数据/载体豁免三条执行约束)并加 `shadow-docs/menu.md` 路由;本仓首次登记 `shadow-docs/domain.md` 上下文地图(内容包已在 6.8.0 落地该规范,本仓补上,`domain` 值域=`cli-infrastructure`/`install-distribution`/`github-integration`)。 +- **对比方案:** + - **B. 复制到新树、主树留一份**:未选。两份受管状态各自演进就是 divergence;`task set` 在 A 树、`review` 在 B 树会产生两个真相,比报错难发现得多。 + - **C. 要求先提交 brief 再建 workspace**:未选。把工具缺陷转嫁为流程纪律,且「brief 已提交」是本就该支持的稳态而非每个 change 的起点;内容包实测两次因此降级 inline。 + - **D. `worktree add` 后自动 `git commit` brief**:未选。提交是显式动作(`commit plan/execute` 带文件集与 planHash 凭证),自动提交会绕过凭证链语义。 + - **E. 复用我上一轮的 `change amend` 简版**:未选,**主动废弃**。#50 的实现严格更强(`reviewReset` 作废已 passed review、`--scope` 同修、幂等短路、CRLF 回写、专用错误码 `AMEND_INPUT_REQUIRED`、5 个用例 + 知识闭环)。旧分支 `feature/20261010-feature-workspace-brief-relocation` 因此不合入,只从中移植 worktree 迁移部分。 +- **理由:** brief 的唯一性是既有不变量(`change create` 的 `CHANGE_EXISTS` 门与 `conflict.inspect` 都以「一个变更一份 brief」为前提),要让专属 workspace 可用,唯一诚实的做法是让「归属哪棵树」成为显式概念并可观测,而不是让执行者手工搬文件。 + +## 任务 + +### Phase 1 — 迁移实现 +- [x] brief 迁入专属工作树 — `lib/domains/worktree.mjs` — `relocate`(逐文件校验一致再删源,失败回退目标并抛 `RELOCATION_MISMATCH`);`execute` 在状态回写后按主树跟踪态决定是否迁移,输出 `relocated` +- [x] brief 搬回与载体豁免 — `lib/domains/worktree.mjs` — `removeExecute` 删目录前搬回未提交 brief,输出 `relocatedBack`;`dirtyBesidesBrief` 排除 brief 自身,真实脏改动仍 `WORKTREE_DIRTY` +- [x] 跨树定位与发现 — `lib/domains/worktree.mjs`, `cli.mjs`, `lib/commands.mjs` — `locateRoot`/`locateBrief` 覆盖主树与全部工作树,`activeBriefs` 跨树去重;harness `readBrief` 优先用域定位器;两条 worktree help 摘要与双语说明补迁移语义 +### Phase 2 — 契约测试与全量回归 +- [x] 三条迁移用例 — `test/cli.test.mjs` — 未提交 brief 迁入(I1/I2/I3:新树 `task list` 可用、主树不留副本);回收搬回(I4/I5:`dirty:false`、`relocatedBack`、`workflow.worktree=null`);稳态不迁移(I6,先把 brief 真提交再断言 `relocated:false`) +- [x] 不变量探针取证(口径已由用户裁定变更) — `shadow-docs/changes/20261010-fix-worktree-uncommitted-brief/brief.md` — 2026-10-11 用户裁定「完全干掉 TDD,转向 DDD」,**「全量测试套件必须绿」不再是合并门槛**;本条改为对四条不变量取真实观察点:E2E 全链手工复现 + 分片测试作为可选契约探针,探针未绿项如实标注不作伪证 +### Phase 3 — 知识闭环与跨仓说明 +- [x] 迁移知识与菜单路由 — `shadow-docs/knowledge/worktree-brief-relocation.md`, `shadow-docs/menu.md` — 唯一性/不丢数据/载体豁免三条执行约束 + 适用边界(跨树定位仅 worktree 域,其它域按归属树)+ 验证方式 +- [x] 本仓上下文地图 — `shadow-docs/domain.md` — 登记 `cli-infrastructure`/`install-distribution`/`github-integration` 三上下文与上游 `workflow-governance` 的反腐边界,使卡片 `domain` 有值域可依 +- [x] 跨仓顺序与偏差记录 — `shadow-docs/changes/20261010-fix-worktree-uncommitted-brief/brief.md` — 结果段记观察点与 `change amend` 让位说明;遗留段写明「`change.amend` 进入内容包 `requiresCommands` 必须等 v1.6.0 发布并升级本机 CLI,否则 `ARTIFACT_INCOMPATIBLE` 判红」 + +## 结果 + +- 实际耗时: ≈50 分钟(其中约 30 分钟折在「跑全量测试」上——该目标本身已被用户裁定废除,见下方口径变更) +- **验收口径变更(用户在执行中裁定,非静默降级)**:2026-10-11 指令「给我去掉所有的 TDD,我现在已经完全干掉 TDD 转向 DDD 了」。据此本 change 的完成判据从「108 条用例全绿」改为「四条不变量各有真实观察点」;测试文件保留为**可选契约探针**,不再充当门槛。 + +- 验证: **L 级=每条不变量一个可追溯观察点**。主证据是真实 CLI 端到端复现(临时 git 仓,7 步逐个取输出): + +| # | 不变量 | 观察点与真实输出 | +|---|--------|------------------| +| I1 唯一性 | brief 迁入后主树不留副本 | `[1] relocated= True branch= fix/demo` → `[2] 主树是否残留副本 → 无副本(I1 唯一性成立)` | +| I2 迁移完整性 | 状态回写后才迁移,内容一致 | 迁移后的工作树内 brief 解析出 `branch=fix/demo`、`workflow.worktree` 指向新树、`status=branched`;`[3] 工作树内任务可读 → tasks= 1`(I3 可发现性同时成立) | +| I3 可发现性 | 归属树内命令可用,主树跨树可定位 | 上列 `task list` 在工作树内 `tasks=1`;主树 `worktree inspect` → `recommendation:"reuse"`(契约用例同断言) | +| I4 回收不丢数据 | 删目录前先搬回 | `[4] dirty= False` → `[6] relocatedBack= True removed= True` → `[7] 回收后主树 brief: 在 · 工作树: 已删 · worktree 字段: None` | +| I5 载体豁免不放水 | 真实用户改动仍判脏并拒绝回收 | 向工作树写入 `user-work.js` 后重规划 → `dirty= true`;`remove execute` 被拒(用例断言 `WORKTREE_DIRTY`;手工探针那次因未重规划先撞 `PLAN_HASH_INVALID`,属凭证链预期行为——`dirty` 参与 planData) | +| I6 稳态零回归 | 已跟踪 brief 不迁移 | 用例 `tracked brief is left in place`:先 `git add + commit` 真造稳态,再断言 `relocated:false` 且主树副本在 | + +- 分片探针结果(沙箱内,27 条一组):`# tests 27 / # pass 23 / # fail 4`。**4 条失败全部是 `null !== 0`**——子进程被本机 node 139 段错误风暴打死、`spawnSync` 返回 null,非断言失败。含 #50 自带的两条 amend 用例与 `index rebuild plan is stable`、`version stdout …` 各一条,即:这 4 条与本次改动无因果关系,但**在本机无法证明它们绿**(诚实标注为未验证,不当作已通过)。 +- 取证环境教训(值得进知识):`node --test` 在**越权(有网)环境下静默挂死**、在沙箱内 27 条约 23 秒跑完;`tty=true` 会让 `runTty` 的 `spawnSync` 继承父 stdin 而永久阻塞;macOS 无 `setsid`。三者都表现为「无输出」,与真失败无法区分——只能靠分片 + 前台 + 关 stdin 取证。**这正是用户废除「全量必须绿」门槛的现实理由。** +- 交付: 待 push / PR(base=main,已含 #49/#50) + +## 知识评估 + +- **预期影响:** 新增 + 更新 +- **候选卡片:** `shadow-docs/knowledge/worktree-brief-relocation.md`(新增,已落)、`shadow-docs/knowledge/brief-state-commit.md`(原位追加与迁移卡的因果链接,已落)、`shadow-docs/menu.md`(新卡路由,已落)、`shadow-docs/domain.md`(本仓首次登记上下文地图,已落) +- **理由:** 唯一性/不丢数据/载体豁免/归属树是本仓长期执行约束,值得独立成卡;`brief-state-commit.md` 的 archive push 特例被本次工作解释出根因(brief 位置未被当作一等事实),属原位更新追加 source,不建重复卡。**`verified-depth` 如实记 `unit` 而非 `runtime`**:E2E 是真实命令输出,但发生在临时仓而非发布产物上;升 `runtime` 需等 v1.6.0 产物实装。未达到的深度不虚报。 diff --git a/shadow-docs/domain.md b/shadow-docs/domain.md new file mode 100644 index 0000000..f0f196e --- /dev/null +++ b/shadow-docs/domain.md @@ -0,0 +1,37 @@ +# shadow-dev-cli 上下文地图 + +> 本仓限界上下文与统一语言的登记处(判据见内容包 `norms/domain-model.md`)。 +> **domain 值域**=本页「限界上下文 ∪ 横切实践 ∪ 外部上游」登记的名称;知识卡 `domain` 取值必须落在其中。新上下文先登记再建卡。 + +## 限界上下文 + +| 上下文 | 职责 | 上游 / 下游 | 主目录与 scope | 关键术语 | +|---|---|---|---|---| +| `cli-infrastructure` | brief 生命周期、workspace 归属、plan/execute 凭证链、双通道输出面 | ← `workflow-governance`(内容包经能力契约驱动);→ 三宿主与项目仓 | `cli.mjs`, `lib/`, `test/cli.test.mjs` | brief / frontmatter / planHash / norm / 归属树 / workspace 迁移 / 零 dirty / stdout 契约 | +| `install-distribution` | 执行层自身的安装、shim、指针文件、产物物化与宿主绑定 | ← `workflow-governance` 的产物轨;→ `~/.local/share` 安装态与宿主目录 | `scripts/bootstrap.sh`, `scripts/install-cli.sh`, `scripts/pack.mjs`, `lib/domains/workflow.mjs`, `lib/domains/bind.mjs` | CURRENT/PREVIOUS/LINK 指针 / shim / sidecar / requiresCommands / ARTIFACT_INCOMPATIBLE / missingCommands | +| `github-integration` | GitHub 侧写操作:issue 渲染、PR 复用、合并校验、归档触发 | ← `cli-infrastructure` 的凭证链;→ api.github.com | `lib/github.mjs`, `lib/issue-render.mjs`, `lib/domains/issue.mjs`, `lib/domains/publish.mjs`, `lib/domains/archive.mjs` | issuePlan / bodySha256 / 机器通道 / PR 复用 / PR_NOT_MERGED | + +## 横切实践 + +跨上下文的**方法**而非业务边界:允许登记为 `domain` 值,但不得当限界上下文扩写。 + +| 名称 | 说明 | 关联 | +|---|---|---| +| (无) | 本仓当前没有横切实践条目;内容包侧的 `debugging` 不在本仓建卡,故不登记 | — | + +## 外部上游(本仓不定义其规范语义,只经能力契约被驱动) + +| 名称 | 仓库 | 反腐边界 | +|---|---|---| +| `workflow-governance` | `stack-wuh/shadow-dev-workflow` | 依赖单向:**执行层驱动内容包**(`bootstrap → workflow plan/execute → bind plan/execute`)。内容包用 `package.json.requiresCommands` 声明所需命令键,本仓在**任何落盘前**用自身命令目录断言,缺失即 `ARTIFACT_INCOMPATIBLE` 且指针不动;兼容判定用能力名不用版本号;跨仓演进顺序=本仓先发布支持、内容包随后声明 | + +## 术语表增量 + +- 概念定义唯一来源:内容包 `norms/domain-model.md`;本页只登记归属与关系。 +- **归属树 owning tree**:任一时刻决定「按 `--name` 去哪棵树读 brief」的那棵树,主工作区与某 worktree 互斥(详见 `shadow-docs/knowledge/worktree-brief-relocation.md`)。 +- **载体豁免 carrier exclusion**:brief 作为 CLI 管理载体不计入工作区脏改动。 + +## 维护规则 + +- 新增/改名上下文:先改本页 → 再动卡片 `domain` 与本仓 `shadow-docs/menu.md` → 两侧不一致以本页为准。 +- 本页只放长期事实;单次变更过程留在 `shadow-docs/changes/`。 diff --git a/shadow-docs/knowledge/brief-state-commit.md b/shadow-docs/knowledge/brief-state-commit.md index 3831ac9..4c923a5 100644 --- a/shadow-docs/knowledge/brief-state-commit.md +++ b/shadow-docs/knowledge/brief-state-commit.md @@ -20,7 +20,7 @@ verified-depth: runtime ## 执行约束 - 新增写 brief 的 execute 域:`write(b)` 之后必须调用 `briefStateCommit(r, b, "<最终态描述>")`;无差异返回 null 不造空 commit,重跑幂等。 -- 需要远端同步的域(publish/release/archive):补提交之后必须 `pushTip`/`git push`;push 失败一律响亮 `GIT_PUSH_FAILED`,禁止吞错续跑。archive 的 push 失败特例:归档提交已落本地 main,续跑不可经 plan(harness 读旧路径 BRIEF_NOT_FOUND),恢复需人工 push 一次。 +- 需要远端同步的域(publish/release/archive):补提交之后必须 `pushTip`/`git push`;push 失败一律响亮 `GIT_PUSH_FAILED`,禁止吞错续跑。archive 的 push 失败特例:归档提交已落本地 main,续跑不可经 plan(harness 读旧路径 BRIEF_NOT_FOUND),恢复需人工 push 一次——其**根因是「brief 的位置」未被当作一等事实**;通用解法见 `worktree-brief-relocation.md`(brief 随 workspace 迁移 + harness 尊重 `locateBrief`),本条特例在迁移语义落地后可逐步收编。 - `commit/publish/release.execute` 的 `briefCommit`(sha 或 null)与 `archive.execute` 的 `pushed` 是 stdout JSON 公开契约(additive),改动须同步 `zero-dirty` 前缀的契约测试。 - review 阶段可用机械检查核对 execute 端点树是否干净(porcelain 为空)。 diff --git a/shadow-docs/knowledge/worktree-brief-relocation.md b/shadow-docs/knowledge/worktree-brief-relocation.md new file mode 100644 index 0000000..d1c5268 --- /dev/null +++ b/shadow-docs/knowledge/worktree-brief-relocation.md @@ -0,0 +1,51 @@ +--- +title: brief 随专属 workspace 迁移与归属树 +domain: cli-infrastructure +keywords: [worktree, workspace, 专属工作区, brief 迁移, relocate, 未跟踪, 唯一真相, 状态分裂, 归属树, 载体豁免, WORKTREE_DIRTY, BRIEF_NOT_FOUND, L 级并行] +scope: [lib/domains/worktree.mjs, cli.mjs, lib/commands.mjs, test/cli.test.mjs] +status: active +source: + - changes/20261010-fix-worktree-uncommitted-brief/brief.md +verified: 2026-10-11 +verified-depth: unit +verified-scope: 待 Phase 2 全量测试收敛后按实际深度更新(本机 node --test 受 139 段错误风暴干扰,需禁编译缓存 + 串行重试) +--- + +# brief 随专属 workspace 迁移与归属树 + +## 当前结论 + +`git worktree add` 从**基线**派生新工作树,而 `change create` 刚写的 brief 是未跟踪文件——新树里没有它。因此专属 workspace 必须**迁移** brief:把该 change 唯一的 brief 目录整体搬进新工作树,旧位置不留副本。 + +复制不是可选项:同一 change 出现两份可各自演进的 brief 就是受管状态分裂,`task set` 落 A 树、`review` 落 B 树会产生两个真相,其表征(静默数据损坏)远比一次报错难发现。 + +**归属树(owning tree)**:任一时刻决定「按 `--name` 去哪棵树读 brief」的那棵树,主工作区与某 worktree 互斥。除 `worktree` 域需要跨树定位(`locateBrief`)外,其余命令域一律按 cwd 所在树读写——这是设计意图,不是缺陷。 + +## 执行约束 + +- 迁移只发生在**状态回写之后**:`branch`、`status`、`workflow.worktree` 先写进 brief,再搬目录;迁移必须逐文件校验一致后才删源,校验失败回退目标并响亮抛 `RELOCATION_MISMATCH`、保留源文件。 +- brief 已跟踪(已提交)时**一行都不动**,`relocated:false`。专属 workspace 的稳态路径不得被迁移逻辑改写。 +- 回收不得吞掉唯一真相:`worktree remove execute` 在 `git worktree remove` 之前把未提交的 brief 搬回主工作树(`relocatedBack`),随后清空 `workflow.worktree`。 +- **载体豁免**:未提交的 brief 不构成 `WORKTREE_DIRTY`(它是 CLI 的管理载体,不是用户工作);真实用户改动仍必须照旧拒绝回收。脏判定属行为门禁,须留在 `planData` 内联执行,且**不得进入 planHash 输入**。 +- harness 层(`planDomain`/`executeDomain`)必须尊重域导出的 `locateBrief`:硬按 cwd 所在树读 brief 会让跨树回收在进入域逻辑之前就 `BRIEF_NOT_FOUND`。 +- `activeBriefs` 需覆盖主树与全部 worktree 并跨树去重,否则归属反查失明、同名 change 可被重复创建。 +- 内容包侧不得把「L 级必须建专属 workspace」写成无条件要求:新立项(brief 未提交)场景可用,但降级 inline 是合法选择,需在 brief「决策」里写明隔离手段(见内容包 `norms/domain-model.md` 与本仓 README)。 + +## 适用边界 + +适用于任何「工作树从基线派生 + 起点产物未入库」的工具结构(脚手架、任务态文件、变更单)。不适用于所有文件已入库的场景——那时派生即自带上下文,迁移只会制造分裂。 + +## 验证方式 + +1. 未跟踪 brief:`worktree execute` → `relocated:true`;主树 `shadow-docs/changes//brief.md` 不存在,工作树内存在且 `branch`/`workflow.worktree`/`status` 已回写;在工作树内 `task list` 成功;主树内 `worktree inspect` → `recommendation:"reuse"`。 +2. 回收:`worktree remove plan` → `dirty:false`;`remove execute` → `relocatedBack:true`、目录消失、`workflow.worktree=null`。 +3. 载体豁免不得放水:工作树内有真实新文件时 → `WORKTREE_DIRTY`、退出码 1。 +4. 稳态零回归:先提交 brief 再 `worktree execute` → `relocated:false`,主树副本仍在。 +5. `node --test test/cli.test.mjs` 全绿(既有 5 条 worktree 用例与上述新用例同处一套基线)。 + +## 关联知识 + +- [brief 最终态提交与零 dirty 不变量](brief-state-commit.md) +- [plan/execute 凭证链与哈希边界](plan-credential-chain.md) +- [brief.md frontmatter 行尾契约](brief-frontmatter-crlf.md) +- [CLI 双通道输出契约](cli-output-contract.md) diff --git a/shadow-docs/menu.md b/shadow-docs/menu.md index 77084ac..d011639 100644 --- a/shadow-docs/menu.md +++ b/shadow-docs/menu.md @@ -11,4 +11,5 @@ | plan/execute 凭证 | planHash hash 漂移 norm changedFiles 脏工作区 porcelain trim 凭证链 | knowledge/plan-credential-chain.md | | 安装与分发 | install 安装器 shim 指针文件 LINK CURRENT 双轨 回滚 插件钩子 tarball Git Bash PowerShell requiresCommands 能力契约 missingCommands ARTIFACT_INCOMPATIBLE 版本错位 | knowledge/install-distribution.md | | issue 正文结构 | issue 正文 渲染器 issue-render issuePlan metadata 机器通道 bodySha256 sections 前缀 最小投影 统一结构 | knowledge/issue-body-contract.md | +| brief 与 workspace 归属 | worktree 专属工作区 并行 workspace brief 迁移 relocate relocatedBack 未跟踪 唯一真相 状态分裂 归属树 载体豁免 WORKTREE_DIRTY BRIEF_NOT_FOUND | knowledge/worktree-brief-relocation.md | | brief 最终态提交 | 零 dirty 最终态 补提交 briefCommit pushed 空提交 archive push 断点续跑 | knowledge/brief-state-commit.md | diff --git a/test/cli.test.mjs b/test/cli.test.mjs index 37d544e..3e8c374 100644 --- a/test/cli.test.mjs +++ b/test/cli.test.mjs @@ -1290,6 +1290,71 @@ function setRating(root, level) { } const WT_HOME = () => fakeHomeWithConfig({}) +// ---- brief 随专属 workspace 迁移(20261010-fix-worktree-uncommitted-brief)---- +// fixture 的 brief 是未跟踪文件——这正是「刚立项」的真实状态:worktree add 从基线派生的新树里没有它。 +// 唯一真相必须随 workspace 迁移;复制会留下两份可各自演进的受管状态(数据分裂)。 +const briefOf = (tree, name) => JSON.parse(readFileSync(join(tree, 'shadow-docs', 'changes', name, 'brief.md'), 'utf8').match(/---\r?\n([\s\S]*?)\r?\n---/)[1]) + +test('worktree: relocates an uncommitted brief so the workspace owns the single copy', () => { + const root = fixture() + const wt = mkdtempSync(join(tmpdir(), 'wt-')); rmSync(wt, { recursive: true, force: true }) + run(['worktree', 'plan', '--name', 'sample', '--path', wt, '--json'], root, WT_HOME()) + const exec = run(['worktree', 'execute', '--name', 'sample', '--path', wt, '--confirm', '--json'], root, WT_HOME()) + assert.equal(exec.status, 0, exec.stderr) + assert.equal(JSON.parse(exec.stdout).data.relocated, true, 'an uncommitted brief must be relocated') + assert.equal(existsSync(join(root, 'shadow-docs', 'changes', 'sample', 'brief.md')), false, 'primary checkout must not keep a second copy') + const b = briefOf(wt, 'sample') + assert.equal(b.branch, 'feat/sample'); assert.equal(b.workflow.worktree, wt); assert.equal(b.status, 'branched') + // I3 可发现性:在归属树里按 name 的命令可解析;跨树的 worktree 域也能定位 + assert.equal(run(['task', 'list', '--name', 'sample', '--json'], wt, WT_HOME()).status, 0) + const insp = run(['worktree', 'inspect', '--name', 'sample', '--json'], root, WT_HOME()) + assert.equal(insp.status, 0, insp.stderr) + assert.equal(JSON.parse(insp.stdout).data.recommendation, 'reuse') +}) + +test('worktree: remove relocates the uncommitted brief back and never treats it as dirty', () => { + const root = fixture() + const wt = mkdtempSync(join(tmpdir(), 'wt-')); rmSync(wt, { recursive: true, force: true }) + run(['worktree', 'plan', '--name', 'sample', '--path', wt, '--json'], root, WT_HOME()) + assert.equal(run(['worktree', 'execute', '--name', 'sample', '--path', wt, '--confirm', '--json'], root, WT_HOME()).status, 0) + const planned = run(['worktree', 'remove', 'plan', '--name', 'sample', '--json'], root, WT_HOME()) + assert.equal(planned.status, 0, planned.stderr) + assert.equal(JSON.parse(planned.stdout).data.dirty, false, 'the brief itself is a CLI carrier, not user work') + const removed = run(['worktree', 'remove', 'execute', '--name', 'sample', '--confirm', '--json'], root, WT_HOME()) + assert.equal(removed.status, 0, removed.stderr) + assert.equal(JSON.parse(removed.stdout).data.relocatedBack, true) + assert.equal(existsSync(wt), false, 'workspace removed') + const b = briefOf(root, 'sample') + assert.equal(b.workflow.worktree, null, 'ownership returns to the primary checkout') +}) + +test('worktree: user work in the workspace still blocks removal with WORKTREE_DIRTY', () => { + const root = fixture() + const wt = mkdtempSync(join(tmpdir(), 'wt-')); rmSync(wt, { recursive: true, force: true }) + run(['worktree', 'plan', '--name', 'sample', '--path', wt, '--json'], root, WT_HOME()) + assert.equal(run(['worktree', 'execute', '--name', 'sample', '--path', wt, '--confirm', '--json'], root, WT_HOME()).status, 0) + writeFileSync(join(wt, 'lib-new-file.js'), 'export const x = 1\n') + run(['worktree', 'remove', 'plan', '--name', 'sample', '--json'], root, WT_HOME()) + const replanned = JSON.parse(run(['worktree', 'remove', 'plan', '--name', 'sample', '--json'], root, WT_HOME()).stdout) + assert.equal(replanned.data.dirty, true, '真实用户改动仍必须判脏——载体豁免不得放水') + const removed = run(['worktree', 'remove', 'execute', '--name', 'sample', '--confirm', '--json'], root, WT_HOME()) + assert.equal(removed.status, 1) + assert.equal(JSON.parse(removed.stdout).error.code, 'WORKTREE_DIRTY', 'carrier exemption must not weaken the real dirty gate') +}) + +test('worktree: a tracked brief is left in place — steady state unchanged', () => { + const root = fixture() + // 稳态要真实造出来:先提交 brief,未跟踪态才不是「已入库的 change」 + execFileSync('git', ['add', '--', 'shadow-docs/changes/sample/brief.md'], { cwd: root }) + execFileSync('git', ['commit', '-qm', 'docs(shadow): track the sample brief'], { cwd: root }) + const wt = mkdtempSync(join(tmpdir(), 'wt-')); rmSync(wt, { recursive: true, force: true }) + run(['worktree', 'plan', '--name', 'sample', '--path', wt, '--json'], root, WT_HOME()) + const exec = run(['worktree', 'execute', '--name', 'sample', '--path', wt, '--confirm', '--json'], root, WT_HOME()) + assert.equal(exec.status, 0, exec.stderr) + assert.equal(JSON.parse(exec.stdout).data.relocated, false, 'tracked briefs must not move — zero regression surface') + assert.equal(existsSync(join(root, 'shadow-docs', 'changes', 'sample', 'brief.md')), true) +}) + test('worktree: execute creates a new worktree with derived branch and writes back the brief', () => { const root = fixture() const wt = mkdtempSync(join(tmpdir(), 'wt-')); rmSync(wt, { recursive: true, force: true }) @@ -1301,7 +1366,9 @@ test('worktree: execute creates a new worktree with derived branch and writes ba assert.equal(body.branch, 'feat/sample') assert.equal(existsSync(join(wt, 'README.md')), true, 'worktree checkout must contain repo files') assert.equal(execFileSync('git', ['-C', wt, 'branch', '--show-current'], { encoding: 'utf8' }).trim(), 'feat/sample') - const b = JSON.parse(readFileSync(join(root, 'shadow-docs', 'changes', 'sample', 'brief.md'), 'utf8').match(/---\n([\s\S]*?)\n---/)[1]) + // fixture 的 brief 未跟踪:迁移后唯一真相在工作树里(I1/I2),主树不应留副本 + assert.equal(existsSync(join(root, 'shadow-docs', 'changes', 'sample', 'brief.md')), false, 'relocated: no second copy in the primary checkout') + const b = briefOf(wt, 'sample') assert.equal(b.branch, 'feat/sample') assert.equal(b.workflow.worktree, wt) assert.equal(b.status, 'branched')