Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,24 @@ 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)
}
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)
Expand Down Expand Up @@ -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) }
}
Expand Down
4 changes: 2 additions & 2 deletions lib/commands.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <change-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 <change-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 <change-name> --path <dir>', '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 <change-name> --path <dir> --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 <change-name> --path <dir> --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 <change-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 <change-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 <change-name> --confirm', null),
'task.list': c('task list', '列出 brief 任务清单', 'list the brief task checklist', [N], 'shadow-dev task list --name <change-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 <change-name> --task task-1 --state done --confirm', null),
'review.plan': c('review plan', '预览审查记录', 'plan the review record', [N], 'shadow-dev review plan --name <change-name>', 'review execute --name {name} --conclusion passed --confirm'),
Expand Down
96 changes: 80 additions & 16 deletions lib/domains/worktree.mjs
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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
Expand All @@ -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,
Expand All @@ -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 <dir> for the new worktree of ${n}`, 1)
const abs = resolve(r, path)
Expand All @@ -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 }
}

Expand All @@ -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 }
}
Loading
Loading