sillyspec 3.22.2 → 3.22.4
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-doctor/SKILL.md +65 -61
- package/.claude/skills/sillyspec-quick/SKILL.md +84 -84
- package/package.json +1 -1
- package/src/doctor-diagnostics.js +696 -575
- package/src/index.js +65 -0
- package/src/progress.js +111 -0
- package/src/run.js +26 -9
- package/src/worktree-apply.js +2 -4
- package/src/worktree.js +37 -3
package/src/index.js
CHANGED
|
@@ -297,6 +297,20 @@ async function main() {
|
|
|
297
297
|
break;
|
|
298
298
|
}
|
|
299
299
|
case 'run': {
|
|
300
|
+
// doctor --json 等价 doctor 顶层 --json(结构化诊断),与 case 'doctor' 行为一致——
|
|
301
|
+
// 否则 sillyspec doctor --json 走 runDoctorDiagnostics,sillyspec run doctor --json 走 runCommand
|
|
302
|
+
// prompt 自检,两者输出不等价(违反"顶层别名 == run <stage>"契约)。
|
|
303
|
+
if (filteredArgs[1] === 'doctor' && json) {
|
|
304
|
+
const doctorEffectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
305
|
+
const { runDoctorDiagnostics, formatDoctorJson, writeDoctorDiagnosis } = await import('./doctor-diagnostics.js')
|
|
306
|
+
const result = await runDoctorDiagnostics({ cwd: doctorEffectiveDir })
|
|
307
|
+
const output = formatDoctorJson(result, { source_root: dir })
|
|
308
|
+
const written = writeDoctorDiagnosis(output, result.authoritySpecDir)
|
|
309
|
+
if (written) console.error(`📁 诊断结果已写入: ${written}`)
|
|
310
|
+
console.log(JSON.stringify(output, null, 2))
|
|
311
|
+
process.exitCode = output.overall_status === 'pass' ? 0 : 1
|
|
312
|
+
break
|
|
313
|
+
}
|
|
300
314
|
const { runCommand } = await import('./run.js')
|
|
301
315
|
// 平台模式(--spec-dir 已指定)时,--dir 是明确的 source_root,不应被 resolveEffectiveDir 纠正
|
|
302
316
|
const effectiveDir = specDir ? dir : resolveEffectiveDir(dir)
|
|
@@ -317,6 +331,57 @@ async function main() {
|
|
|
317
331
|
const pathIdx = filteredArgs.indexOf('--path');
|
|
318
332
|
const dbPath = pathIdx >= 0 && filteredArgs[pathIdx + 1] ? filteredArgs[pathIdx + 1] : null;
|
|
319
333
|
|
|
334
|
+
// --align-execute-progress:基于 plan.md 声明对齐 execute 派生戳(仿 --cleanup-remnant 范式)
|
|
335
|
+
// 默认 dry-run(只报告将补哪些 step),加 --confirm 才写。命中即 break,绝不 fall-through。
|
|
336
|
+
const alignFlag = filteredArgs.includes('--align-execute-progress');
|
|
337
|
+
if (alignFlag) {
|
|
338
|
+
// --change 解析:显式优先,缺省用单活跃变更自动兜底(与 run.js resolveChangeNameAuto 同逻辑)
|
|
339
|
+
const alignChangeIdx = filteredArgs.indexOf('--change');
|
|
340
|
+
let alignChange = alignChangeIdx >= 0 && filteredArgs[alignChangeIdx + 1] ? filteredArgs[alignChangeIdx + 1] : null;
|
|
341
|
+
if (!alignChange) {
|
|
342
|
+
const changesDir = join(resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec'), 'changes');
|
|
343
|
+
if (existsSync(changesDir)) {
|
|
344
|
+
const activeChanges = readdirSync(changesDir, { withFileTypes: true })
|
|
345
|
+
.filter(e => e.isDirectory() && e.name !== 'archive')
|
|
346
|
+
.map(e => e.name);
|
|
347
|
+
if (activeChanges.length === 1) alignChange = activeChanges[0];
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (!alignChange) {
|
|
351
|
+
console.error('❌ 无法确定变更名:请用 --change <name>,或确保仅有一个活跃变更');
|
|
352
|
+
process.exitCode = 2;
|
|
353
|
+
break;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// 复用顶层静态 import(line 12),不动态 await import
|
|
357
|
+
const pm = new ProgressManager({ specDir: resolvePlatformSpecDir(doctorEffectiveDir, specDir) });
|
|
358
|
+
const specBase = resolvePlatformSpecDir(doctorEffectiveDir, specDir) || join(doctorEffectiveDir, '.sillyspec');
|
|
359
|
+
let r;
|
|
360
|
+
try {
|
|
361
|
+
r = await pm.alignExecuteToPlan(doctorEffectiveDir, alignChange, specBase, { confirm: doctorConfirm });
|
|
362
|
+
} catch (e) {
|
|
363
|
+
console.error(`❌ 对齐失败:${e.message}`);
|
|
364
|
+
process.exitCode = 1;
|
|
365
|
+
break;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
if (json) {
|
|
369
|
+
console.log(JSON.stringify(r, null, 2));
|
|
370
|
+
} else if (r.ok === false) {
|
|
371
|
+
console.error(`❌ ${r.reason || '对齐未执行'}`);
|
|
372
|
+
} else {
|
|
373
|
+
console.log(`✅ 已基于 plan.md 声明对齐 ${r.aligned} 个 step(plan: ${r.planChecked}/${r.planTotal})。`);
|
|
374
|
+
if (!doctorConfirm) {
|
|
375
|
+
console.log(` (dry-run:未写盘。加 --confirm 实际落盘,并置 execute 阶段 status=completed。)`);
|
|
376
|
+
} else {
|
|
377
|
+
console.log(` 已落盘,execute 阶段 status=completed。请确认 verify 通过。`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
// ok=false 或 reason 非空 → 1;否则 0(与 --cleanup-remnant 的 r.errors 逻辑同构)
|
|
381
|
+
process.exitCode = (r.ok === false || r.reason) ? 1 : 0;
|
|
382
|
+
break;
|
|
383
|
+
}
|
|
384
|
+
|
|
320
385
|
if (cleanupRemnant) {
|
|
321
386
|
const { cleanupRemnantDbs } = await import('./doctor-diagnostics.js');
|
|
322
387
|
const r = await cleanupRemnantDbs({ cwd: doctorEffectiveDir, confirm: doctorConfirm });
|
package/src/progress.js
CHANGED
|
@@ -1799,4 +1799,115 @@ export class ProgressManager {
|
|
|
1799
1799
|
writeFileSync(gitignorePath, rule + '\n');
|
|
1800
1800
|
}
|
|
1801
1801
|
}
|
|
1802
|
+
|
|
1803
|
+
// ── plan.md 对齐(doctor --align-execute-progress 入口)──
|
|
1804
|
+
|
|
1805
|
+
/**
|
|
1806
|
+
* 解析 changeDir/plan.md(回退 tasks.md)的 task checkbox 统计。
|
|
1807
|
+
* 仅匹配 `- [ ] task-NN` / `- [x] task-NN` 形态的行(task- 前缀锚定,避免误捞非任务项)。
|
|
1808
|
+
* @param {string} changeDir - 变更目录绝对路径(含 plan.md/tasks.md)
|
|
1809
|
+
* @returns {{ total: number, checked: number }}
|
|
1810
|
+
*/
|
|
1811
|
+
readPlanCheckboxStatus(changeDir) {
|
|
1812
|
+
if (!changeDir || typeof changeDir !== 'string') {
|
|
1813
|
+
throw new Error('changeDir 不能为空');
|
|
1814
|
+
}
|
|
1815
|
+
const planPath = join(changeDir, 'plan.md');
|
|
1816
|
+
const tasksPath = join(changeDir, 'tasks.md');
|
|
1817
|
+
let content = null;
|
|
1818
|
+
if (existsSync(planPath)) {
|
|
1819
|
+
content = readFileSync(planPath, 'utf8');
|
|
1820
|
+
} else if (existsSync(tasksPath)) {
|
|
1821
|
+
content = readFileSync(tasksPath, 'utf8');
|
|
1822
|
+
} else {
|
|
1823
|
+
return { total: 0, checked: 0 };
|
|
1824
|
+
}
|
|
1825
|
+
// 匹配 `- [ ] task-NN` / `- [x] task-NN`(允许 [x] 大小写、空格弹性、task- 前缀)
|
|
1826
|
+
const re = /^\s*[-*]\s+\[([ xX])\]\s+task-\d+/gm;
|
|
1827
|
+
let total = 0;
|
|
1828
|
+
let checked = 0;
|
|
1829
|
+
let m;
|
|
1830
|
+
while ((m = re.exec(content)) !== null) {
|
|
1831
|
+
total++;
|
|
1832
|
+
if (m[1] === 'x' || m[1] === 'X') checked++;
|
|
1833
|
+
}
|
|
1834
|
+
return { total, checked };
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
/**
|
|
1838
|
+
* 按 plan.md 声明对齐 execute 阶段派生进度戳。
|
|
1839
|
+
* 仅当 plan.md 所有 task checkbox 全勾时,把 execute 阶段所有非 completed step 标 completed,
|
|
1840
|
+
* 并显式置 execute stageData.status='completed' + completedAt(绕过 completeStep 推导,D-003@v2)。
|
|
1841
|
+
* 不复核代码,信任 plan.md 声明(与 archive 同源,verify 阶段兜底,D-002/D-004)。
|
|
1842
|
+
*
|
|
1843
|
+
* @param {string} cwd
|
|
1844
|
+
* @param {string} changeName
|
|
1845
|
+
* @param {string} specBase - platformOpts.specRoot || join(cwd, '.sillyspec'),用于定位 changes 目录
|
|
1846
|
+
* @param {object} [opts]
|
|
1847
|
+
* @param {boolean} [opts.confirm=false] - 默认 dry-run,仅当 confirm=true 才落盘
|
|
1848
|
+
* @returns {Promise<{ ok: boolean, aligned: number, skipped: number, planTotal: number, planChecked: number, reason?: string, dryRun?: boolean }>}
|
|
1849
|
+
*/
|
|
1850
|
+
async alignExecuteToPlan(cwd, changeName, specBase, opts = {}) {
|
|
1851
|
+
if (!changeName) throw new Error('changeName 不能为空');
|
|
1852
|
+
const { confirm = false } = opts;
|
|
1853
|
+
|
|
1854
|
+
// 1. 读 progress;无 progress / 无 execute 阶段 → 拒绝
|
|
1855
|
+
const progress = await this.read(cwd, changeName);
|
|
1856
|
+
if (!progress || !progress.stages || !progress.stages.execute) {
|
|
1857
|
+
return { ok: false, aligned: 0, skipped: 0, planTotal: 0, planChecked: 0, reason: 'execute 阶段无进度数据' };
|
|
1858
|
+
}
|
|
1859
|
+
const executeStage = progress.stages.execute;
|
|
1860
|
+
const steps = Array.isArray(executeStage.steps) ? executeStage.steps : [];
|
|
1861
|
+
if (steps.length === 0) {
|
|
1862
|
+
return { ok: false, aligned: 0, skipped: 0, planTotal: 0, planChecked: 0, reason: 'execute 阶段无进度数据' };
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
// 2. 读 plan.md checkbox;未全勾 → 拒绝(D-002)
|
|
1866
|
+
const specRoot = specBase || this._getSpecDir(cwd);
|
|
1867
|
+
const changeDir = join(specRoot, CHANGES_SUBDIR, changeName);
|
|
1868
|
+
const { total: planTotal, checked: planChecked } = this.readPlanCheckboxStatus(changeDir);
|
|
1869
|
+
if (planTotal === 0) {
|
|
1870
|
+
return { ok: false, aligned: 0, skipped: 0, planTotal, planChecked, reason: 'plan.md 无 task checkbox(无法判定完成度)' };
|
|
1871
|
+
}
|
|
1872
|
+
if (planChecked < planTotal) {
|
|
1873
|
+
return {
|
|
1874
|
+
ok: false,
|
|
1875
|
+
aligned: 0,
|
|
1876
|
+
skipped: 0,
|
|
1877
|
+
planTotal,
|
|
1878
|
+
planChecked,
|
|
1879
|
+
reason: `plan.md 有未勾选 task(${planChecked}/${planTotal}),拒绝对齐`,
|
|
1880
|
+
};
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
// 3. 全勾:计算将补哪些 step
|
|
1884
|
+
const now = new Date().toISOString();
|
|
1885
|
+
let aligned = 0;
|
|
1886
|
+
let skipped = 0;
|
|
1887
|
+
for (const step of steps) {
|
|
1888
|
+
if (step.status === 'completed') {
|
|
1889
|
+
skipped++;
|
|
1890
|
+
continue;
|
|
1891
|
+
}
|
|
1892
|
+
aligned++;
|
|
1893
|
+
if (confirm) {
|
|
1894
|
+
step.status = 'completed';
|
|
1895
|
+
step.completedAt = now;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
|
|
1899
|
+
// dry-run:只报告,不落盘
|
|
1900
|
+
if (!confirm) {
|
|
1901
|
+
return { ok: true, aligned, skipped, planTotal, planChecked, dryRun: true };
|
|
1902
|
+
}
|
|
1903
|
+
|
|
1904
|
+
// 4. confirm:显式置 execute stageData.status='completed' + completedAt(D-003@v2,复刻 run.js:2303-2304)
|
|
1905
|
+
executeStage.status = 'completed';
|
|
1906
|
+
executeStage.completedAt = now;
|
|
1907
|
+
progress.currentStage = progress.currentStage || 'execute';
|
|
1908
|
+
progress.lastActive = now;
|
|
1909
|
+
await this._write(cwd, progress, changeName);
|
|
1910
|
+
|
|
1911
|
+
return { ok: true, aligned, skipped, planTotal, planChecked };
|
|
1912
|
+
}
|
|
1802
1913
|
}
|
package/src/run.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* CLI 成为流程引擎,AI 变成步骤执行器。
|
|
5
5
|
* 支持多变更并行:每个变更状态存储在 sillyspec.db 中。
|
|
6
6
|
*/
|
|
7
|
-
import { basename, join, resolve, dirname } from 'path'
|
|
7
|
+
import { basename, join, resolve, dirname, relative, isAbsolute } from 'path'
|
|
8
8
|
import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, rmSync, statSync } from 'fs'
|
|
9
9
|
import { createRequire } from 'module'
|
|
10
10
|
const require = createRequire(import.meta.url)
|
|
@@ -61,7 +61,8 @@ export function validateParsedProjects(projects, sourceRoot) {
|
|
|
61
61
|
errors.push(`项目 "${id}" 的 path 包含 .. ,拒绝越界`)
|
|
62
62
|
} else {
|
|
63
63
|
const absPath = resolve(safeRoot, proj.path)
|
|
64
|
-
|
|
64
|
+
const rel = relative(safeRoot, absPath)
|
|
65
|
+
if (rel.startsWith('..') || isAbsolute(rel)) {
|
|
65
66
|
errors.push(`项目 "${id}" 的 path "${proj.path}" 解析后超出 source_root`)
|
|
66
67
|
}
|
|
67
68
|
if (!existsSync(absPath)) {
|
|
@@ -1585,7 +1586,7 @@ export async function runCommand(args, cwd, specDir = null) {
|
|
|
1585
1586
|
|
|
1586
1587
|
// --skip
|
|
1587
1588
|
if (isSkip) {
|
|
1588
|
-
return await skipStep(pm, progress, stageName, cwd, effectiveChange)
|
|
1589
|
+
return await skipStep(pm, progress, stageName, cwd, effectiveChange, platformOpts)
|
|
1589
1590
|
}
|
|
1590
1591
|
|
|
1591
1592
|
// --wait: 将 step 设为 waiting(独立于 --done)
|
|
@@ -2388,19 +2389,35 @@ function isCurrentWaveAllNoDepsVerify(stepName, changeDir) {
|
|
|
2388
2389
|
async function enforceDepsGate(stageName, cwd, changeName, step, steps, currentIdx, specBase, platformOpts) {
|
|
2389
2390
|
if (stageName !== 'execute') return true
|
|
2390
2391
|
let meta = null
|
|
2392
|
+
let wm = null
|
|
2391
2393
|
try {
|
|
2392
2394
|
const { WorktreeManager } = await import('./worktree.js')
|
|
2393
|
-
|
|
2395
|
+
wm = new WorktreeManager({ cwd })
|
|
2396
|
+
meta = wm.getMeta(changeName)
|
|
2394
2397
|
} catch {}
|
|
2395
2398
|
const depsStatus = meta?.depsStatus
|
|
2396
2399
|
if (['linked', 'installed', 'n/a'].includes(depsStatus)) return true
|
|
2397
2400
|
const changeDir = changeName ? join(specBase, 'changes', changeName) : null
|
|
2398
2401
|
if (isCurrentWaveAllNoDepsVerify(step?.name, changeDir)) return true
|
|
2399
2402
|
if (steps && steps[currentIdx]) steps[currentIdx].status = 'blocked'
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
2403
|
+
// ── 诊断分支(Phase 2,G2/R3 修正:判定基于物理目录而非 !meta)──
|
|
2404
|
+
// getMeta 对"目录不存在"与"meta 损坏"都返回 null,后者会误判终态 → 用物理目录存在性判定。
|
|
2405
|
+
let worktreeGone = true
|
|
2406
|
+
try {
|
|
2407
|
+
worktreeGone = !!(wm && changeName) && !existsSync(wm.getWorktreePath(changeName))
|
|
2408
|
+
} catch {}
|
|
2409
|
+
// ── fail-loud 块(Phase 3,D-005@v1:仅改拒绝侧 stderr)──
|
|
2410
|
+
console.error('❌ ── deps 门控阻断(本次 --done 未完成,进度未推进)──')
|
|
2411
|
+
if (worktreeGone) {
|
|
2412
|
+
console.error(' worktree 不可用(已 cleanup 或目录不存在)。')
|
|
2413
|
+
console.error(` 修复:sillyspec doctor --align-execute-progress${changeName ? ` --change ${changeName}` : ''} 按 plan.md 对齐进度`)
|
|
2414
|
+
console.error(` 或: sillyspec worktree create ${changeName || '<change>'} 重建 worktree 继续跑`)
|
|
2415
|
+
} else {
|
|
2416
|
+
console.error(` 原因:依赖未就绪(depsStatus=${depsStatus || 'unknown'}),不得在无构建/测试能力时声称完成。`)
|
|
2417
|
+
console.error(` 修复:sillyspec worktree doctor --fix${changeName ? ` --change ${changeName}` : ''}`)
|
|
2418
|
+
console.error(' 或在 worktree 内手动安装依赖后重试。')
|
|
2419
|
+
if (meta?.depsError) console.error(` 上次供给错误:${meta.depsError}`)
|
|
2420
|
+
}
|
|
2404
2421
|
process.exit(1)
|
|
2405
2422
|
}
|
|
2406
2423
|
|
|
@@ -3304,7 +3321,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
|
|
|
3304
3321
|
return { stageCompleted: false, currentIdx, nextPendingIdx }
|
|
3305
3322
|
}
|
|
3306
3323
|
|
|
3307
|
-
async function skipStep(pm, progress, stageName, cwd, changeName) {
|
|
3324
|
+
async function skipStep(pm, progress, stageName, cwd, changeName, platformOpts = {}) {
|
|
3308
3325
|
const stageData = progress.stages[stageName]
|
|
3309
3326
|
if (!stageData || !stageData.steps) {
|
|
3310
3327
|
console.error(`❌ 阶段 ${stageName} 未初始化`)
|
package/src/worktree-apply.js
CHANGED
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
15
|
import { execSync } from 'child_process';
|
|
16
|
-
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
16
|
+
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync, readdirSync, readFileSync } from 'fs';
|
|
17
17
|
import { join, resolve } from 'path';
|
|
18
18
|
import { tmpdir } from 'os';
|
|
19
19
|
import { createHash } from 'crypto';
|
|
@@ -362,7 +362,6 @@ export function assessApplyRisk(changeName, { cwd } = {}) {
|
|
|
362
362
|
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
363
363
|
const allowedPaths = new Set();
|
|
364
364
|
if (existsSync(tasksDir)) {
|
|
365
|
-
const { readdirSync, readFileSync } = require('fs');
|
|
366
365
|
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
367
366
|
const content = readFileSync(join(tasksDir, tf), 'utf8');
|
|
368
367
|
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
@@ -511,8 +510,7 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
|
|
|
511
510
|
// worktree 还在,用 baselineCommit 或 baseHash 做 diff
|
|
512
511
|
try {
|
|
513
512
|
const diffBase = meta.baselineCommit || meta.baseHash;
|
|
514
|
-
const
|
|
515
|
-
const filesRaw = es(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
|
|
513
|
+
const filesRaw = execSync(`git -C ${meta.worktreePath} diff --name-only ${diffBase} 2>/dev/null`, { encoding: 'utf8' });
|
|
516
514
|
const files = filesRaw ? filesRaw.trim().split('\n').filter(Boolean) : [];
|
|
517
515
|
if (files.length > 0) {
|
|
518
516
|
lines.push(``);
|
package/src/worktree.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
import { execSync } from 'child_process';
|
|
12
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync } from 'fs';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
|
|
13
13
|
import { join, resolve, dirname } from 'path';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
15
|
import { provisionDeps, lockfileHash } from './worktree-deps.js';
|
|
@@ -244,9 +244,20 @@ export class WorktreeManager {
|
|
|
244
244
|
|
|
245
245
|
// 2. 检查 worktree 是否已存在
|
|
246
246
|
if (existsSync(worktreePath)) {
|
|
247
|
-
// 目录在但 meta.json
|
|
247
|
+
// 目录在但 meta.json 不存在(幽灵状态)—— 删之前必须确认无未提交改动,
|
|
248
|
+
// 否则会丢失 execute 期间未 commit 的代码(不可恢复,3.22.4 修复)。
|
|
248
249
|
if (!this.getMeta(name)) {
|
|
249
|
-
|
|
250
|
+
let uncommitted = '';
|
|
251
|
+
try { uncommitted = git(worktreePath, 'status --porcelain') } catch {}
|
|
252
|
+
if (uncommitted.trim()) {
|
|
253
|
+
throw new Error(
|
|
254
|
+
`检测到幽灵 worktree(无 meta.json)但含未提交改动,拒绝自动清理(防丢代码)。\n` +
|
|
255
|
+
` 目录:${worktreePath}\n` +
|
|
256
|
+
` 请先检查/commit/备份该目录,再手动清理:sillyspec worktree cleanup ${name} --force\n` +
|
|
257
|
+
` (未提交文件数:${uncommitted.trim().split('\n').length})`
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
console.log(`⚠️ 检测到幽灵 worktree 目录(无 meta.json,无未提交改动),自动清理...`);
|
|
250
261
|
try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
|
|
251
262
|
// 同步清理 git worktree 注册 + 残留分支,否则目录虽删但 git 内部状态未清,
|
|
252
263
|
// 后续 git worktree add 会因「worktree 已注册」或「分支已存在」失败
|
|
@@ -590,6 +601,29 @@ export class WorktreeManager {
|
|
|
590
601
|
|
|
591
602
|
const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
|
|
592
603
|
|
|
604
|
+
// Windows 保护:先解链接 worktree/node_modules(junction 指向主 checkout),
|
|
605
|
+
// 否则后续 git worktree remove / rmSync recursive 会跟随 junction 误删主 node_modules 内容。
|
|
606
|
+
if (!isInPlace && existsSync(worktreePath)) {
|
|
607
|
+
const wtNodeModules = join(worktreePath, 'node_modules');
|
|
608
|
+
if (existsSync(wtNodeModules)) {
|
|
609
|
+
let isLink = false;
|
|
610
|
+
try { isLink = lstatSync(wtNodeModules).isSymbolicLink(); } catch {}
|
|
611
|
+
if (isLink) {
|
|
612
|
+
try {
|
|
613
|
+
if (process.platform === 'win32') {
|
|
614
|
+
// Windows rmdir 删 junction(reparse point)不跟随目标,保护主 checkout
|
|
615
|
+
execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
|
|
616
|
+
} else {
|
|
617
|
+
unlinkSync(wtNodeModules);
|
|
618
|
+
}
|
|
619
|
+
details.push('worktree node_modules junction/symlink removed (protect main checkout)');
|
|
620
|
+
} catch (e) {
|
|
621
|
+
details.push(`node_modules link remove failed: ${e.message}`);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
593
627
|
// 1. git worktree remove(带 retry)—— in-place 跳过:无 git worktree 注册,且 worktreePath 即主工作区
|
|
594
628
|
let gitRemoveOk = false;
|
|
595
629
|
if (!isInPlace && existsSync(worktreePath)) {
|