sillyspec 3.15.0 → 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 +185 -16
- 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 +119 -6
- package/src/worktree.js +209 -17
- package/test/scan-paths.test.mjs +68 -0
package/src/worktree-apply.js
CHANGED
|
@@ -92,9 +92,17 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
|
92
92
|
// 同时检测 untracked 新文件(git diff 不包含 untracked)
|
|
93
93
|
let changedFiles;
|
|
94
94
|
try {
|
|
95
|
-
//
|
|
96
|
-
const
|
|
97
|
-
const
|
|
95
|
+
// 用 --name-status 捕获 rename/delete(--name-only 会丢失 rename 源文件)
|
|
96
|
+
const statusRaw = git(worktreePath, `diff --name-status ${diffBase}`);
|
|
97
|
+
const statusFiles = new Set();
|
|
98
|
+
if (statusRaw) {
|
|
99
|
+
for (const line of statusRaw.split('\n').filter(Boolean)) {
|
|
100
|
+
const parts = line.split('\t');
|
|
101
|
+
// R100 old.txt new.txt → 提取两个文件
|
|
102
|
+
if (parts.length >= 2) statusFiles.add(parts[parts.length - 1]);
|
|
103
|
+
if (parts.length >= 3) statusFiles.add(parts[parts.length - 2]);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
98
106
|
|
|
99
107
|
// untracked 新文件(diffBase 中不存在的文件)
|
|
100
108
|
const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`);
|
|
@@ -102,7 +110,7 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
|
102
110
|
? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
|
|
103
111
|
: [];
|
|
104
112
|
|
|
105
|
-
changedFiles = [...new Set([...
|
|
113
|
+
changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
|
|
106
114
|
} catch (e) {
|
|
107
115
|
result.errors.push(`获取变更文件列表失败: ${e.message}`);
|
|
108
116
|
return result;
|
|
@@ -216,8 +224,11 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
|
216
224
|
|
|
217
225
|
// 分 tracked 变更和 untracked 新文件生成 patch
|
|
218
226
|
const trackedFiles = patchFiles.filter(f => {
|
|
219
|
-
//
|
|
220
|
-
|
|
227
|
+
// 文件在 diffBase 的 tree 中存在 → tracked(包括 rename 目标可能的情况)
|
|
228
|
+
if (gitQuiet(worktreePath, `cat-file -e ${diffBase}:${f}`) !== null) return true;
|
|
229
|
+
// 文件在工作区 index 中已存在(比如被 git mv 处理过)→ 也视为 tracked
|
|
230
|
+
if (gitQuiet(worktreePath, `ls-files --error-unmatch ${f}`) !== null) return true;
|
|
231
|
+
return false;
|
|
221
232
|
});
|
|
222
233
|
const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
|
|
223
234
|
|
|
@@ -290,3 +301,105 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
|
290
301
|
|
|
291
302
|
return result;
|
|
292
303
|
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* 格式化 execute run summary(人类可读)
|
|
307
|
+
*
|
|
308
|
+
* 只展示 CLI 真实掌握的信息,不声称知道 per-task 状态。
|
|
309
|
+
* @param {object} opts
|
|
310
|
+
* @param {string} opts.changeName - 变更名
|
|
311
|
+
* @param {number} opts.stepsCompleted - 已完成步骤数
|
|
312
|
+
* @param {number} opts.stepsTotal - 总步骤数
|
|
313
|
+
* @param {string} opts.agentSummary - Agent 最终输出摘要
|
|
314
|
+
* @param {string} [opts.cwd] - 项目根目录(默认 process.cwd())
|
|
315
|
+
* @returns {string} 格式化的 summary 文本
|
|
316
|
+
*/
|
|
317
|
+
export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, agentSummary, cwd }) {
|
|
318
|
+
const wm = new WorktreeManager({ cwd });
|
|
319
|
+
const meta = wm.getMeta(changeName);
|
|
320
|
+
const lines = [];
|
|
321
|
+
|
|
322
|
+
const SEPARATOR = '─'.repeat(32);
|
|
323
|
+
|
|
324
|
+
// --- Header ---
|
|
325
|
+
lines.push(`Execute Summary`);
|
|
326
|
+
lines.push(SEPARATOR);
|
|
327
|
+
|
|
328
|
+
// --- Status ---
|
|
329
|
+
if (!meta) {
|
|
330
|
+
// worktree 不存在(可能已 cleanup 或没有用过 worktree)
|
|
331
|
+
lines.push(`Status: COMPLETED`);
|
|
332
|
+
lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
|
|
333
|
+
lines.push(`Apply: N/A`);
|
|
334
|
+
} else {
|
|
335
|
+
const hasBaseline = meta.baselineCommit != null;
|
|
336
|
+
const wtExists = existsSync(meta.worktreePath);
|
|
337
|
+
|
|
338
|
+
const applyStatus = wtExists ? 'pending' : 'applied';
|
|
339
|
+
const baselineCount = meta.baselineFiles?.length || 0;
|
|
340
|
+
const baselineStatus = hasBaseline
|
|
341
|
+
? `dirty (${baselineCount} baseline file${baselineCount === 1 ? '' : 's'} protected)`
|
|
342
|
+
: 'clean';
|
|
343
|
+
|
|
344
|
+
// Worktree 最终状态
|
|
345
|
+
const mode = meta.mode || 'worktree';
|
|
346
|
+
let worktreeStatus;
|
|
347
|
+
if (mode === 'native-worktree') {
|
|
348
|
+
worktreeStatus = 'kept (external worktree)';
|
|
349
|
+
} else if (mode === 'in-place-fallback') {
|
|
350
|
+
worktreeStatus = 'none (in-place)';
|
|
351
|
+
} else if (!wtExists) {
|
|
352
|
+
worktreeStatus = 'cleaned';
|
|
353
|
+
} else {
|
|
354
|
+
worktreeStatus = 'exists';
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
lines.push(`Status: COMPLETED`);
|
|
358
|
+
lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
|
|
359
|
+
lines.push(`Baseline: ${baselineStatus}`);
|
|
360
|
+
lines.push(`Apply: ${applyStatus}`);
|
|
361
|
+
lines.push(`Worktree: ${worktreeStatus}`);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// --- Changed files ---
|
|
365
|
+
// 从主工作区 diff 获取(worktree 已 apply)或从 worktree diff 获取
|
|
366
|
+
if (meta && existsSync(meta.worktreePath)) {
|
|
367
|
+
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
368
|
+
try {
|
|
369
|
+
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
370
|
+
const { execSync: es } = require('child_process');
|
|
371
|
+
const filesRaw = es(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
|
|
372
|
+
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
373
|
+
if (files.length > 0) {
|
|
374
|
+
lines.push(``);
|
|
375
|
+
const maxShow = 10;
|
|
376
|
+
const showFiles = files.slice(0, maxShow);
|
|
377
|
+
const remain = files.length - maxShow;
|
|
378
|
+
lines.push(`Changed Files (${files.length})`);
|
|
379
|
+
showFiles.forEach(f => lines.push(` ${f}`));
|
|
380
|
+
if (remain > 0) {
|
|
381
|
+
lines.push(` ... ${remain} more`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} catch {}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --- Agent Summary ---
|
|
388
|
+
if (agentSummary) {
|
|
389
|
+
lines.push(``);
|
|
390
|
+
lines.push(`Agent Summary`);
|
|
391
|
+
// 缩进每行,截断过长内容
|
|
392
|
+
const maxLen = 200;
|
|
393
|
+
const summary = agentSummary.length > maxLen
|
|
394
|
+
? agentSummary.slice(0, maxLen) + '...'
|
|
395
|
+
: agentSummary;
|
|
396
|
+
summary.split('\n').forEach(l => lines.push(` ${l}`));
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// --- Next ---
|
|
400
|
+
lines.push(``);
|
|
401
|
+
lines.push(`Next`);
|
|
402
|
+
lines.push(` → sillyspec run verify`);
|
|
403
|
+
|
|
404
|
+
return lines.join('\n');
|
|
405
|
+
}
|
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
|
/**
|
|
@@ -302,10 +492,11 @@ export class WorktreeManager {
|
|
|
302
492
|
const staged = gitQuiet(mainCwd, 'diff --cached --name-only') || '';
|
|
303
493
|
if (staged) {
|
|
304
494
|
try {
|
|
305
|
-
|
|
306
|
-
|
|
495
|
+
// 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
|
|
496
|
+
const patchBuf = execSync(`git diff --cached --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
|
|
497
|
+
if (patchBuf && patchBuf.length > 0) {
|
|
307
498
|
const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
|
|
308
|
-
writeFileSync(patchFile,
|
|
499
|
+
writeFileSync(patchFile, patchBuf);
|
|
309
500
|
git(worktreePath, `apply --binary ${patchFile}`);
|
|
310
501
|
rmSync(patchFile, { force: true });
|
|
311
502
|
}
|
|
@@ -319,10 +510,11 @@ export class WorktreeManager {
|
|
|
319
510
|
const unstaged = gitQuiet(mainCwd, 'diff --name-only') || '';
|
|
320
511
|
if (unstaged) {
|
|
321
512
|
try {
|
|
322
|
-
|
|
323
|
-
|
|
513
|
+
// 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
|
|
514
|
+
const patchBuf = execSync(`git diff --binary`, { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
|
|
515
|
+
if (patchBuf && patchBuf.length > 0) {
|
|
324
516
|
const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
|
|
325
|
-
writeFileSync(patchFile,
|
|
517
|
+
writeFileSync(patchFile, patchBuf);
|
|
326
518
|
git(worktreePath, `apply --binary ${patchFile}`);
|
|
327
519
|
rmSync(patchFile, { force: true });
|
|
328
520
|
}
|
|
@@ -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
|
+
}
|