sillyspec 3.27.7 → 3.27.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/contract-matrix.js +39 -11
- package/src/index.js +25 -0
- package/src/progress/stage-machine.js +3 -1
- package/src/run/command.js +46 -15
- package/src/run/complete-handlers.js +20 -0
- package/src/run/complete.js +10 -1
- package/src/run/gates.js +38 -3
- package/src/run/multi-repo-context.js +42 -4
- package/src/run/shared.js +1 -1
- package/src/run/stage.js +28 -2
- package/src/stage-contract.js +20 -7
- package/src/stages/execute.js +35 -15
- package/src/stages/plan.js +8 -0
- package/src/task-review.js +7 -0
- package/src/worktree-apply.js +196 -4
- package/src/worktree-cross.js +294 -0
- package/src/worktree-deps.js +156 -58
package/src/stages/plan.js
CHANGED
|
@@ -137,6 +137,12 @@ const stepGeneratePlan = {
|
|
|
137
137
|
4. **写回任务清单**:把展开后的任务清单写回 tasks.md(checkbox 行 \`- [ ] task-XX: 一句话任务名\`,可附 \`[model:xxx]\`/\`(depends_on: task-01,02)\` 行内标注)。**写回规则(D-002@v1)**:保留 frontmatter/中文标题/所有非 task-XX 行(quick 挂载的 ql-xxx 勾选行、注记等逐行保留),仅重写 task-XX checkbox 行集合——防摧毁 quick 挂载条目
|
|
138
138
|
5. 保存 plan.md(审查在下一步"审查计划"独立进行,不在本步自审——避免生成与自审同一次输出);frontmatter 保留 plan_level 字段不删。**plan.md Wave 段下任务一律纯 ID 引用行**(\`- task-XX\`,不重抄任务名——任务名唯一真相在 tasks.md,重抄即双写漂移)
|
|
139
139
|
|
|
140
|
+
> ⚠️ **Wave 段格式铁律(踩坑:旧先例 \`- [ ] task-XX: 描述\` 格式会导致 postcheck 告警):**
|
|
141
|
+
> ✅ 正确:\`- task-01\`(裸 ID 引用行,无 checkbox 无任务名)
|
|
142
|
+
> ❌ 错误:\`- [ ] task-01: 添加用户创建接口\`(checkbox + 任务名——这是 tasks.md 格式,不是 plan.md Wave 段格式)
|
|
143
|
+
> tasks.md 格式:\`- [ ] task-01: 一句话任务名\`(execute 从这里解析任务清单)
|
|
144
|
+
> plan.md Wave 段格式:\`- task-01\`(纯 ID 引用,execute 据此分组)
|
|
145
|
+
|
|
140
146
|
---
|
|
141
147
|
|
|
142
148
|
#### plan_level = none
|
|
@@ -446,6 +452,8 @@ related_tests: # 可选。当本 task 改动会导致
|
|
|
446
452
|
- **task id 从 1 连续**:task-01、task-02、task-03… 不能跳号或重号(postcheck 校验 id 连续性,gap 会拦)
|
|
447
453
|
这两条是跨 task 全局约束,子代理只写单卡看不到全局——你(主 agent)分派子代理前必须先在 plan.md 里确认 Wave 划分与编号正确。
|
|
448
454
|
|
|
455
|
+
⚠️ **TaskCard 必备字段(缺一 postcheck 直接阻断):** id、title(英文)、**title_zh(中文标题,必填)**、allowed_paths、goal、implementation、acceptance、verify、constraints。骨架已由 taskcard CLI 预生成(含 title_zh 占位),子代理只需 Edit 填值,不要删除任何必备字段。
|
|
456
|
+
|
|
449
457
|
## 任务清单
|
|
450
458
|
${taskList}
|
|
451
459
|
|
package/src/task-review.js
CHANGED
|
@@ -1107,11 +1107,18 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1107
1107
|
const reviewPath = join(reviewDir, 'review.json')
|
|
1108
1108
|
|
|
1109
1109
|
// 幂等:review.json 已存在(无论合法/解析错/schema 错)一律跳过,绝不覆盖人工/子代理 verdict
|
|
1110
|
+
// 双重守卫:readReview 判定 + existsSync 兜底(坑 task-review-draft-overwrite:已存在真实
|
|
1111
|
+
// review 仍被草稿覆盖的变体——readReview 路径/权限异常时 existsSync 作为最后防线)
|
|
1110
1112
|
const existing = readReview(reviewPath)
|
|
1111
1113
|
if (existing.ok || existing.parseError || existing.schemaError) {
|
|
1112
1114
|
skipped++
|
|
1113
1115
|
continue
|
|
1114
1116
|
}
|
|
1117
|
+
if (existsSync(reviewPath)) {
|
|
1118
|
+
// readReview 未捕获但文件存在(极端:权限/路径长度等)→ 宁跳不覆盖
|
|
1119
|
+
skipped++
|
|
1120
|
+
continue
|
|
1121
|
+
}
|
|
1115
1122
|
|
|
1116
1123
|
const content = readFileSync(join(tasksDir, tf), 'utf8')
|
|
1117
1124
|
const allowedPaths = parseAllowedPaths(content)
|
package/src/worktree-apply.js
CHANGED
|
@@ -16,6 +16,7 @@ import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync
|
|
|
16
16
|
import { join, resolve } from 'path';
|
|
17
17
|
import { tmpdir } from 'os';
|
|
18
18
|
import { WorktreeManager } from './worktree.js';
|
|
19
|
+
import { getCrossWorktreeMeta, cleanupCrossWorktrees } from './worktree-cross.js';
|
|
19
20
|
import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
|
|
20
21
|
import { parseAllowedPaths, parseRepo } from './stages/plan-postcheck.js';
|
|
21
22
|
import { git, gitQuiet } from './git-helper.js';
|
|
@@ -440,6 +441,182 @@ export async function withMainRepoLock(projectRoot, changeName, purpose, fn, opt
|
|
|
440
441
|
}
|
|
441
442
|
}
|
|
442
443
|
|
|
444
|
+
/**
|
|
445
|
+
* 跨仓 worktree 真实 apply(坑 cross-repo-no-worktree-isolation,2026-08-27)。
|
|
446
|
+
*
|
|
447
|
+
* 对有 worktree meta 的跨仓仓(worktree-cross.js 创建)执行与主仓 A5 同构的 patch 回落:
|
|
448
|
+
* deliverables(diff baselineCommit||baseHash + untracked,filterDeliverableFiles 排除基础设施)
|
|
449
|
+
* → 清单校验(resolveApplyAllowSet 按 repo 切片 ∪ review 声明切片)
|
|
450
|
+
* → 跨仓主工作副本 dirty 重叠拦截
|
|
451
|
+
* → patch(tracked diff + untracked add/diff --cached/reset,Buffer 防二进制损坏)
|
|
452
|
+
* → git apply --3way 回跨仓主工作副本;成功后 cleanup 该跨仓 worktree(分支保留作锚)。
|
|
453
|
+
*
|
|
454
|
+
* 与主仓 apply 的顺序契约:本函数在主仓 patch 动作**之前**调用——任一跨仓失败即整体报错返回,
|
|
455
|
+
* 主仓工作区不动(不留半套状态)。legacy 直写模式的跨仓仓无 meta,不走本函数(原 no-op 校验)。
|
|
456
|
+
*
|
|
457
|
+
* @param {string} changeName
|
|
458
|
+
* @param {string} projectRoot 主仓根
|
|
459
|
+
* @param {object} ctx MultiRepoContext
|
|
460
|
+
* @param {{ checkOnly?: boolean }} opts
|
|
461
|
+
* @returns {{ repos: string[], applied: Array<{repoKey, changedFiles}>, errors: string[], warnings: string[], crossCleanups: string[] }}
|
|
462
|
+
*/
|
|
463
|
+
function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = false } = {}) {
|
|
464
|
+
const specBase = (ctx.platformOpts && ctx.platformOpts.specRoot) || join(projectRoot, '.sillyspec');
|
|
465
|
+
const out = { repos: [], applied: [], errors: [], warnings: [], crossCleanups: [] };
|
|
466
|
+
for (const [repoKey, entry] of ctx.repos) {
|
|
467
|
+
if (entry.isMain) continue;
|
|
468
|
+
const cm = getCrossWorktreeMeta(specBase, changeName, repoKey);
|
|
469
|
+
if (!cm || !cm.isCross || !cm.worktreePath || !existsSync(cm.worktreePath)) continue;
|
|
470
|
+
out.repos.push(repoKey);
|
|
471
|
+
const crossRoot = cm.crossRepoRoot || entry.projectRoot;
|
|
472
|
+
const deliverableBase = cm.baselineCommit || cm.baseHash;
|
|
473
|
+
|
|
474
|
+
// 交付集合(与主仓 step2 同口径:--name-status 捕 rename/delete + untracked,过滤基础设施文件)
|
|
475
|
+
let changedFiles = [];
|
|
476
|
+
const deletedFiles = [];
|
|
477
|
+
try {
|
|
478
|
+
const statusRaw = git(cm.worktreePath, ['diff', '--name-status', deliverableBase]);
|
|
479
|
+
const statusFiles = new Set();
|
|
480
|
+
if (statusRaw) {
|
|
481
|
+
for (const line of statusRaw.split('\n').filter(Boolean)) {
|
|
482
|
+
const parts = line.split('\t');
|
|
483
|
+
if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
|
|
484
|
+
if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
|
|
485
|
+
if (parts[0].startsWith('D')) deletedFiles.push(parts[parts.length - 1]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
const untrackedRaw = gitQuiet(cm.worktreePath, ['ls-files', '--others', '--exclude-standard']) || '';
|
|
489
|
+
changedFiles = filterDeliverableFiles([...new Set([...statusFiles, ...untrackedRaw.split('\n').filter(Boolean)])]);
|
|
490
|
+
} catch (e) {
|
|
491
|
+
out.errors.push(`跨仓 ${repoKey}:获取变更文件列表失败(${cm.worktreePath}): ${e.message}`);
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
if (changedFiles.length === 0) {
|
|
496
|
+
if (!checkOnly) {
|
|
497
|
+
const cr = cleanupCrossWorktrees({ cwd: crossRoot, changeName, specBase, force: true });
|
|
498
|
+
for (const r of cr.results) {
|
|
499
|
+
if (r.result === 'cleaned') out.crossCleanups.push(`${repoKey}(无变更,worktree 已清理)`);
|
|
500
|
+
else out.warnings.push(`跨仓 ${repoKey} 无变更但清理 partial:${(r.details || []).join('; ')}`);
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
out.applied.push({ repoKey, changedFiles: [] });
|
|
504
|
+
continue;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// 清单校验(per-repo 切片 + review 声明切片并入,与主仓 Gate1/3b 同语义)
|
|
508
|
+
const allowMap = resolveApplyAllowSet(projectRoot, changeName);
|
|
509
|
+
let allowSet = allowMap.get(repoKey) || new Set();
|
|
510
|
+
const reviewDeclared = collectReviewDeclaredFiles(projectRoot, changeName).get(repoKey);
|
|
511
|
+
if (reviewDeclared) {
|
|
512
|
+
const merged = new Set([...allowSet]);
|
|
513
|
+
for (const f of reviewDeclared) {
|
|
514
|
+
if (![...merged].some(ap => pathMatches(f, ap))) merged.add(f);
|
|
515
|
+
}
|
|
516
|
+
allowSet = merged;
|
|
517
|
+
}
|
|
518
|
+
if (allowSet.size > 0) {
|
|
519
|
+
const violations = classifyAllowListViolations(changedFiles, allowSet);
|
|
520
|
+
if (violations.length > 0) {
|
|
521
|
+
out.errors.push(
|
|
522
|
+
`跨仓 ${repoKey}:文件清单校验失败——以下变更文件不在 design.md 清单、也不在该仓 task review.json changedFiles 声明中:\n ${violations.join('\n ')}`
|
|
523
|
+
);
|
|
524
|
+
if (!checkOnly) continue;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if (checkOnly) {
|
|
529
|
+
out.applied.push({ repoKey, changedFiles });
|
|
530
|
+
continue;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// 跨仓主工作副本 dirty 重叠拦截(与主仓 step4.5 同语义:重叠 = git apply 无法安全应用)
|
|
534
|
+
try {
|
|
535
|
+
const dirtyFiles = [...new Set(
|
|
536
|
+
((gitQuiet(crossRoot, ['diff', '--name-only', 'HEAD']) || '').split('\n').filter(Boolean))
|
|
537
|
+
.concat((gitQuiet(crossRoot, ['ls-files', '--others', '--exclude-standard']) || '').split('\n').filter(Boolean))
|
|
538
|
+
)].filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
539
|
+
const overlap = dirtyFiles.filter(f => changedFiles.includes(f));
|
|
540
|
+
if (overlap.length > 0) {
|
|
541
|
+
out.errors.push(
|
|
542
|
+
`跨仓 ${repoKey}(${crossRoot})主工作副本以下未提交文件与本次 apply 重叠,无法安全应用:\n ${overlap.join('\n ')}\n` +
|
|
543
|
+
`请先在跨仓仓提交或 stash 这些改动后重试 apply。`
|
|
544
|
+
);
|
|
545
|
+
continue;
|
|
546
|
+
}
|
|
547
|
+
} catch (e) {
|
|
548
|
+
out.errors.push(`跨仓 ${repoKey}:dirty 重叠检测失败(${crossRoot}): ${e.message}`);
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// patch 生成 + apply --3way(tracked/untracked 分流与 Buffer 聚合,与主仓 step7 同款)
|
|
553
|
+
const patchFiles = allowSet.size > 0
|
|
554
|
+
? resolvePatchFiles(changedFiles, allowSet, true)
|
|
555
|
+
: changedFiles;
|
|
556
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-cross-'));
|
|
557
|
+
const patchPath = join(tmpDir, 'apply.patch');
|
|
558
|
+
try {
|
|
559
|
+
const inTree = new Set(
|
|
560
|
+
(gitQuiet(cm.worktreePath, ['ls-tree', '-r', '--name-only', deliverableBase]) || '').split('\n').filter(Boolean)
|
|
561
|
+
);
|
|
562
|
+
const inIndexRaw = patchFiles.length > 0
|
|
563
|
+
? (gitQuiet(cm.worktreePath, ['ls-files', '--', ...patchFiles]) || '')
|
|
564
|
+
: '';
|
|
565
|
+
const inIndex = new Set(inIndexRaw.split('\n').filter(Boolean));
|
|
566
|
+
const trackedFiles = patchFiles.filter(f => inTree.has(f) || inIndex.has(f));
|
|
567
|
+
const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
|
|
568
|
+
const patchParts = [];
|
|
569
|
+
if (trackedFiles.length > 0) {
|
|
570
|
+
patchParts.push(git(cm.worktreePath, ['diff', '--binary', deliverableBase, '--', ...trackedFiles], { encoding: 'buffer', timeout: 60000 }));
|
|
571
|
+
}
|
|
572
|
+
if (untrackedPatchFiles.length > 0) {
|
|
573
|
+
git(cm.worktreePath, ['add', '--', ...untrackedPatchFiles]);
|
|
574
|
+
try {
|
|
575
|
+
patchParts.push(git(cm.worktreePath, ['diff', '--binary', '--cached', '--', ...untrackedPatchFiles], { encoding: 'buffer', timeout: 60000 }));
|
|
576
|
+
} finally {
|
|
577
|
+
gitQuiet(cm.worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
const patchContent = Buffer.concat(patchParts);
|
|
581
|
+
if (patchContent.length === 0) {
|
|
582
|
+
out.applied.push({ repoKey, changedFiles: [] });
|
|
583
|
+
continue;
|
|
584
|
+
}
|
|
585
|
+
writeFileSync(patchPath, patchContent);
|
|
586
|
+
try {
|
|
587
|
+
git(crossRoot, ['apply', '--3way', patchPath], { timeout: 30000 });
|
|
588
|
+
} catch (e) {
|
|
589
|
+
// 冲突回滚:tracked 文件还原 HEAD 版,--3way 新建的部分文件删除(与主仓 rollbackApply 同语义)
|
|
590
|
+
const rawStderr = e.message || '';
|
|
591
|
+
for (const f of patchFiles) {
|
|
592
|
+
try {
|
|
593
|
+
if (gitQuiet(crossRoot, ['cat-file', '-e', `HEAD:${f}`]) !== null) {
|
|
594
|
+
gitQuiet(crossRoot, ['checkout', 'HEAD', '--', f]);
|
|
595
|
+
} else {
|
|
596
|
+
try { rmSync(join(crossRoot, f), { force: true }) } catch { /* 新文件删除失败留残,rescue 提示 */ }
|
|
597
|
+
}
|
|
598
|
+
} catch { /* 回滚尽力而为 */ }
|
|
599
|
+
}
|
|
600
|
+
out.errors.push(
|
|
601
|
+
`跨仓 ${repoKey}:apply --3way 失败(已回滚跨仓主工作副本到 apply 前状态):${rawStderr}\n` +
|
|
602
|
+
`可选:进 ${crossRoot} 手工合并 worktree 分支 ${cm.branch}(git merge ${cm.branch}),完成后手动清理跨仓 worktree。`
|
|
603
|
+
);
|
|
604
|
+
continue;
|
|
605
|
+
}
|
|
606
|
+
out.applied.push({ repoKey, changedFiles: patchFiles });
|
|
607
|
+
// 成功 → 清理该跨仓 worktree(分支保留作 review 锚点)
|
|
608
|
+
const cr = cleanupCrossWorktrees({ cwd: crossRoot, changeName, specBase, force: true });
|
|
609
|
+
for (const r of cr.results) {
|
|
610
|
+
if (r.result === 'cleaned') out.crossCleanups.push(repoKey);
|
|
611
|
+
else out.warnings.push(`跨仓 ${repoKey} apply 成功但清理 partial:${(r.details || []).join('; ')}${(r.residual || []).length ? ';残留: ' + r.residual.join('; ') : ''}`);
|
|
612
|
+
}
|
|
613
|
+
} finally {
|
|
614
|
+
try { rmSync(tmpDir, { recursive: true, force: true }) } catch { /* 临时目录清理尽力而为 */ }
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
return out;
|
|
618
|
+
}
|
|
619
|
+
|
|
443
620
|
export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false, base = 'merge-base', ctx = null, skipOverlap = false, stashDirty = false } = {}) {
|
|
444
621
|
const projectRoot = cwd || process.cwd();
|
|
445
622
|
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
@@ -458,11 +635,26 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
458
635
|
skippedOverlapFiles: [],
|
|
459
636
|
};
|
|
460
637
|
|
|
461
|
-
// --- 0.
|
|
462
|
-
//
|
|
463
|
-
//
|
|
464
|
-
//
|
|
638
|
+
// --- 0. 跨仓处理(task-05 / D-009 + 坑 cross-repo-no-worktree-isolation) ---
|
|
639
|
+
// 0a. worktree 模式的跨仓仓:真实 apply(patch 回跨仓主工作副本 + 成功后 cleanup worktree)。
|
|
640
|
+
// 在主仓任何动作之前跑——任一跨仓失败即整体返回,主仓工作区不动(不留半套状态)。
|
|
641
|
+
// 0b. legacy 直写模式的跨仓仓:no-op 校验(review.head 是跨仓真实 commit + 不 cleanup)。
|
|
642
|
+
// 单仓 ctx 缺省 / 无跨仓 entry → 两步都空转(零回归)。
|
|
465
643
|
if (ctx) {
|
|
644
|
+
const crossApplied = applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly });
|
|
645
|
+
if (crossApplied.repos.length > 0) {
|
|
646
|
+
result.crossRepoApplied = crossApplied.applied;
|
|
647
|
+
for (const w of crossApplied.warnings) result.warnings = (result.warnings || []).concat([w]);
|
|
648
|
+
if (crossApplied.crossCleanups.length > 0) {
|
|
649
|
+
result.warnings = (result.warnings || []).concat([
|
|
650
|
+
`跨仓 worktree 已 apply 并清理:${crossApplied.crossCleanups.join(', ')}(分支保留作 review 锚点)`
|
|
651
|
+
]);
|
|
652
|
+
}
|
|
653
|
+
if (crossApplied.errors.length > 0) {
|
|
654
|
+
result.errors.push(...crossApplied.errors);
|
|
655
|
+
return result; // 跨仓未落地 = 该仓 task 实际未完成,主仓 apply 不可继续
|
|
656
|
+
}
|
|
657
|
+
}
|
|
466
658
|
const cross = validateCrossRepoNoOp(ctx, projectRoot, changeName);
|
|
467
659
|
for (const e of cross.errors) result.errors.push(e);
|
|
468
660
|
if (cross.warnings.length > 0) result.warnings = (result.warnings || []).concat(cross.warnings);
|
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree-cross.js — 跨仓仓 worktree 隔离(坑 cross-repo-no-worktree-isolation,2026-08-27 用户实证)。
|
|
3
|
+
*
|
|
4
|
+
* 背景:跨仓 task 的原设计(multi-repo W1 D-005/D-006/D-009)是子代理直写跨仓仓主工作副本、
|
|
5
|
+
* commit 直落该仓主干,apply 对跨仓 no-op。实战暴露三类事故面:
|
|
6
|
+
* 1. 并行混流:跨仓主工作副本常有用户/其他变更的在途改动,子代理产物与之混在同一工作区,
|
|
7
|
+
* 无法区分归属、无法整体回滚(实证:sub-grid-security 主工作副本 task-05 产物与
|
|
8
|
+
* .gitignore/.webpackrc.js/yarn.lock 并行改动混流,无法核对);
|
|
9
|
+
* 2. 无 base 锚:跨仓仓无 meta/baseHash,base 只能靠 task 卡 base_commit 锡点;
|
|
10
|
+
* 3. 误伤面大:子代理在主工作副本可误改/误删用户未提交文件,无护栏。
|
|
11
|
+
*
|
|
12
|
+
* 本模块把跨仓仓纳入与主仓同构的 worktree 隔离(D-009 就地翻新,向后兼容):
|
|
13
|
+
* - worktree 落位 <主仓 specBase>/.runtime/worktrees/<change>--<repoKey>:主仓侧该目录已被
|
|
14
|
+
* .gitignore 覆盖(主仓 create 已强制检查),不要求跨仓仓自配 .gitignore;git worktree
|
|
15
|
+
* 的注册信息在跨仓 .git/worktrees/,目录本身可在仓外任意路径;
|
|
16
|
+
* - meta.json 与主仓同 schema + repoKey/crossRepoRoot/isCross 标识(唯一写入方 = 本模块,
|
|
17
|
+
* multi-repo-context.js 只读且路径公式与此处 crossWorktreePath 保持同步);
|
|
18
|
+
* - base 锚 meta.baseHash(跨仓仓 HEAD 快照);dirty baseline overlay 沿用主仓同款
|
|
19
|
+
* (跨仓主工作副本的在途改动进 baseline,不算交付 diff,apply 不覆盖它们);
|
|
20
|
+
* - deps 供给 provisionDeps(worktree, 跨仓根, {specBase:null})——sniff worktree 自身项目
|
|
21
|
+
* 类型(package.json→nodejs 链主工作副本 node_modules;不沿用主仓 local.yaml 的
|
|
22
|
+
* project.type,主仓与跨仓类型常不同,如实测 maven 主仓 + nodejs 前端仓);
|
|
23
|
+
* - cleanup:解链 node_modules junction → git worktree remove --force → 保留分支
|
|
24
|
+
* sillyspec/<change> 作 review 锚点(对齐主仓 cleanup 的分支保护哲学)。
|
|
25
|
+
*
|
|
26
|
+
* 兼容:无跨仓 worktree meta 的旧变更(legacy 直写主干模式)各链路继续走原路径,零回归。
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { git, gitQuiet } from './git-helper.js';
|
|
32
|
+
import { writeAtomicSync } from './fs-atomic.js';
|
|
33
|
+
import { WorktreeManager, unlinkNodeModulesLinks, safeRemoveWorktreeDir } from './worktree.js';
|
|
34
|
+
import { provisionDeps } from './worktree-deps.js';
|
|
35
|
+
import { aggregateDeclaredRepos } from './run/shared.js';
|
|
36
|
+
import { parseRepoRegistry } from './stages/plan-postcheck.js';
|
|
37
|
+
|
|
38
|
+
const META_FILE = 'meta.json';
|
|
39
|
+
const BRANCH_PREFIX = 'sillyspec/';
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* 跨仓 worktree 路径(唯一路径公式;multi-repo-context.js 的 meta 只读逻辑与此同步)。
|
|
43
|
+
* @param {string} specBase 主仓 spec 根(<主仓>/.sillyspec)
|
|
44
|
+
* @param {string} changeName 变更名
|
|
45
|
+
* @param {string} repoKey 跨仓 repo 键(local.yaml repos: 段的 key)
|
|
46
|
+
*/
|
|
47
|
+
export function crossWorktreePath(specBase, changeName, repoKey) {
|
|
48
|
+
return join(specBase, '.runtime', 'worktrees', `${changeName}--${repoKey}`);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* 读跨仓 worktree meta(不存在/损坏返 null = legacy 直写模式)。
|
|
53
|
+
* @returns {object|null}
|
|
54
|
+
*/
|
|
55
|
+
export function getCrossWorktreeMeta(specBase, changeName, repoKey) {
|
|
56
|
+
const metaPath = join(crossWorktreePath(specBase, changeName, repoKey), META_FILE);
|
|
57
|
+
if (!existsSync(metaPath)) return null;
|
|
58
|
+
try {
|
|
59
|
+
return JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 列出某变更已建的全部跨仓 worktree meta(扫描 worktrees 目录下 `<change>--*` 且 isCross 的 meta)。
|
|
67
|
+
* @returns {Array<{repoKey: string, meta: object}>}
|
|
68
|
+
*/
|
|
69
|
+
export function listCrossWorktreeMetas(specBase, changeName) {
|
|
70
|
+
const base = join(specBase, '.runtime', 'worktrees');
|
|
71
|
+
if (!existsSync(base)) return [];
|
|
72
|
+
const out = [];
|
|
73
|
+
for (const name of readdirSync(base)) {
|
|
74
|
+
if (!name.startsWith(`${changeName}--`)) continue;
|
|
75
|
+
const metaPath = join(base, name, META_FILE);
|
|
76
|
+
if (!existsSync(metaPath)) continue;
|
|
77
|
+
try {
|
|
78
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
79
|
+
if (meta && meta.isCross && meta.repoKey) out.push({ repoKey: meta.repoKey, meta });
|
|
80
|
+
} catch { /* 损坏 meta 跳过(cleanup 阶段仍按目录处理) */ }
|
|
81
|
+
}
|
|
82
|
+
return out;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* 聚合声明跨仓 key(plan.md 内联卡片 + tasks/ 独立卡片双源,与 shared.js getOrCreateMultiRepoContext 同口径)。
|
|
87
|
+
* @param {string} specBase
|
|
88
|
+
* @param {string} changeName
|
|
89
|
+
* @returns {string[]} 除 'main' 外的跨仓 key(未注册/配置错由 MultiRepoContext fail-closed,此处只聚合)
|
|
90
|
+
*/
|
|
91
|
+
function aggregateCrossRepoKeys(specBase, changeName) {
|
|
92
|
+
const planFile = join(specBase, 'changes', changeName, 'plan.md');
|
|
93
|
+
let planContent = '';
|
|
94
|
+
if (existsSync(planFile)) {
|
|
95
|
+
try { planContent = readFileSync(planFile, 'utf8') } catch { planContent = '' }
|
|
96
|
+
}
|
|
97
|
+
// tasks/ 独立卡片兜底(与 run/shared.js collectTaskCardReposFallback 同口径,该函数未导出)
|
|
98
|
+
const tasksDir = join(specBase, 'changes', changeName, 'tasks');
|
|
99
|
+
if (existsSync(tasksDir)) {
|
|
100
|
+
for (const f of readdirSync(tasksDir)) {
|
|
101
|
+
if (!/^task-\d+\.md$/i.test(f)) continue;
|
|
102
|
+
try { planContent += '\n' + readFileSync(join(tasksDir, f), 'utf8') + '\n' } catch { /* 兜底源不阻断 */ }
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return aggregateDeclaredRepos(planContent).filter(k => k !== 'main');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* 读主仓 local.yaml 的 repos: 段(路径口径与 run/shared.js readLocalYamlRaw 一致:cwd/.sillyspec/local.yaml)。
|
|
110
|
+
* @returns {Map<string,string>}
|
|
111
|
+
*/
|
|
112
|
+
function readRepoRegistry(cwd) {
|
|
113
|
+
const p = join(cwd, '.sillyspec', 'local.yaml');
|
|
114
|
+
let text = '';
|
|
115
|
+
if (existsSync(p)) {
|
|
116
|
+
try { text = readFileSync(p, 'utf8') } catch { text = '' }
|
|
117
|
+
}
|
|
118
|
+
return parseRepoRegistry(text);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* execute 启动时为每个声明的跨仓仓建 worktree(幂等:meta 在即复用)。
|
|
123
|
+
*
|
|
124
|
+
* 失败语义 fail-closed(对齐主仓 create:隔离建不起来就不该开工):任何仓创建失败 → 抛错,
|
|
125
|
+
* 由调用方(run/stage.js execute 启动)exit(1) 并给修复指引。已建成功的仓保留(重跑幂等复用)。
|
|
126
|
+
*
|
|
127
|
+
* @param {{ cwd: string, changeName: string, specBase?: string }} opts
|
|
128
|
+
* @returns {{ created: Array<{repoKey,worktreePath}>, reused: Array<{repoKey,worktreePath}>, skippedLegacy: string[] }}
|
|
129
|
+
* @throws {Error} 跨仓仓未注册 / git 不可达 / worktree add 失败 / 分支冲突
|
|
130
|
+
*/
|
|
131
|
+
export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
|
|
132
|
+
const base = specBase || join(cwd, '.sillyspec');
|
|
133
|
+
const keys = aggregateCrossRepoKeys(base, changeName);
|
|
134
|
+
const registry = readRepoRegistry(cwd);
|
|
135
|
+
const created = [];
|
|
136
|
+
const reused = [];
|
|
137
|
+
const skippedLegacy = [];
|
|
138
|
+
|
|
139
|
+
for (const key of keys) {
|
|
140
|
+
const repoRoot = registry.get(key);
|
|
141
|
+
if (!repoRoot) {
|
|
142
|
+
// 与 MultiRepoContext 约束②同语义:声明的 repo 必须已注册,配置错不降级
|
|
143
|
+
throw new Error(
|
|
144
|
+
`跨仓 repo "${key}" 未在 local.yaml repos: 段注册。` +
|
|
145
|
+
`一键注册:sillyspec local register-repo ${key} <${key} 仓根路径>(勿手编 YAML),补注册后重跑 execute。`
|
|
146
|
+
);
|
|
147
|
+
}
|
|
148
|
+
const existing = getCrossWorktreeMeta(base, changeName, key);
|
|
149
|
+
if (existing && existing.worktreePath && existsSync(existing.worktreePath)) {
|
|
150
|
+
reused.push({ repoKey: key, worktreePath: existing.worktreePath });
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
// meta 在但目录没了(外部误删):走重建(下方 git worktree add 会因注册残留失败,
|
|
154
|
+
// 先 prune 掉悬空注册再建——目录不在则 prune 无损)
|
|
155
|
+
if (existing) {
|
|
156
|
+
try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const worktreePath = crossWorktreePath(base, changeName, key);
|
|
160
|
+
const branch = BRANCH_PREFIX + changeName;
|
|
161
|
+
|
|
162
|
+
// base 快照:跨仓仓当前 HEAD(与主仓 create 的默认 base 语义一致)
|
|
163
|
+
let baseHash;
|
|
164
|
+
try {
|
|
165
|
+
baseHash = git(repoRoot, ['rev-parse', 'HEAD']);
|
|
166
|
+
} catch (e) {
|
|
167
|
+
throw new Error(`跨仓 repo "${key}"(${repoRoot})git 不可达(rev-parse HEAD 失败:${e.message})。请检查 local.yaml repos: 段路径。`);
|
|
168
|
+
}
|
|
169
|
+
const branchExists = !!gitQuiet(repoRoot, ['rev-parse', '--verify', `refs/heads/${branch}`]);
|
|
170
|
+
if (branchExists) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`跨仓仓 ${key} 已有分支 ${branch}(疑似上次 execute 残留)。三选一:\n` +
|
|
173
|
+
` ① 确认残留作废:git -C ${repoRoot} branch -D ${branch} 后重跑;\n` +
|
|
174
|
+
` ② 分支内容已 apply 落地或不再需要:sillyspec worktree cleanup ${changeName} --force(跨仓 worktree 一并清理后重跑);\n` +
|
|
175
|
+
` ③ 换变更名重跑。`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
mkdirSync(join(base, '.runtime', 'worktrees'), { recursive: true });
|
|
179
|
+
try {
|
|
180
|
+
git(repoRoot, ['worktree', 'add', worktreePath, '-b', branch, baseHash], { timeout: 120000 });
|
|
181
|
+
} catch (e) {
|
|
182
|
+
throw new Error(`跨仓仓 ${key} worktree 创建失败(${worktreePath}):${e.stderr || e.message}`);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const meta = {
|
|
186
|
+
name_zh: 'worktree 元数据',
|
|
187
|
+
changeName,
|
|
188
|
+
repoKey: key,
|
|
189
|
+
crossRepoRoot: repoRoot,
|
|
190
|
+
isCross: true,
|
|
191
|
+
branch,
|
|
192
|
+
baseBranch: gitQuiet(repoRoot, ['symbolic-ref', '--short', 'HEAD']) || 'HEAD',
|
|
193
|
+
baseHash,
|
|
194
|
+
actualBaseHash: gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || baseHash,
|
|
195
|
+
createdAt: new Date().toISOString(),
|
|
196
|
+
worktreePath,
|
|
197
|
+
mode: 'worktree',
|
|
198
|
+
};
|
|
199
|
+
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
200
|
+
|
|
201
|
+
// dirty baseline overlay:跨仓主工作副本在途改动 → worktree baseline(借主仓同款实现,
|
|
202
|
+
// 只用其参数化逻辑,不用实例路径状态)
|
|
203
|
+
try {
|
|
204
|
+
const wmHelper = new WorktreeManager({ cwd: repoRoot });
|
|
205
|
+
const baselineResult = wmHelper._overlayBaseline(repoRoot, worktreePath);
|
|
206
|
+
meta.baselineFiles = baselineResult.files;
|
|
207
|
+
meta.baselineHash = baselineResult.baselineHash;
|
|
208
|
+
if (baselineResult.files.length > 0) {
|
|
209
|
+
meta.baselineCommit = wmHelper._createBaselineCheckpoint(worktreePath, `${changeName}--${key}`, baselineResult.files);
|
|
210
|
+
}
|
|
211
|
+
if (baselineResult.errors && baselineResult.errors.length > 0) {
|
|
212
|
+
console.warn(`⚠️ 跨仓 ${key} baseline overlay 部分失败(${baselineResult.errors.join('; ')})——在途改动未全量进 baseline,apply 前请人工核对跨仓主工作副本`);
|
|
213
|
+
}
|
|
214
|
+
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
215
|
+
} catch (e) {
|
|
216
|
+
console.warn(`⚠️ 跨仓 ${key} baseline overlay 失败(${e.message})——跨仓主工作副本如有在途改动,worktree 不含它们`);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// deps 供给:specBase 传 null —— sniff worktree 自身(主仓 local.yaml 的 project.type/install
|
|
220
|
+
// 描述的是主仓,跨仓仓类型常不同,如实测 maven 主仓 + nodejs 前端仓,沿用会把 mvn 命令
|
|
221
|
+
// 打到前端仓上)。失败不阻断(与主仓 create 同语义,doctor --fix 可修)
|
|
222
|
+
try {
|
|
223
|
+
const deps = provisionDeps(worktreePath, repoRoot, { specBase: null }) || {};
|
|
224
|
+
Object.assign(meta, {
|
|
225
|
+
depsStatus: deps.depsStatus,
|
|
226
|
+
depsMethod: deps.depsMethod || null,
|
|
227
|
+
depsSource: deps.depsSource || null,
|
|
228
|
+
depsLockHash: deps.depsLockHash || null,
|
|
229
|
+
depsCheckedAt: deps.depsCheckedAt || null,
|
|
230
|
+
...(deps.depsError ? { depsError: deps.depsError } : {}),
|
|
231
|
+
});
|
|
232
|
+
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
233
|
+
} catch (e) {
|
|
234
|
+
meta.depsStatus = 'failed';
|
|
235
|
+
meta.depsError = `provisionDeps crashed: ${e.message}`;
|
|
236
|
+
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
created.push({ repoKey: key, worktreePath });
|
|
240
|
+
}
|
|
241
|
+
return { created, reused, skippedLegacy };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* 清理某变更的全部跨仓 worktree(幂等)。
|
|
246
|
+
*
|
|
247
|
+
* 顺序对齐主仓 cleanup 的 Windows 防护:先解 node_modules junction(裸删会穿透删跨仓主工作
|
|
248
|
+
* 副本的 node_modules),再 git worktree remove --force,最后**保留分支** sillyspec/<change>
|
|
249
|
+
* 作 review 锚点(分支删除在主仓侧有 review 锚点 + 双保护,跨仓侧 v1 保守不删,残 branch
|
|
250
|
+
* 可人工清理)。目录残留(remove 失败)走 safeRemoveWorktreeDir 兜底 + residual 上报。
|
|
251
|
+
*
|
|
252
|
+
* @param {{ cwd: string, changeName: string, specBase?: string, force?: boolean }} opts
|
|
253
|
+
* @returns {{ results: Array<{repoKey, result: 'cleaned'|'skipped'|'partial', details: string[], residual: string[]}> }}
|
|
254
|
+
*/
|
|
255
|
+
export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false }) {
|
|
256
|
+
const base = specBase || join(cwd, '.sillyspec');
|
|
257
|
+
const results = [];
|
|
258
|
+
for (const { repoKey, meta } of listCrossWorktreeMetas(base, changeName)) {
|
|
259
|
+
const details = [];
|
|
260
|
+
const residual = [];
|
|
261
|
+
const repoRoot = meta.crossRepoRoot;
|
|
262
|
+
const wtPath = meta.worktreePath || crossWorktreePath(base, changeName, repoKey);
|
|
263
|
+
if (!existsSync(wtPath)) {
|
|
264
|
+
try { if (repoRoot) gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
|
|
265
|
+
results.push({ repoKey, result: 'skipped', details: ['目录不存在(已清理)'], residual });
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
if (!force) {
|
|
269
|
+
// fail-closed:worktree 内还有未落地交付(相对 baseHash 有 diff)时拒绝清理(对齐主仓
|
|
270
|
+
// hasUnappliedChanges 哲学;跨仓侧以「分支上有交付 diff 且未 apply」近似)
|
|
271
|
+
try {
|
|
272
|
+
const diff = gitQuiet(wtPath, ['diff', '--name-only', meta.baseHash]) || '';
|
|
273
|
+
const untracked = gitQuiet(wtPath, ['ls-files', '--others', '--exclude-standard']) || '';
|
|
274
|
+
if (diff.trim() || untracked.trim()) {
|
|
275
|
+
results.push({ repoKey, result: 'partial', details: [`blocked: 跨仓 worktree 有未 apply 的交付改动(git diff ${String(meta.baseHash).slice(0, 8)} 非空)——先 apply 或显式 --force`], residual: [wtPath] });
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
} catch (e) {
|
|
279
|
+
details.push(`未落地检测失败(${e.message}),按 force 处理`);
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
try { unlinkNodeModulesLinks(wtPath, meta, details) } catch (e) { details.push(`junction 解链失败: ${e.message}`) }
|
|
283
|
+
try {
|
|
284
|
+
git(repoRoot, ['worktree', 'remove', '--force', wtPath], { timeout: 60000 });
|
|
285
|
+
} catch {
|
|
286
|
+
// remove 失败(脏文件/锁):解链后安全删目录 + prune 注册
|
|
287
|
+
try { safeRemoveWorktreeDir(wtPath, meta) } catch (e) { residual.push(`目录残留 ${wtPath}: ${e.message}`) }
|
|
288
|
+
try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
|
|
289
|
+
}
|
|
290
|
+
details.push(`分支 ${meta.branch} 保留作 review 锚点(确认无需回溯后可 git -C ${repoRoot} branch -D ${meta.branch})`);
|
|
291
|
+
results.push({ repoKey, result: residual.length > 0 ? 'partial' : 'cleaned', details, residual });
|
|
292
|
+
}
|
|
293
|
+
return { results };
|
|
294
|
+
}
|