sillyspec 3.32.4 → 3.32.5
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/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -3
- package/.claude/skills/sillyspec-explore/SKILL.md +2 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +116 -0
- package/package.json +1 -1
- package/src/config-schema.js +8 -0
- package/src/cross-placement.js +246 -0
- package/src/cross-repo-reconcile.js +8 -5
- package/src/doctor-diagnostics.js +21 -1
- package/src/flow-parity.js +14 -1
- package/src/flow.js +32 -2
- package/src/hooks/worktree-guard.js +35 -2
- package/src/index.js +22 -3
- package/src/run/command.js +19 -0
- package/src/run/multi-repo-context.js +11 -5
- package/src/run/shared.js +10 -4
- package/src/scope-audit.js +130 -85
- package/src/spec-sync.js +45 -16
- package/src/stages/plan-postcheck.js +67 -9
- package/src/sync.js +3 -0
- package/src/verify-postcheck.js +56 -8
- package/src/verify-probes.js +14 -7
- package/src/worktree-apply.js +62 -8
- package/src/worktree-cross.js +188 -27
- package/src/worktree-deps.js +50 -9
package/src/worktree-cross.js
CHANGED
|
@@ -27,56 +27,117 @@
|
|
|
27
27
|
*/
|
|
28
28
|
|
|
29
29
|
import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs';
|
|
30
|
-
import { join } from 'path';
|
|
30
|
+
import { join, dirname, resolve as resolvePath, isAbsolute, relative as relativePath } from 'path';
|
|
31
31
|
import { git, gitQuiet } from './git-helper.js';
|
|
32
32
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
33
33
|
import { WorktreeManager, unlinkNodeModulesLinks, safeRemoveWorktreeDir } from './worktree.js';
|
|
34
34
|
import { provisionDeps } from './worktree-deps.js';
|
|
35
35
|
import { aggregateDeclaredRepos } from './run/shared.js';
|
|
36
|
-
import { parseRepoRegistry } from './stages/plan-postcheck.js';
|
|
36
|
+
import { parseRepoRegistry, parseRepoWorktreePlacements } from './stages/plan-postcheck.js';
|
|
37
|
+
import {
|
|
38
|
+
crossWorktreePath as _placementCrossPath,
|
|
39
|
+
crossWorktreeDirName,
|
|
40
|
+
resolveCrossWorktreePath,
|
|
41
|
+
resolveCrossWorktreePathCandidates,
|
|
42
|
+
readCrossPlacementConfig,
|
|
43
|
+
resolvePlacementRoot,
|
|
44
|
+
readPlacementRegistry,
|
|
45
|
+
updatePlacementRegistry,
|
|
46
|
+
removePlacementRegistryEntry,
|
|
47
|
+
sweepPlacementRegistry,
|
|
48
|
+
isWslSplit,
|
|
49
|
+
} from './cross-placement.js';
|
|
37
50
|
|
|
38
51
|
const META_FILE = 'meta.json';
|
|
39
52
|
const BRANCH_PREFIX = 'sillyspec/';
|
|
40
53
|
|
|
41
54
|
/**
|
|
42
|
-
* 跨仓 worktree
|
|
55
|
+
* 跨仓 worktree 路径(唯一路径公式;实现已下沉 cross-placement 叶子模块——multi-repo-context.js
|
|
56
|
+
* 经该叶子模块共用,不再与 worktree-cross 经 run/shared 成环)。
|
|
57
|
+
* placementRoot 可选(坑 cross-wt-toolchain-split 落位配置):给定时时 <placementRoot>/<change>--<repoKey>。
|
|
43
58
|
* @param {string} specBase 主仓 spec 根(<主仓>/.sillyspec)
|
|
44
59
|
* @param {string} changeName 变更名
|
|
45
60
|
* @param {string} repoKey 跨仓 repo 键(local.yaml repos: 段的 key)
|
|
61
|
+
* @param {string|null} [placementRoot] 自定义落位根(null/缺省走默认公式——三参调用零回归)
|
|
46
62
|
*/
|
|
47
|
-
export function crossWorktreePath(specBase, changeName, repoKey) {
|
|
48
|
-
return
|
|
63
|
+
export function crossWorktreePath(specBase, changeName, repoKey, placementRoot = null) {
|
|
64
|
+
return _placementCrossPath(specBase, changeName, repoKey, placementRoot);
|
|
49
65
|
}
|
|
50
66
|
|
|
51
67
|
/**
|
|
52
68
|
* 读跨仓 worktree meta(不存在/损坏返 null = legacy 直写模式)。
|
|
69
|
+
* 寻址经 resolveCrossWorktreePath(注册表优先、公式兜底)——worktree 挪到 crossPlacement
|
|
70
|
+
* 自定义落位后 meta 仍可达(坑 cross-wt-toolchain-split)。
|
|
53
71
|
* @returns {object|null}
|
|
54
72
|
*/
|
|
55
73
|
export function getCrossWorktreeMeta(specBase, changeName, repoKey) {
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
74
|
+
// repoRoot 从 repos 注册表解析(无 repoKey 注册/解析失败 → null,候选列表少一级)
|
|
75
|
+
const mainRoot = dirname(specBase);
|
|
76
|
+
const repoRoot = parseRepoRegistry(readLocalYamlText(mainRoot)).get(repoKey) || null;
|
|
77
|
+
const resolvedRoot = repoRoot && !isAbsolute(repoRoot) ? resolvePath(mainRoot, repoRoot) : repoRoot;
|
|
78
|
+
// 寻址候选逐级探测:注册表 → 仓内新公式 → 主仓 specBase 旧公式(legacy 存量兜底)
|
|
79
|
+
for (const dir of resolveCrossWorktreePathCandidates(specBase, changeName, repoKey, resolvedRoot)) {
|
|
80
|
+
const metaPath = join(dir, META_FILE);
|
|
81
|
+
if (!existsSync(metaPath)) continue; // 未命中 → 下一候选
|
|
82
|
+
try {
|
|
83
|
+
const m = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
84
|
+
return m && m.isCross ? m : null; // 非跨仓/损坏 = 命中但无效
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
62
88
|
}
|
|
89
|
+
return null;
|
|
63
90
|
}
|
|
64
91
|
|
|
65
92
|
/**
|
|
66
|
-
* 列出某变更已建的全部跨仓 worktree meta
|
|
93
|
+
* 列出某变更已建的全部跨仓 worktree meta(双源:worktrees 目录 `<change>--*` 扫描 ∪ 落位
|
|
94
|
+
* 注册表条目——坑 cross-wt-toolchain-split:worktree 挪到 crossPlacement 自定义目录后不在
|
|
95
|
+
* 默认存储目录下,纯目录扫描盲区)。注册表只提供位置:条目指向处须读到可解析且 isCross 的
|
|
96
|
+
* meta.json 才入列(与目录扫描同判据——不合成无 meta 条目,消费方 gates.js 解引用
|
|
97
|
+
* cm.depsStatus 零 TypeError 面)。按 repoKey 去重(注册表与目录扫描可能同指一处)。
|
|
67
98
|
* @returns {Array<{repoKey: string, meta: object}>}
|
|
68
99
|
*/
|
|
69
100
|
export function listCrossWorktreeMetas(specBase, changeName) {
|
|
70
101
|
const base = join(specBase, '.runtime', 'worktrees');
|
|
71
|
-
|
|
102
|
+
const candidates = []; // {dir}
|
|
103
|
+
const scanDir = (dir) => {
|
|
104
|
+
if (!existsSync(dir)) return;
|
|
105
|
+
try {
|
|
106
|
+
for (const name of readdirSync(dir)) {
|
|
107
|
+
if (!name.startsWith(`${changeName}--`)) continue;
|
|
108
|
+
candidates.push(join(dir, name));
|
|
109
|
+
}
|
|
110
|
+
} catch { /* 目录不可读跳过 */ }
|
|
111
|
+
};
|
|
112
|
+
// 源① 主仓 specBase 旧默认(legacy-main-specbase 存量)
|
|
113
|
+
scanDir(base);
|
|
114
|
+
// 源② 仓内新默认(<repoRoot>/.sillyspec/.runtime/worktrees——按 local.yaml repos 注册表枚举各仓)
|
|
115
|
+
const mainRoot = dirname(specBase);
|
|
116
|
+
const registryYaml = readLocalYamlText(mainRoot);
|
|
117
|
+
for (const repoRoot of parseRepoRegistry(registryYaml).values()) {
|
|
118
|
+
const root = isAbsolute(repoRoot) ? repoRoot : resolvePath(mainRoot, repoRoot);
|
|
119
|
+
scanDir(join(root, '.sillyspec', '.runtime', 'worktrees'));
|
|
120
|
+
}
|
|
121
|
+
// 源③ 注册表(注册表读取 fail-open 返回 {}——损坏降级纯目录扫描)
|
|
122
|
+
const registry = readPlacementRegistry(specBase);
|
|
123
|
+
const prefix = `${changeName}--`;
|
|
124
|
+
for (const [key, entry] of Object.entries(registry)) {
|
|
125
|
+
if (!key.startsWith(prefix)) continue;
|
|
126
|
+
if (entry && typeof entry.worktreePath === 'string' && entry.worktreePath) {
|
|
127
|
+
candidates.push(entry.worktreePath);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
72
130
|
const out = [];
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const metaPath = join(
|
|
76
|
-
if (!existsSync(metaPath)) continue;
|
|
131
|
+
const seen = new Set();
|
|
132
|
+
for (const dir of candidates) {
|
|
133
|
+
const metaPath = join(dir, META_FILE);
|
|
134
|
+
if (!existsSync(metaPath)) continue; // 无 meta 不入列(含注册表悬挂键——sweep 前的过渡态)
|
|
77
135
|
try {
|
|
78
136
|
const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
|
|
79
|
-
if (meta && meta.isCross && meta.repoKey
|
|
137
|
+
if (meta && meta.isCross && meta.repoKey && !seen.has(meta.repoKey)) {
|
|
138
|
+
seen.add(meta.repoKey);
|
|
139
|
+
out.push({ repoKey: meta.repoKey, meta });
|
|
140
|
+
}
|
|
80
141
|
} catch { /* 损坏 meta 跳过(cleanup 阶段仍按目录处理) */ }
|
|
81
142
|
}
|
|
82
143
|
return out;
|
|
@@ -105,17 +166,55 @@ function aggregateCrossRepoKeys(specBase, changeName) {
|
|
|
105
166
|
return aggregateDeclaredRepos(planContent).filter(k => k !== 'main');
|
|
106
167
|
}
|
|
107
168
|
|
|
169
|
+
/**
|
|
170
|
+
* 读主仓 local.yaml 文本(路径口径与 run/shared.js readLocalYamlRaw 一致:cwd/.sillyspec/local.yaml)。
|
|
171
|
+
* @returns {string}
|
|
172
|
+
*/
|
|
173
|
+
function readLocalYamlText(cwd) {
|
|
174
|
+
const p = join(cwd, '.sillyspec', 'local.yaml');
|
|
175
|
+
if (!existsSync(p)) return '';
|
|
176
|
+
try { return readFileSync(p, 'utf8') } catch { return '' }
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** worktreePath 是否落在主仓 specBase 旧默认(legacy-main-specbase 形态——该形态不写注册表,公式可寻址) */
|
|
180
|
+
function isLegacyMainSpecbasePlacement(worktreePath, specBase) {
|
|
181
|
+
try {
|
|
182
|
+
return resolvePath(dirname(worktreePath)) === resolvePath(join(specBase, '.runtime', 'worktrees'));
|
|
183
|
+
} catch { return false }
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* 仓内落位的 untracked 保障(坑 cross-wt-repo-local-placement):.git/info/exclude 幂等追加
|
|
188
|
+
* 条目——本仓生效、不进版本库(不碰用户 .gitignore 工作区文件、无 pull 冲突面)。创建 worktree
|
|
189
|
+
* 前调用,worktree 目录一出现即被 git 忽略。追加失败(只读仓等)仅 warn 不阻断——最坏回落
|
|
190
|
+
* 「worktree 成 untracked 噪音」,apply 侧清单校验本就过滤。
|
|
191
|
+
* @param {string} repoRoot 跨仓仓根
|
|
192
|
+
* @param {string} entry exclude 条目(如 '.sillyspec/')
|
|
193
|
+
*/
|
|
194
|
+
function ensureRepoLocalExclude(repoRoot, entry) {
|
|
195
|
+
try {
|
|
196
|
+
const excludePath = join(repoRoot, '.git', 'info', 'exclude');
|
|
197
|
+
let content = '';
|
|
198
|
+
if (existsSync(excludePath)) {
|
|
199
|
+
try { content = readFileSync(excludePath, 'utf8') } catch { content = '' }
|
|
200
|
+
}
|
|
201
|
+
const entryRe = new RegExp(`^${entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm');
|
|
202
|
+
if (entryRe.test(content)) return; // 幂等:已有条目不动
|
|
203
|
+
const sep = content && !content.endsWith('\n') ? '\n' : '';
|
|
204
|
+
const stamp = `${sep}# sillyspec cross-repo worktree placement (auto)\n${entry}\n`;
|
|
205
|
+
mkdirSync(join(repoRoot, '.git', 'info'), { recursive: true });
|
|
206
|
+
writeAtomicSync(excludePath, content + stamp);
|
|
207
|
+
} catch (e) {
|
|
208
|
+
console.warn(`⚠️ 跨仓仓 .git/info/exclude 追加失败(${e.message})——worktree 目录可能成 untracked 噪音,apply 清单校验仍会过滤,建议手工补 ignore。`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
108
212
|
/**
|
|
109
213
|
* 读主仓 local.yaml 的 repos: 段(路径口径与 run/shared.js readLocalYamlRaw 一致:cwd/.sillyspec/local.yaml)。
|
|
110
214
|
* @returns {Map<string,string>}
|
|
111
215
|
*/
|
|
112
216
|
function readRepoRegistry(cwd) {
|
|
113
|
-
|
|
114
|
-
let text = '';
|
|
115
|
-
if (existsSync(p)) {
|
|
116
|
-
try { text = readFileSync(p, 'utf8') } catch { text = '' }
|
|
117
|
-
}
|
|
118
|
-
return parseRepoRegistry(text);
|
|
217
|
+
return parseRepoRegistry(readLocalYamlText(cwd));
|
|
119
218
|
}
|
|
120
219
|
|
|
121
220
|
/**
|
|
@@ -132,6 +231,11 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
|
|
|
132
231
|
const base = specBase || join(cwd, '.sillyspec');
|
|
133
232
|
const keys = aggregateCrossRepoKeys(base, changeName);
|
|
134
233
|
const registry = readRepoRegistry(cwd);
|
|
234
|
+
// 落位配置双源(坑 cross-wt-toolchain-split 配置简化,2026-10-10 用户反馈):推荐 repos 条目
|
|
235
|
+
// 内联(repos.<key>: {path, worktree}——注册与落位一处声明);worktree.crossPlacement 段兼容
|
|
236
|
+
// 保留作 legacy 读面。优先级:repos.<key>.worktree > worktree.crossPlacement.<key> > 默认公式。
|
|
237
|
+
const inlinePlacements = parseRepoWorktreePlacements(readLocalYamlText(cwd));
|
|
238
|
+
const placementCfg = readCrossPlacementConfig(cwd);
|
|
135
239
|
const created = [];
|
|
136
240
|
const reused = [];
|
|
137
241
|
const skippedLegacy = [];
|
|
@@ -156,9 +260,36 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
|
|
|
156
260
|
try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
|
|
157
261
|
}
|
|
158
262
|
|
|
159
|
-
|
|
263
|
+
// 落位解析(FR-01/FR-02):显式(repos.<key>.worktree 内联 > crossPlacement.<key> legacy)>
|
|
264
|
+
// 新默认=仓内 <repoRoot>/.sillyspec/.runtime/worktrees(坑 cross-wt-repo-local-placement:
|
|
265
|
+
// 天然同盘同文件系统,WSL 主仓+Windows 盘跨仓的工具链分裂自动消解——用户 2026-10-10 提议,
|
|
266
|
+
// 对齐主仓 worktree 的同构形态)。旧默认(主仓 specBase 下)降 legacy 兜底(resolve/扫描
|
|
267
|
+
// 仍认,存量 worktree 全链可达)。
|
|
268
|
+
// 显式配置指向仓根内维持拒绝(手配仓内=误配——untracked 噪音面);新默认仓内合法,untracked
|
|
269
|
+
// 面由 ensureRepoLocalExclude 的 .git/info/exclude 保障(不动用户 .gitignore、不进版本库)。
|
|
270
|
+
const rawPlacement = inlinePlacements.get(key) || placementCfg.get(key) || null;
|
|
271
|
+
let placementRoot = null;
|
|
272
|
+
let placementMode = 'repo-local';
|
|
273
|
+
if (rawPlacement) {
|
|
274
|
+
placementRoot = resolvePlacementRoot(rawPlacement, cwd);
|
|
275
|
+
placementMode = inlinePlacements.has(key) ? 'explicit-inline' : 'explicit-legacy';
|
|
276
|
+
const rel = relativePath(resolvePath(repoRoot), placementRoot);
|
|
277
|
+
if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`跨仓 repo "${key}" 的落位配置(${placementRoot})落在该仓仓根(${repoRoot})之内——` +
|
|
280
|
+
`显式落位应选该仓之外的目录(仓内落位请删掉该配置走默认)。重跑 execute 前修正配置。`
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const worktreePath = crossWorktreePath(base, changeName, key, placementRoot || join(repoRoot, '.sillyspec', '.runtime', 'worktrees'));
|
|
160
285
|
const branch = BRANCH_PREFIX + changeName;
|
|
161
286
|
|
|
287
|
+
// 仓内落位的 untracked 保障:.git/info/exclude 幂等追加 .sillyspec/(本仓生效、不进版本库、
|
|
288
|
+
// 不碰用户 .gitignore)。创建 worktree 前落盘——worktree 目录一出现即被忽略。
|
|
289
|
+
if (!rawPlacement) {
|
|
290
|
+
ensureRepoLocalExclude(repoRoot, '.sillyspec/');
|
|
291
|
+
}
|
|
292
|
+
|
|
162
293
|
// base 快照:跨仓仓当前 HEAD(与主仓 create 的默认 base 语义一致)
|
|
163
294
|
let baseHash;
|
|
164
295
|
try {
|
|
@@ -175,13 +306,27 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
|
|
|
175
306
|
` ③ 换变更名重跑。`
|
|
176
307
|
);
|
|
177
308
|
}
|
|
178
|
-
|
|
309
|
+
if (rawPlacement) {
|
|
310
|
+
mkdirSync(placementRoot, { recursive: true });
|
|
311
|
+
} else {
|
|
312
|
+
mkdirSync(join(repoRoot, '.sillyspec', '.runtime', 'worktrees'), { recursive: true });
|
|
313
|
+
}
|
|
179
314
|
try {
|
|
180
315
|
git(repoRoot, ['worktree', 'add', worktreePath, '-b', branch, baseHash], { timeout: 120000 });
|
|
181
316
|
} catch (e) {
|
|
182
317
|
throw new Error(`跨仓仓 ${key} worktree 创建失败(${worktreePath}):${e.stderr || e.message}`);
|
|
183
318
|
}
|
|
184
319
|
|
|
320
|
+
// WSL 跨文件系统分裂警告(FR-03,advisory 不阻断——坑 cross-wt-toolchain-split:主仓在
|
|
321
|
+
// WSL /root、跨仓在 /mnt/<盘>/ Windows 盘时,worktree 默认落 WSL 原生 fs,该仓 Windows
|
|
322
|
+
// 侧工具链(mvn/JDK)够不着,隔离形同虚设——fire-equipment 2026-10-10 实证被迫绕开
|
|
323
|
+
// worktree 直写主干。配置同侧落位后本警告自然消音)。
|
|
324
|
+
if (isWslSplit(repoRoot, worktreePath)) {
|
|
325
|
+
console.warn(`⚠️ 跨仓 ${key}: 工具链分裂风险——仓根在 WSL automount Windows 盘(${repoRoot})而 worktree 落在 WSL 原生文件系统(${worktreePath})。`);
|
|
326
|
+
console.warn(` 该仓若依赖 Windows 侧构建工具链(mvn/JDK/IDE),将无法访问 worktree——2026-10-10 实证此形态下 agent 被迫绕开 worktree 直写主副本。`);
|
|
327
|
+
console.warn(` 建议:local.yaml 配 worktree.crossPlacement.${key} 到该仓可达的目录(如同盘符路径),重跑 execute 前先 sillyspec worktree cleanup ${changeName} --force。`);
|
|
328
|
+
}
|
|
329
|
+
|
|
185
330
|
const meta = {
|
|
186
331
|
name_zh: 'worktree 元数据',
|
|
187
332
|
changeName,
|
|
@@ -195,6 +340,7 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
|
|
|
195
340
|
createdAt: new Date().toISOString(),
|
|
196
341
|
worktreePath,
|
|
197
342
|
mode: 'worktree',
|
|
343
|
+
placementMode,
|
|
198
344
|
};
|
|
199
345
|
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
200
346
|
|
|
@@ -209,6 +355,12 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
|
|
|
209
355
|
console.log(`ℹ️ 跨仓 ${key}: 跳过 dirty baseline checkpoint(跨仓主副本在途改动不进本变更分支——含并行会话外来文件时防污染;锚点 = 跨仓仓 HEAD ${String(baseHash).slice(0, 8)})`);
|
|
210
356
|
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
211
357
|
|
|
358
|
+
// 落位注册表(FR-02):仓内新默认与显式落位都记录位置(新默认位置依赖 repoRoot,读取方
|
|
359
|
+
// 无 repoRoot 上下文时公式兜底不可用——注册表是主寻址);落主仓 specBase 的旧默认不写。
|
|
360
|
+
if (placementRoot || !isLegacyMainSpecbasePlacement(worktreePath, base)) {
|
|
361
|
+
updatePlacementRegistry(base, changeName, key, { worktreePath, placementRoot: placementRoot || join(repoRoot, '.sillyspec', '.runtime', 'worktrees') });
|
|
362
|
+
}
|
|
363
|
+
|
|
212
364
|
// deps 供给:specBase 传 null —— sniff worktree 自身(主仓 local.yaml 的 project.type/install
|
|
213
365
|
// 描述的是主仓,跨仓仓类型常不同,如实测 maven 主仓 + nodejs 前端仓,沿用会把 mvn 命令
|
|
214
366
|
// 打到前端仓上)。失败不阻断(与主仓 create 同语义,doctor --fix 可修)
|
|
@@ -229,7 +381,7 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
|
|
|
229
381
|
writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
|
|
230
382
|
}
|
|
231
383
|
|
|
232
|
-
created.push({ repoKey: key, worktreePath });
|
|
384
|
+
created.push({ repoKey: key, worktreePath, meta });
|
|
233
385
|
}
|
|
234
386
|
return { created, reused, skippedLegacy };
|
|
235
387
|
}
|
|
@@ -255,6 +407,7 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
|
|
|
255
407
|
const wtPath = meta.worktreePath || crossWorktreePath(base, changeName, repoKey);
|
|
256
408
|
if (!existsSync(wtPath)) {
|
|
257
409
|
try { if (repoRoot) gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
|
|
410
|
+
removePlacementRegistryEntry(base, changeName, repoKey); // 挪位清理后注册表键回收(FR-02)
|
|
258
411
|
results.push({ repoKey, result: 'skipped', details: ['目录不存在(已清理)'], residual });
|
|
259
412
|
continue;
|
|
260
413
|
}
|
|
@@ -282,7 +435,15 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
|
|
|
282
435
|
try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
|
|
283
436
|
}
|
|
284
437
|
details.push(`分支 ${meta.branch} 保留作 review 锚点(确认无需回溯后可 git -C ${repoRoot} branch -D ${meta.branch})`);
|
|
438
|
+
if (residual.length === 0) {
|
|
439
|
+
removePlacementRegistryEntry(base, changeName, repoKey); // cleaned 后注册表键回收(FR-02)
|
|
440
|
+
}
|
|
285
441
|
results.push({ repoKey, result: residual.length > 0 ? 'partial' : 'cleaned', details, residual });
|
|
286
442
|
}
|
|
443
|
+
// 差集 sweep(评审 P2 吸收):cleanup 迭代源=listCrossWorktreeMetas 只列有 meta 条目,
|
|
444
|
+
// 「worktree remove 连目录带 meta 同亡、仅注册表键存」的悬挂键够不着——末尾对本变更名下
|
|
445
|
+
// 全部键按「worktreePath 处无 meta.json」差集清(活键不删)。
|
|
446
|
+
const swept = sweepPlacementRegistry(base, changeName);
|
|
447
|
+
if (swept.length > 0) results.push({ repoKey: '(registry-sweep)', result: 'cleaned', details: [`悬挂注册表键已清: ${swept.join('、')}`], residual: [] });
|
|
287
448
|
return { results };
|
|
288
449
|
}
|
package/src/worktree-deps.js
CHANGED
|
@@ -38,21 +38,24 @@ const DEFAULT_TIMEOUT_MS = 300 * 1000;
|
|
|
38
38
|
*/
|
|
39
39
|
const ECOSYSTEMS = [
|
|
40
40
|
{
|
|
41
|
+
// JVM 系(maven/gradle)install=null(坑 maven-provision-runs-tests,2026-10-10 用户实证):
|
|
42
|
+
// 依赖在用户级仓库(~/.m2、~/.gradle),worktree 内无本地产物可供给——根供给诚实落 n/a
|
|
43
|
+
// (deps 门控放行集含 n/a)。旧默认 'mvn -o test' 把跑测试当供给:冷缓存 + 离线 + 工具链
|
|
44
|
+
// 不在 PATH 必败 → depsStatus=failed 卡死 execute --done(agent 被迫手改 meta)。构建/测试
|
|
45
|
+
// 归 task 级验证(mvn compile / gradle build 在任务验收面显式执行);显式 commands.install
|
|
46
|
+
// 配置时仍按白名单执行(inferInstallCommand 优先级不变)。
|
|
41
47
|
type: 'maven',
|
|
42
48
|
manifests: ['pom.xml'],
|
|
43
49
|
marker: null,
|
|
44
50
|
detect: (d) => existsSync(join(d, 'pom.xml')),
|
|
45
|
-
install:
|
|
51
|
+
install: null,
|
|
46
52
|
},
|
|
47
53
|
{
|
|
48
54
|
type: 'gradle',
|
|
49
55
|
manifests: ['build.gradle.kts', 'build.gradle', 'settings.gradle', 'gradle.lockfile'],
|
|
50
56
|
marker: null,
|
|
51
57
|
detect: (d) => existsSync(join(d, 'build.gradle')) || existsSync(join(d, 'build.gradle.kts')),
|
|
52
|
-
|
|
53
|
-
install: (d) => (process.platform === 'win32'
|
|
54
|
-
? (existsSync(join(d, 'gradlew.bat')) ? 'gradlew.bat test' : 'gradle test')
|
|
55
|
-
: './gradlew test'),
|
|
58
|
+
install: null,
|
|
56
59
|
},
|
|
57
60
|
{
|
|
58
61
|
type: 'nodejs',
|
|
@@ -210,7 +213,10 @@ export function detectProjectType(worktreePath, specBase) {
|
|
|
210
213
|
export function inferInstallCommand(projectType, worktreePath, userInstall) {
|
|
211
214
|
if (userInstall) return userInstall;
|
|
212
215
|
const eco = ECOSYSTEMS.find(e => e.type === projectType);
|
|
213
|
-
|
|
216
|
+
// install:null 表项(maven/gradle——依赖在用户级仓库无 worktree 内供给面)与表外类型同归
|
|
217
|
+
// null → 根供给 n/a(坑 maven-provision-runs-tests)
|
|
218
|
+
if (!eco || typeof eco.install !== 'function') return null;
|
|
219
|
+
return eco.install(worktreePath);
|
|
214
220
|
}
|
|
215
221
|
|
|
216
222
|
/** 在 worktreePath 创建 node_modules 链接到 mainNodeModules;失败回退 */
|
|
@@ -640,10 +646,45 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
|
|
|
640
646
|
const lines = String(yamlText || '').replace(/\r\n?/g, '\n').split('\n');
|
|
641
647
|
const start = lines.findIndex(l => /^repos:\s*(?:#.*)?$/.test(l));
|
|
642
648
|
if (start === -1) return roots;
|
|
649
|
+
const cleanVal = (v) => String(v || '').replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '');
|
|
643
650
|
for (let i = start + 1; i < lines.length; i++) {
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
651
|
+
const line = lines[i];
|
|
652
|
+
if (line.length > 0 && !/\s/.test(line[0]) && line.trim() !== '' && !line.startsWith('#')) break;
|
|
653
|
+
const m = line.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/);
|
|
654
|
+
if (!m) continue;
|
|
655
|
+
const value = (m[2] || '').trim();
|
|
656
|
+
if (value !== '' && !value.startsWith('#')) {
|
|
657
|
+
if (value.startsWith('{') && value.endsWith('}')) {
|
|
658
|
+
// inline 对象条目(repos.<key>: {path: .., worktree: ..})——只收 path 子键
|
|
659
|
+
for (const part of value.slice(1, -1).split(',')) {
|
|
660
|
+
const idx = part.indexOf(':');
|
|
661
|
+
if (idx !== -1 && part.slice(0, idx).trim() === 'path') {
|
|
662
|
+
const p = cleanVal(part.slice(idx + 1));
|
|
663
|
+
if (p) roots.push(p);
|
|
664
|
+
break;
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
} else {
|
|
668
|
+
roots.push(cleanVal(value));
|
|
669
|
+
}
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
// 块式对象条目(value 空):消费子键块,只收 path 子键值(worktree 落位根不是仓根,
|
|
673
|
+
// 混入会放宽越界豁免面——坑 repo-inline-worktree-placement 收口)
|
|
674
|
+
const indent = line.length - line.trimStart().length;
|
|
675
|
+
let j = i + 1;
|
|
676
|
+
for (; j < lines.length; j++) {
|
|
677
|
+
const l = lines[j];
|
|
678
|
+
if (l.trim() === '' || l.trim().startsWith('#')) continue;
|
|
679
|
+
const subIndent = l.length - l.trimStart().length;
|
|
680
|
+
if (subIndent <= indent) break;
|
|
681
|
+
const sub = l.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/);
|
|
682
|
+
if (sub && sub[1] === 'path') {
|
|
683
|
+
const p = cleanVal(sub[2]);
|
|
684
|
+
if (p && !p.startsWith('#')) roots.push(p);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
i = j - 1;
|
|
647
688
|
}
|
|
648
689
|
return roots;
|
|
649
690
|
})();
|