sillyspec 3.15.2 → 3.16.0
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/.husky/pre-push +13 -0
- package/docs/sillyspec/file-lifecycle.md +140 -10
- package/docs/worktree-isolation.md +57 -2
- package/package.json +5 -1
- package/src/db.js +17 -0
- package/src/index.js +44 -3
- package/src/progress.js +42 -0
- package/src/run.js +169 -15
- package/src/stages/doctor.js +42 -0
- package/src/stages/execute.js +32 -5
- package/src/stages/quick.js +3 -3
- package/src/stages/scan.js +15 -15
- package/src/workflow.js +6 -2
- package/src/worktree-apply.js +14 -0
- package/src/worktree.js +201 -11
- package/test/scan-paths.test.mjs +68 -0
package/src/worktree.js
CHANGED
|
@@ -17,6 +17,45 @@ const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
|
|
|
17
17
|
const BRANCH_PREFIX = 'sillyspec/';
|
|
18
18
|
const META_FILE = 'meta.json';
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* 检测当前目录的隔离状态
|
|
22
|
+
* 返回 { inWorktree: boolean, inSubmodule: boolean }
|
|
23
|
+
*
|
|
24
|
+
* 用 git rev-parse --git-dir 和 --git-common-dir 判断:
|
|
25
|
+
* - GIT_DIR != GIT_COMMON 通常是 linked worktree
|
|
26
|
+
* - 但在 git submodule 里也会出现这种情况
|
|
27
|
+
* - 所以必须额外检查 --show-superproject-working-tree 排除 submodule
|
|
28
|
+
*/
|
|
29
|
+
export function detectIsolation(cwd = process.cwd()) {
|
|
30
|
+
try {
|
|
31
|
+
const gitDir = execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
32
|
+
const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
33
|
+
const superProject = gitQuiet(cwd, 'rev-parse --show-superproject-working-tree');
|
|
34
|
+
|
|
35
|
+
const inWorktree = gitDir !== gitCommonDir && !superProject;
|
|
36
|
+
const inSubmodule = !!superProject;
|
|
37
|
+
|
|
38
|
+
return { inWorktree, inSubmodule, gitDir, gitCommonDir };
|
|
39
|
+
} catch {
|
|
40
|
+
return { inWorktree: false, inSubmodule: false, gitDir: null, gitCommonDir: null };
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* 检查 worktree 存储目录是否被 .gitignore 忽略
|
|
46
|
+
* @param {string} cwd - 项目根目录
|
|
47
|
+
* @returns {{ ignored: boolean, path: string }}
|
|
48
|
+
*/
|
|
49
|
+
export function checkWorktreeDirIgnored(cwd = process.cwd()) {
|
|
50
|
+
const relPath = WORKTREES_REL;
|
|
51
|
+
try {
|
|
52
|
+
execSync(`git check-ignore -q ${relPath}`, { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
|
|
53
|
+
return { ignored: true, path: relPath };
|
|
54
|
+
} catch {
|
|
55
|
+
return { ignored: false, path: relPath };
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
20
59
|
function git(cwd, args) {
|
|
21
60
|
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
22
61
|
}
|
|
@@ -115,7 +154,37 @@ export class WorktreeManager {
|
|
|
115
154
|
const worktreePath = this.getWorktreePath(name);
|
|
116
155
|
const branch = BRANCH_PREFIX + name;
|
|
117
156
|
|
|
118
|
-
//
|
|
157
|
+
// 0. 检测当前环境隔离状态(submodule guard)
|
|
158
|
+
const isolation = detectIsolation(this.cwd);
|
|
159
|
+
if (isolation.inSubmodule) {
|
|
160
|
+
throw new Error(
|
|
161
|
+
'当前目录在 git submodule 内,SillySpec worktree 不支持在 submodule 中创建。' +
|
|
162
|
+
'\n请在主仓库中执行,或使用 --no-worktree 跳过隔离。'
|
|
163
|
+
);
|
|
164
|
+
}
|
|
165
|
+
if (isolation.inWorktree) {
|
|
166
|
+
// 已在 linked worktree 中,复用当前目录作为 worktree 路径
|
|
167
|
+
console.log(`ℹ️ 已在 linked worktree 中(git-dir: ${isolation.gitDir}),复用当前隔离环境。`);
|
|
168
|
+
return this._createInPlaceMeta(name, {
|
|
169
|
+
worktreePath: this.cwd,
|
|
170
|
+
branch: gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || 'detached',
|
|
171
|
+
mode: 'native-worktree',
|
|
172
|
+
base,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// 1. 检查 worktree 目录是否被 gitignore
|
|
177
|
+
const ignoreStatus = checkWorktreeDirIgnored(this.cwd);
|
|
178
|
+
if (!ignoreStatus.ignored) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`worktree 存储目录 ${ignoreStatus.path} 未被 .gitignore 忽略,` +
|
|
181
|
+
`创建 worktree 可能导致内容被误提交。\n` +
|
|
182
|
+
`请先在 .gitignore 中添加: ${ignoreStatus.path}/\n` +
|
|
183
|
+
`或运行 sillyspec doctor 检查修复。`
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// 2. 检查 worktree 是否已存在
|
|
119
188
|
if (existsSync(worktreePath)) {
|
|
120
189
|
// 目录在但 meta.json 不存在(幽灵状态),自动清理
|
|
121
190
|
if (!this.getMeta(name)) {
|
|
@@ -147,7 +216,7 @@ export class WorktreeManager {
|
|
|
147
216
|
mkdirSync(this.worktreeBase, { recursive: true });
|
|
148
217
|
}
|
|
149
218
|
|
|
150
|
-
// 5. 创建 worktree
|
|
219
|
+
// 5. 创建 worktree(含版本检测 + sandbox fallback)
|
|
151
220
|
try {
|
|
152
221
|
git(this.cwd, `worktree add ${worktreePath} -b ${branch} ${baseHash}`);
|
|
153
222
|
} catch (e) {
|
|
@@ -155,7 +224,16 @@ export class WorktreeManager {
|
|
|
155
224
|
if (!check.supported) {
|
|
156
225
|
throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 使用 --no-worktree 标志跳过隔离,或升级 git 到 >= 2.15`);
|
|
157
226
|
}
|
|
158
|
-
|
|
227
|
+
// sandbox/permission fallback: 降级为 in-place + baseline protection
|
|
228
|
+
console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
|
|
229
|
+
console.log(` 原因: ${e.stderr || e.message}`);
|
|
230
|
+
return this._createInPlaceMeta(name, {
|
|
231
|
+
worktreePath: this.cwd,
|
|
232
|
+
branch,
|
|
233
|
+
baseBranch,
|
|
234
|
+
baseHash,
|
|
235
|
+
mode: 'in-place-fallback',
|
|
236
|
+
});
|
|
159
237
|
}
|
|
160
238
|
|
|
161
239
|
// 5.5 自动同步远程最新代码(防止 worktree 基于过时的 commit)
|
|
@@ -206,15 +284,99 @@ export class WorktreeManager {
|
|
|
206
284
|
actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
|
|
207
285
|
createdAt: new Date().toISOString(),
|
|
208
286
|
worktreePath,
|
|
287
|
+
mode: 'worktree',
|
|
209
288
|
baselineFiles,
|
|
210
289
|
baselineCommit,
|
|
211
|
-
baselineHash,
|
|
290
|
+
baselineHash,
|
|
212
291
|
};
|
|
213
292
|
|
|
214
293
|
const metaPath = join(worktreePath, META_FILE);
|
|
215
294
|
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
216
295
|
|
|
217
|
-
return { branch, worktreePath, baseHash };
|
|
296
|
+
return { branch, worktreePath, baseHash, mode: meta.mode };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* 创建 in-place 模式的 meta.json(降级路径)
|
|
301
|
+
* 不创建 git worktree,直接在当前目录记录 baseline 并写入 meta
|
|
302
|
+
* @private
|
|
303
|
+
*/
|
|
304
|
+
_createInPlaceMeta(name, { worktreePath, branch, baseBranch, baseHash, mode } = {}) {
|
|
305
|
+
// 解析 base
|
|
306
|
+
if (!baseHash) {
|
|
307
|
+
baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD');
|
|
308
|
+
baseHash = git(this.cwd, 'rev-parse HEAD');
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const baselineResult = this._overlayBaseline(this.cwd, this.cwd);
|
|
312
|
+
const baselineFiles = baselineResult.files;
|
|
313
|
+
const baselineHash = baselineResult.baselineHash;
|
|
314
|
+
|
|
315
|
+
let baselineCommit = null;
|
|
316
|
+
if (baselineFiles.length > 0) {
|
|
317
|
+
baselineCommit = this._createBaselineCheckpoint(this.cwd, name);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const meta = {
|
|
321
|
+
changeName: name,
|
|
322
|
+
branch: branch || BRANCH_PREFIX + name,
|
|
323
|
+
baseBranch,
|
|
324
|
+
baseHash,
|
|
325
|
+
actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
|
|
326
|
+
createdAt: new Date().toISOString(),
|
|
327
|
+
worktreePath,
|
|
328
|
+
mode: mode || 'in-place-fallback',
|
|
329
|
+
baselineFiles,
|
|
330
|
+
baselineCommit,
|
|
331
|
+
baselineHash,
|
|
332
|
+
};
|
|
333
|
+
|
|
334
|
+
// in-place 模式下 meta 写入 worktreeBase(避免污染主工作区)
|
|
335
|
+
if (!existsSync(this.worktreeBase)) {
|
|
336
|
+
mkdirSync(this.worktreeBase, { recursive: true });
|
|
337
|
+
}
|
|
338
|
+
const metaPath = join(this.worktreeBase, name, META_FILE);
|
|
339
|
+
const metaDir = join(this.worktreeBase, name);
|
|
340
|
+
if (!existsSync(metaDir)) {
|
|
341
|
+
mkdirSync(metaDir, { recursive: true });
|
|
342
|
+
}
|
|
343
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
344
|
+
|
|
345
|
+
return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 构建 isolation 信息对象,用于写入 gate-status.json
|
|
350
|
+
* @param {string} changeName
|
|
351
|
+
* @returns {{ status: string, mode: string, path: string } | null}
|
|
352
|
+
*/
|
|
353
|
+
getIsolationInfo(changeName) {
|
|
354
|
+
const meta = this.getMeta(changeName);
|
|
355
|
+
if (!meta) return null;
|
|
356
|
+
|
|
357
|
+
const mode = meta.mode || 'worktree';
|
|
358
|
+
const statusMap = {
|
|
359
|
+
'worktree': 'verified',
|
|
360
|
+
'native-worktree': 'verified',
|
|
361
|
+
'in-place-fallback': 'degraded',
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
return {
|
|
365
|
+
status: statusMap[mode] || 'verified',
|
|
366
|
+
mode,
|
|
367
|
+
path: meta.worktreePath,
|
|
368
|
+
branch: meta.branch,
|
|
369
|
+
};
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* 获取 worktree 的运行模式
|
|
374
|
+
* @param {string} changeName
|
|
375
|
+
* @returns {'worktree'|'native-worktree'|'in-place-fallback'|null}
|
|
376
|
+
*/
|
|
377
|
+
getMode(changeName) {
|
|
378
|
+
const meta = this.getMeta(changeName);
|
|
379
|
+
return meta?.mode || null;
|
|
218
380
|
}
|
|
219
381
|
|
|
220
382
|
/**
|
|
@@ -239,6 +401,7 @@ export class WorktreeManager {
|
|
|
239
401
|
baseBranch: meta.baseBranch,
|
|
240
402
|
createdAt: meta.createdAt,
|
|
241
403
|
worktreePath: meta.worktreePath,
|
|
404
|
+
mode: meta.mode || 'worktree',
|
|
242
405
|
});
|
|
243
406
|
}
|
|
244
407
|
|
|
@@ -246,24 +409,43 @@ export class WorktreeManager {
|
|
|
246
409
|
}
|
|
247
410
|
|
|
248
411
|
/**
|
|
249
|
-
* 清理 worktree
|
|
412
|
+
* 清理 worktree(仅限 SillySpec 创建的临时 worktree)
|
|
250
413
|
* @param {string} changeName
|
|
251
|
-
* @
|
|
414
|
+
* @param {{ force?: boolean }} opts - force: 跳过 mode 安检(仅用于 worktree 目录本身)
|
|
415
|
+
* @throws {Error} worktree 不存在、不允许删除
|
|
416
|
+
* @returns {{ result: 'cleaned'|'skipped'|'kept', mode: string }}
|
|
252
417
|
*/
|
|
253
|
-
cleanup(changeName) {
|
|
418
|
+
cleanup(changeName, { force = false } = {}) {
|
|
254
419
|
const name = validateChangeName(changeName);
|
|
255
420
|
const meta = this.getMeta(name);
|
|
256
421
|
const worktreePath = this.getWorktreePath(name);
|
|
257
422
|
|
|
258
423
|
if (!meta && !existsSync(worktreePath)) {
|
|
259
|
-
|
|
424
|
+
return { result: 'skipped', mode: null };
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const mode = meta?.mode || 'worktree';
|
|
428
|
+
|
|
429
|
+
// 安全检查:只有 SillySpec 创建的 worktree 才允许删除
|
|
430
|
+
if (!force) {
|
|
431
|
+
if (mode === 'native-worktree') {
|
|
432
|
+
throw new Error(
|
|
433
|
+
`当前 worktree 是外部/原生隔离环境(mode: native-worktree),SillySpec 不允许删除。\n` +
|
|
434
|
+
`此 worktree 不是由 SillySpec 创建的,请手动管理。\n` +
|
|
435
|
+
`如需强制清理,使用 --force 标志。`
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (mode === 'in-place-fallback') {
|
|
439
|
+
return { result: 'skipped', mode };
|
|
440
|
+
}
|
|
260
441
|
}
|
|
261
442
|
|
|
262
443
|
// 1. 尝试 git worktree remove
|
|
444
|
+
let gitRemoveOk = true;
|
|
263
445
|
try {
|
|
264
446
|
git(this.cwd, `worktree remove ${worktreePath} --force`);
|
|
265
|
-
} catch {
|
|
266
|
-
|
|
447
|
+
} catch (e) {
|
|
448
|
+
gitRemoveOk = false;
|
|
267
449
|
}
|
|
268
450
|
const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
|
|
269
451
|
|
|
@@ -283,6 +465,14 @@ export class WorktreeManager {
|
|
|
283
465
|
if (existsSync(worktreePath)) {
|
|
284
466
|
rmSync(worktreePath, { recursive: true, force: true });
|
|
285
467
|
}
|
|
468
|
+
|
|
469
|
+
// 5. 清除 meta 目录(如果 worktree 目录在 worktreeBase 下)
|
|
470
|
+
const metaDir = join(this.worktreeBase, name);
|
|
471
|
+
if (existsSync(metaDir)) {
|
|
472
|
+
rmSync(metaDir, { recursive: true, force: true });
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode };
|
|
286
476
|
}
|
|
287
477
|
|
|
288
478
|
/**
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 防回归测试:scan.js 中不允许硬编码 .sillyspec/docs/<project>/ 作为写入路径
|
|
3
|
+
* 所有正式文档路径必须使用 {DOCS_ROOT} 占位符
|
|
4
|
+
*/
|
|
5
|
+
import { readFileSync } from 'fs'
|
|
6
|
+
import { join, dirname } from 'path'
|
|
7
|
+
import { fileURLToPath } from 'url'
|
|
8
|
+
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
10
|
+
const scanPath = join(__dirname, '..', 'src', 'stages', 'scan.js')
|
|
11
|
+
const content = readFileSync(scanPath, 'utf8')
|
|
12
|
+
|
|
13
|
+
const banned = [
|
|
14
|
+
'.sillyspec/docs/<project>/scan/',
|
|
15
|
+
'.sillyspec/docs/<project>/modules/',
|
|
16
|
+
'.sillyspec/docs/<project>/flows/',
|
|
17
|
+
'.sillyspec/docs/<project>/glossary.md',
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
const required = [
|
|
21
|
+
'{DOCS_ROOT}/scan/',
|
|
22
|
+
'{DOCS_ROOT}/modules/',
|
|
23
|
+
'{DOCS_ROOT}/flows/',
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
let failed = false
|
|
27
|
+
|
|
28
|
+
// 禁止硬编码路径
|
|
29
|
+
for (const pattern of banned) {
|
|
30
|
+
if (content.includes(pattern)) {
|
|
31
|
+
console.error(`❌ FAIL: scan.js 仍包含硬编码路径 "${pattern}"`)
|
|
32
|
+
failed = true
|
|
33
|
+
} else {
|
|
34
|
+
console.log(`✅ PASS: 不包含 "${pattern}"`)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// 必须包含占位符
|
|
39
|
+
for (const pattern of required) {
|
|
40
|
+
if (content.includes(pattern)) {
|
|
41
|
+
console.log(`✅ PASS: 包含占位符 "${pattern}"`)
|
|
42
|
+
} else {
|
|
43
|
+
console.error(`❌ FAIL: scan.js 缺少占位符 "${pattern}"`)
|
|
44
|
+
failed = true
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 禁止硬编码 projects 路径
|
|
49
|
+
if (content.includes('.sillyspec/projects/')) {
|
|
50
|
+
console.error('❌ FAIL: scan.js 仍包含硬编码 ".sillyspec/projects/"')
|
|
51
|
+
failed = true
|
|
52
|
+
} else {
|
|
53
|
+
console.log('✅ PASS: 不包含 ".sillyspec/projects/"')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (content.includes('{PROJECTS_ROOT}/')) {
|
|
57
|
+
console.log('✅ PASS: 包含占位符 "{PROJECTS_ROOT}/"')
|
|
58
|
+
} else {
|
|
59
|
+
console.error('❌ FAIL: scan.js 缺少占位符 "{PROJECTS_ROOT}/"')
|
|
60
|
+
failed = true
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (failed) {
|
|
64
|
+
console.error('\n💥 有测试失败!scan.js 路径占位符可能被回退为硬编码。')
|
|
65
|
+
process.exit(1)
|
|
66
|
+
} else {
|
|
67
|
+
console.log('\n✅ 全部通过 — scan.js 路径占位符防回归测试 OK')
|
|
68
|
+
}
|