sillyspec 3.25.9 → 3.26.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/.claude/skills/sillyspec-auto/SKILL.md +8 -13
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +8 -6
- package/.claude/skills/sillyspec-continue/SKILL.md +3 -3
- package/.claude/skills/sillyspec-doctor/SKILL.md +1 -1
- package/.claude/skills/sillyspec-execute/SKILL.md +10 -4
- package/.claude/skills/sillyspec-plan/SKILL.md +1 -1
- package/.claude/skills/sillyspec-quick/SKILL.md +3 -1
- package/.claude/skills/sillyspec-verify/SKILL.md +1 -1
- package/.sillyspec-platform-cleaned +1 -0
- package/.sillyspec-platform.json +7 -0
- package/README.md +9 -0
- package/package.json +2 -2
- package/src/change-list.js +11 -8
- package/src/change-risk-profile.js +22 -0
- package/src/db.js +170 -161
- package/src/dispatch/backends/local-agent.js +152 -0
- package/src/dispatch/backends/sillyhub-mcp.js +242 -0
- package/src/dispatch/probe.js +210 -0
- package/src/dispatch/strategy.js +121 -0
- package/src/doctor-diagnostics.js +19 -25
- package/src/fs-atomic.js +69 -69
- package/src/git-helper.js +66 -0
- package/src/hooks/worktree-guard.js +64 -55
- package/src/index.js +326 -36
- package/src/init.js +7 -14
- package/src/local-detect.js +120 -110
- package/src/machine-interface.js +54 -19
- package/src/migrate.js +14 -4
- package/src/modules.js +16 -5
- package/src/progress/change-registry.js +85 -70
- package/src/progress/consistency-doctor.js +71 -14
- package/src/progress/shared.js +8 -5
- package/src/progress/stage-machine.js +67 -65
- package/src/progress/step-store.js +72 -67
- package/src/progress.js +448 -236
- package/src/quicklog.js +59 -12
- package/src/run/command.js +78 -39
- package/src/run/complete-handlers.js +1028 -889
- package/src/run/complete.js +132 -212
- package/src/run/concurrent-detect.js +132 -0
- package/src/run/gates.js +302 -19
- package/src/run/prompt.js +22 -68
- package/src/run/quick-audit.js +10 -9
- package/src/run/shared.js +722 -600
- package/src/run/stage.js +43 -24
- package/src/scan-postcheck.js +9 -31
- package/src/sillyhub-mcp/client.js +394 -0
- package/src/sillyhub-mcp/config.js +62 -0
- package/src/spec-dir-typo.js +71 -0
- package/src/stage-contract-engine.js +22 -1
- package/src/stage-contract-spec.js +11 -4
- package/src/stage-contract.js +26 -3
- package/src/stage-review.js +118 -10
- package/src/stages/archive.js +4 -6
- package/src/stages/brainstorm-auto.js +26 -20
- package/src/stages/brainstorm.js +29 -5
- package/src/stages/cmd-existence.js +145 -0
- package/src/stages/doctor.js +10 -7
- package/src/stages/execute.js +64 -22
- package/src/stages/plan-postcheck.js +152 -0
- package/src/stages/plan.js +16 -31
- package/src/stages/quick.js +25 -9
- package/src/stages/scan.js +53 -8
- package/src/stages/status.js +0 -1
- package/src/stages/verify.js +14 -9
- package/src/sync.js +433 -16
- package/src/task-review.js +59 -1
- package/src/verify-postcheck.js +938 -862
- package/src/version.js +19 -0
- package/src/worktree-apply.js +218 -93
- package/src/worktree-deps.js +89 -5
- package/src/worktree.js +213 -111
- package/templates/prompts/taskcard-rules.md +27 -0
- package/templates/prompts/verify-probes.md +3 -3
- package/.claude/skills/sillyspec-propose/SKILL.md +0 -87
- package/src/stages/propose.js +0 -181
package/src/worktree.js
CHANGED
|
@@ -8,12 +8,13 @@
|
|
|
8
8
|
* 分支命名:sillyspec/<change-name>
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { execSync } from 'child_process';
|
|
11
|
+
import { execSync, execFileSync } from 'child_process';
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync, statSync, lstatSync, readlinkSync, unlinkSync } from 'fs';
|
|
13
|
-
import { join, resolve, dirname } from 'path';
|
|
13
|
+
import { join, resolve, dirname, relative, isAbsolute } from 'path';
|
|
14
14
|
import { createHash } from 'crypto';
|
|
15
|
-
import { provisionDeps,
|
|
15
|
+
import { provisionDeps, checkDepsFreshness } from './worktree-deps.js';
|
|
16
16
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
17
|
+
import { git, gitQuiet } from './git-helper.js';
|
|
17
18
|
|
|
18
19
|
// meta.json 会被 hook 进程与其它 CLI 进程并发读取(worktree-guard / getMeta / create 幽灵判定),
|
|
19
20
|
// 必须原子写:半截 JSON 会让 getMeta 返回 null → 触发幽灵 worktree 强删(可能丢 gitignored 改动)。
|
|
@@ -39,9 +40,9 @@ const _mainRepoRootByCwd = new Map();
|
|
|
39
40
|
*/
|
|
40
41
|
export function detectIsolation(cwd = process.cwd()) {
|
|
41
42
|
try {
|
|
42
|
-
const gitDir =
|
|
43
|
-
const gitCommonDir =
|
|
44
|
-
const superProject = gitQuiet(cwd, 'rev-parse --show-superproject-working-tree');
|
|
43
|
+
const gitDir = git(cwd, ['rev-parse', '--git-dir']);
|
|
44
|
+
const gitCommonDir = git(cwd, ['rev-parse', '--git-common-dir']);
|
|
45
|
+
const superProject = gitQuiet(cwd, ['rev-parse', '--show-superproject-working-tree']);
|
|
45
46
|
|
|
46
47
|
const inWorktree = gitDir !== gitCommonDir && !superProject;
|
|
47
48
|
const inSubmodule = !!superProject;
|
|
@@ -60,25 +61,13 @@ export function detectIsolation(cwd = process.cwd()) {
|
|
|
60
61
|
export function checkWorktreeDirIgnored(cwd = process.cwd()) {
|
|
61
62
|
const relPath = WORKTREES_REL;
|
|
62
63
|
try {
|
|
63
|
-
|
|
64
|
+
git(cwd, ['check-ignore', '-q', relPath]);
|
|
64
65
|
return { ignored: true, path: relPath };
|
|
65
66
|
} catch {
|
|
66
67
|
return { ignored: false, path: relPath };
|
|
67
68
|
}
|
|
68
69
|
}
|
|
69
70
|
|
|
70
|
-
function git(cwd, args) {
|
|
71
|
-
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
function gitQuiet(cwd, args) {
|
|
75
|
-
try {
|
|
76
|
-
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
77
|
-
} catch {
|
|
78
|
-
return null;
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
71
|
/**
|
|
83
72
|
* 检测 worktree base(主仓库 HEAD)与 origin 默认分支的同步状态(只读:不 ff、不改 HEAD)。
|
|
84
73
|
* 替代旧的 fetch + merge --ff-only:旧逻辑分叉时 ff 失败被静默吞(syncStatus=failed),
|
|
@@ -93,12 +82,12 @@ export function computeBaseSync(cwd, baseHash) {
|
|
|
93
82
|
const diag = { status: 'unknown', defaultBranch: null, behind: 0, ahead: 0 };
|
|
94
83
|
// 推断默认分支:优先 origin/HEAD 指向的真实分支名(修复旧 339-342 的运算符优先级 bug
|
|
95
84
|
// ——旧代码在 origin/HEAD 存在时恒返回 'main',丢弃 symbolic-ref 的真实结果)。
|
|
96
|
-
const headRef = gitQuiet(cwd, 'symbolic-ref refs/remotes/origin/HEAD --short');
|
|
85
|
+
const headRef = gitQuiet(cwd, ['symbolic-ref', 'refs/remotes/origin/HEAD', '--short']);
|
|
97
86
|
let defaultBranch = headRef ? headRef.replace('origin/', '') : null;
|
|
98
87
|
if (!defaultBranch) {
|
|
99
88
|
// fallback:origin/HEAD 未设置时,探测常见默认分支是否存在
|
|
100
89
|
for (const cand of ['main', 'master']) {
|
|
101
|
-
if (gitQuiet(cwd,
|
|
90
|
+
if (gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${cand}`])) {
|
|
102
91
|
defaultBranch = cand;
|
|
103
92
|
break;
|
|
104
93
|
}
|
|
@@ -108,15 +97,15 @@ export function computeBaseSync(cwd, baseHash) {
|
|
|
108
97
|
diag.defaultBranch = defaultBranch;
|
|
109
98
|
|
|
110
99
|
// best-effort fetch(只更新 remote-tracking,不改工作区/HEAD/分支;失败静默降级用缓存)
|
|
111
|
-
gitQuiet(cwd, 'fetch origin --quiet');
|
|
112
|
-
const remoteHead = gitQuiet(cwd,
|
|
100
|
+
gitQuiet(cwd, ['fetch', 'origin', '--quiet'], { timeout: 60000 });
|
|
101
|
+
const remoteHead = gitQuiet(cwd, ['rev-parse', '--verify', '--quiet', `refs/remotes/origin/${defaultBranch}`]);
|
|
113
102
|
if (!baseHash || !remoteHead) return diag;
|
|
114
103
|
if (baseHash === remoteHead) {
|
|
115
104
|
diag.status = 'up-to-date';
|
|
116
105
|
return diag;
|
|
117
106
|
}
|
|
118
|
-
const behind = Number(gitQuiet(cwd,
|
|
119
|
-
const ahead = Number(gitQuiet(cwd,
|
|
107
|
+
const behind = Number(gitQuiet(cwd, ['rev-list', '--count', `${baseHash}..${remoteHead}`]) || 0);
|
|
108
|
+
const ahead = Number(gitQuiet(cwd, ['rev-list', '--count', `${remoteHead}..${baseHash}`]) || 0);
|
|
120
109
|
diag.behind = behind;
|
|
121
110
|
diag.ahead = ahead;
|
|
122
111
|
diag.status = (behind > 0 && ahead > 0) ? 'diverged' : (behind > 0 ? 'behind' : 'ahead');
|
|
@@ -179,10 +168,10 @@ export function computeBaselineHash(cwd) {
|
|
|
179
168
|
// - docs/:文档(非代码交付物)
|
|
180
169
|
// - CLAUDE.md:根 agent 指引(多操作者常改)
|
|
181
170
|
// 必须和 applyWorktree step 4.5 (worktree-apply.js) 使用相同的排除规则。
|
|
182
|
-
const exclude = '-- .
|
|
183
|
-
const staged = gitQuiet(cwd,
|
|
184
|
-
const unstaged = gitQuiet(cwd,
|
|
185
|
-
const untracked = gitQuiet(cwd,
|
|
171
|
+
const exclude = ['--', '.', ':(exclude).sillyspec/', ':(exclude).claude/', ':(exclude)docs/', ':(exclude)CLAUDE.md'];
|
|
172
|
+
const staged = gitQuiet(cwd, ['diff', '--cached', ...exclude], { timeout: 30000 }) || '';
|
|
173
|
+
const unstaged = gitQuiet(cwd, ['diff', ...exclude], { timeout: 30000 }) || '';
|
|
174
|
+
const untracked = gitQuiet(cwd, ['ls-files', '--others', '--exclude-standard', ...exclude], { timeout: 30000 }) || '';
|
|
186
175
|
const raw = `staged:${staged}
|
|
187
176
|
unstaged:${unstaged}
|
|
188
177
|
untracked:${untracked}`;
|
|
@@ -208,7 +197,7 @@ function validateChangeName(changeName) {
|
|
|
208
197
|
*/
|
|
209
198
|
export function isGitWorktreeSupported(cwd = process.cwd()) {
|
|
210
199
|
try {
|
|
211
|
-
const raw =
|
|
200
|
+
const raw = git(cwd, ['--version']);
|
|
212
201
|
const match = raw.match(/git version (\d+)\.(\d+)/);
|
|
213
202
|
if (!match) return { supported: false, version: raw, reason: 'cannot parse version' };
|
|
214
203
|
const major = parseInt(match[1], 10);
|
|
@@ -222,6 +211,31 @@ export function isGitWorktreeSupported(cwd = process.cwd()) {
|
|
|
222
211
|
}
|
|
223
212
|
}
|
|
224
213
|
|
|
214
|
+
/**
|
|
215
|
+
* 复制单个 untracked 条目到 worktree(baseline overlay 用)。
|
|
216
|
+
*
|
|
217
|
+
* 防御目录:untracked 项若是目录(如 Claude Code agent worktree 隔离目录 .worktrees/<hash>/),
|
|
218
|
+
* readFileSync 会 EISDIR → 整个 overlay fail-fast。目录跳过(overlay 只同步文件层未提交改动,
|
|
219
|
+
* 目录结构由后续文件写入的 mkdirSync 重建)。坑 execute-worktree-overlay-untracked-dir-eisdir。
|
|
220
|
+
*
|
|
221
|
+
* @param {string} src 主仓库源路径
|
|
222
|
+
* @param {string} dst worktree 目标路径
|
|
223
|
+
* @returns {{status:'copied'|'skipped-dir'|'missing'|'error', error?:string}}
|
|
224
|
+
*/
|
|
225
|
+
export function copyUntrackedEntry(src, dst) {
|
|
226
|
+
if (!existsSync(src)) return { status: 'missing' };
|
|
227
|
+
let st;
|
|
228
|
+
try { st = statSync(src); } catch { return { status: 'missing' }; }
|
|
229
|
+
if (st.isDirectory()) return { status: 'skipped-dir' };
|
|
230
|
+
try {
|
|
231
|
+
mkdirSync(dirname(dst), { recursive: true });
|
|
232
|
+
writeFileSync(dst, readFileSync(src));
|
|
233
|
+
return { status: 'copied' };
|
|
234
|
+
} catch (e) {
|
|
235
|
+
return { status: 'error', error: e.message };
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
225
239
|
export class WorktreeManager {
|
|
226
240
|
constructor({ cwd, worktreeDir } = {}) {
|
|
227
241
|
this.cwd = cwd || process.cwd();
|
|
@@ -254,7 +268,7 @@ export class WorktreeManager {
|
|
|
254
268
|
// existsSync/statSync 会相对 process.cwd() 解析——当 process.cwd 是另一个 git 仓库
|
|
255
269
|
// (未 chdir 的脚本、或 CLI 在别处跑)时,worktreeBase 会错解析到 process.cwd 仓库,
|
|
256
270
|
// getMeta 读错位置返回 null。resolve() 对绝对参数原样返回、对相对参数相对 this.cwd 解析,两者皆稳。
|
|
257
|
-
const commonDir = gitQuiet(this.cwd, 'rev-parse --git-common-dir');
|
|
271
|
+
const commonDir = gitQuiet(this.cwd, ['rev-parse', '--git-common-dir']);
|
|
258
272
|
if (commonDir) {
|
|
259
273
|
const absCommonDir = resolve(this.cwd, commonDir);
|
|
260
274
|
if (existsSync(absCommonDir) && statSync(absCommonDir).isDirectory()) {
|
|
@@ -324,7 +338,7 @@ export class WorktreeManager {
|
|
|
324
338
|
// 只恢复 meta 引用,不触碰文件系统。
|
|
325
339
|
return this._recoverNativeWorktreeMeta(name, {
|
|
326
340
|
worktreePath: this.cwd,
|
|
327
|
-
branch: gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || 'detached',
|
|
341
|
+
branch: gitQuiet(this.cwd, ['symbolic-ref', '--short', 'HEAD']) || 'detached',
|
|
328
342
|
});
|
|
329
343
|
}
|
|
330
344
|
|
|
@@ -345,7 +359,7 @@ export class WorktreeManager {
|
|
|
345
359
|
// 否则会丢失 execute 期间未 commit 的代码(不可恢复,3.22.4 修复)。
|
|
346
360
|
if (!this.getMeta(name)) {
|
|
347
361
|
let uncommitted = '';
|
|
348
|
-
try { uncommitted = git(worktreePath, 'status --porcelain') } catch {}
|
|
362
|
+
try { uncommitted = git(worktreePath, ['status', '--porcelain'], { timeout: 30000 }) } catch {}
|
|
349
363
|
if (uncommitted.trim()) {
|
|
350
364
|
throw new Error(
|
|
351
365
|
`检测到幽灵 worktree(无 meta.json)但含未提交改动,拒绝自动清理(防丢代码)。\n` +
|
|
@@ -358,15 +372,15 @@ export class WorktreeManager {
|
|
|
358
372
|
try { rmSync(worktreePath, { recursive: true, force: true }); } catch {}
|
|
359
373
|
// 同步清理 git worktree 注册 + 残留分支,否则目录虽删但 git 内部状态未清,
|
|
360
374
|
// 后续 git worktree add 会因「worktree 已注册」或「分支已存在」失败
|
|
361
|
-
try { gitQuiet(this.cwd, 'worktree prune'); } catch {}
|
|
362
|
-
try { gitQuiet(this.cwd,
|
|
375
|
+
try { gitQuiet(this.cwd, ['worktree', 'prune'], { timeout: 30000 }); } catch {}
|
|
376
|
+
try { gitQuiet(this.cwd, ['branch', '-D', branch]); } catch {}
|
|
363
377
|
} else {
|
|
364
378
|
throw new Error(`worktree already exists: ${name}. Run cleanup first.`);
|
|
365
379
|
}
|
|
366
380
|
}
|
|
367
381
|
|
|
368
382
|
// 2. 检查分支是否已存在
|
|
369
|
-
if (gitQuiet(this.cwd,
|
|
383
|
+
if (gitQuiet(this.cwd, ['rev-parse', '--verify', `refs/heads/${branch}`])) {
|
|
370
384
|
throw new Error(`branch already exists: ${branch}. Run cleanup first.`);
|
|
371
385
|
}
|
|
372
386
|
|
|
@@ -374,11 +388,11 @@ export class WorktreeManager {
|
|
|
374
388
|
let baseBranch = base;
|
|
375
389
|
let baseHash;
|
|
376
390
|
if (baseBranch) {
|
|
377
|
-
baseHash = git(this.cwd,
|
|
391
|
+
baseHash = git(this.cwd, ['rev-parse', baseBranch]);
|
|
378
392
|
} else {
|
|
379
393
|
// 默认用当前 HEAD
|
|
380
|
-
baseBranch = gitQuiet(this.cwd,
|
|
381
|
-
baseHash = git(this.cwd,
|
|
394
|
+
baseBranch = gitQuiet(this.cwd, ['symbolic-ref', '--short', 'HEAD']) || git(this.cwd, ['rev-parse', 'HEAD']);
|
|
395
|
+
baseHash = git(this.cwd, ['rev-parse', 'HEAD']);
|
|
382
396
|
}
|
|
383
397
|
|
|
384
398
|
// 4. 创建 worktree 根目录
|
|
@@ -391,12 +405,12 @@ export class WorktreeManager {
|
|
|
391
405
|
// 未切分支,直接写代码污染 main(见缺陷 execute-in-place-windows-pitfalls 坑1)。
|
|
392
406
|
// core.longpaths=true 让 git 用 \\?\ 前缀绕过 MAX_PATH,幂等、Windows 推荐、低风险,失败不阻断。
|
|
393
407
|
if (process.platform === 'win32') {
|
|
394
|
-
try { gitQuiet(this.cwd, 'config core.longpaths true'); } catch {}
|
|
408
|
+
try { gitQuiet(this.cwd, ['config', 'core.longpaths', 'true']); } catch {}
|
|
395
409
|
}
|
|
396
410
|
|
|
397
411
|
// 5. 创建 worktree(含版本检测 + sandbox fallback)
|
|
398
412
|
try {
|
|
399
|
-
git(this.cwd,
|
|
413
|
+
git(this.cwd, ['worktree', 'add', worktreePath, '-b', branch, baseHash], { timeout: 120000 });
|
|
400
414
|
} catch (e) {
|
|
401
415
|
const check = isGitWorktreeSupported(this.cwd);
|
|
402
416
|
if (!check.supported) {
|
|
@@ -471,7 +485,7 @@ export class WorktreeManager {
|
|
|
471
485
|
branch,
|
|
472
486
|
baseBranch,
|
|
473
487
|
baseHash,
|
|
474
|
-
actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
|
|
488
|
+
actualBaseHash: gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || baseHash,
|
|
475
489
|
createdAt: new Date().toISOString(),
|
|
476
490
|
worktreePath,
|
|
477
491
|
mode: 'worktree',
|
|
@@ -499,7 +513,7 @@ export class WorktreeManager {
|
|
|
499
513
|
* @private
|
|
500
514
|
*/
|
|
501
515
|
_recoverNativeWorktreeMeta(name, { worktreePath, branch }) {
|
|
502
|
-
const baseHash = gitQuiet(worktreePath, 'rev-parse HEAD') || null
|
|
516
|
+
const baseHash = gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || null
|
|
503
517
|
const meta = {
|
|
504
518
|
name_zh: 'worktree 元数据',
|
|
505
519
|
changeName: name,
|
|
@@ -552,15 +566,15 @@ export class WorktreeManager {
|
|
|
552
566
|
if (resolvedSource === resolvedTarget) {
|
|
553
567
|
console.warn('⚠️ 跳过 baseline overlay:当前目录与目标目录相同(native-worktree 或 in-place 模式)')
|
|
554
568
|
// 写 meta 但不 overlay
|
|
555
|
-
baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD')
|
|
556
|
-
baseHash = baseHash || git(this.cwd, 'rev-parse HEAD')
|
|
569
|
+
baseBranch = baseBranch || gitQuiet(this.cwd, ['symbolic-ref', '--short', 'HEAD']) || gitQuiet(this.cwd, ['rev-parse', 'HEAD'])
|
|
570
|
+
baseHash = baseHash || git(this.cwd, ['rev-parse', 'HEAD'])
|
|
557
571
|
const meta = {
|
|
558
572
|
name_zh: 'worktree 元数据',
|
|
559
573
|
changeName: name,
|
|
560
574
|
branch: branch || BRANCH_PREFIX + name,
|
|
561
575
|
baseBranch,
|
|
562
576
|
baseHash,
|
|
563
|
-
actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
|
|
577
|
+
actualBaseHash: gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || baseHash,
|
|
564
578
|
createdAt: new Date().toISOString(),
|
|
565
579
|
worktreePath,
|
|
566
580
|
mode: mode || 'in-place-fallback',
|
|
@@ -578,8 +592,8 @@ export class WorktreeManager {
|
|
|
578
592
|
|
|
579
593
|
// 解析 base
|
|
580
594
|
if (!baseHash) {
|
|
581
|
-
baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD');
|
|
582
|
-
baseHash = git(this.cwd, 'rev-parse HEAD');
|
|
595
|
+
baseBranch = baseBranch || gitQuiet(this.cwd, ['symbolic-ref', '--short', 'HEAD']) || gitQuiet(this.cwd, ['rev-parse', 'HEAD']);
|
|
596
|
+
baseHash = git(this.cwd, ['rev-parse', 'HEAD']);
|
|
583
597
|
}
|
|
584
598
|
|
|
585
599
|
const baselineResult = this._overlayBaseline(this.cwd, this.cwd);
|
|
@@ -597,7 +611,7 @@ export class WorktreeManager {
|
|
|
597
611
|
branch: branch || BRANCH_PREFIX + name,
|
|
598
612
|
baseBranch,
|
|
599
613
|
baseHash,
|
|
600
|
-
actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
|
|
614
|
+
actualBaseHash: gitQuiet(worktreePath, ['rev-parse', 'HEAD']) || baseHash,
|
|
601
615
|
createdAt: new Date().toISOString(),
|
|
602
616
|
worktreePath,
|
|
603
617
|
mode: mode || 'in-place-fallback',
|
|
@@ -622,7 +636,7 @@ export class WorktreeManager {
|
|
|
622
636
|
}
|
|
623
637
|
|
|
624
638
|
/**
|
|
625
|
-
* 构建 isolation 信息对象,用于写入
|
|
639
|
+
* 构建 isolation 信息对象,用于写入 sillyspec.db(_writeIsolationToDB,isolation_status 列)
|
|
626
640
|
* @param {string} changeName
|
|
627
641
|
* @returns {{ status: string, mode: string, path: string } | null}
|
|
628
642
|
*/
|
|
@@ -724,8 +738,14 @@ export class WorktreeManager {
|
|
|
724
738
|
if (!isInPlace && existsSync(worktreePath)) {
|
|
725
739
|
const wtNodeModules = join(worktreePath, 'node_modules');
|
|
726
740
|
if (existsSync(wtNodeModules)) {
|
|
727
|
-
|
|
728
|
-
|
|
741
|
+
// lstat 失败 fail-loud(D-001@v1 / review-2026-08-09 #4):EPERM(杀毒/索引锁 junction)若静默跳过解链,
|
|
742
|
+
// 后续 git worktree remove / rmSync recursive 会跟随 junction 误删主仓 node_modules
|
|
743
|
+
let isLink;
|
|
744
|
+
try {
|
|
745
|
+
isLink = lstatSync(wtNodeModules).isSymbolicLink();
|
|
746
|
+
} catch (e) {
|
|
747
|
+
throw new Error(`worktree node_modules junction 检测失败(疑似 EPERM:杀毒/索引占用),阻断 cleanup 保护主仓 node_modules:${e.message}。请关闭占用进程或手动 rmdir "${wtNodeModules}" 后重试 sillyspec worktree cleanup`);
|
|
748
|
+
}
|
|
729
749
|
if (isLink) {
|
|
730
750
|
try {
|
|
731
751
|
if (process.platform === 'win32') {
|
|
@@ -736,7 +756,7 @@ export class WorktreeManager {
|
|
|
736
756
|
}
|
|
737
757
|
details.push('worktree node_modules junction/symlink removed (protect main checkout)');
|
|
738
758
|
} catch (e) {
|
|
739
|
-
|
|
759
|
+
throw new Error(`worktree node_modules junction 解链失败,阻断 cleanup 保护主仓 node_modules:${e.message}。请手动 rmdir "${wtNodeModules}" 后重试`);
|
|
740
760
|
}
|
|
741
761
|
}
|
|
742
762
|
}
|
|
@@ -747,7 +767,7 @@ export class WorktreeManager {
|
|
|
747
767
|
if (!isInPlace && existsSync(worktreePath)) {
|
|
748
768
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
749
769
|
try {
|
|
750
|
-
git(this.cwd,
|
|
770
|
+
git(this.cwd, ['worktree', 'remove', worktreePath, '--force'], { timeout: 60000 });
|
|
751
771
|
gitRemoveOk = true;
|
|
752
772
|
details.push(`git worktree remove succeeded (attempt ${attempt})`);
|
|
753
773
|
break;
|
|
@@ -773,14 +793,14 @@ export class WorktreeManager {
|
|
|
773
793
|
|
|
774
794
|
// 3. git worktree prune(清理 git 内部注册信息)
|
|
775
795
|
try {
|
|
776
|
-
gitQuiet(this.cwd, 'worktree prune');
|
|
796
|
+
gitQuiet(this.cwd, ['worktree', 'prune'], { timeout: 30000 });
|
|
777
797
|
} catch {
|
|
778
798
|
// prune 失败不阻断
|
|
779
799
|
}
|
|
780
800
|
|
|
781
801
|
// 4. 删除分支(忽略分支不存在的错误)
|
|
782
802
|
try {
|
|
783
|
-
gitQuiet(this.cwd,
|
|
803
|
+
gitQuiet(this.cwd, ['branch', '-D', branch]);
|
|
784
804
|
details.push('branch deleted');
|
|
785
805
|
} catch {
|
|
786
806
|
// 分支可能已被删除,幂等跳过
|
|
@@ -800,7 +820,7 @@ export class WorktreeManager {
|
|
|
800
820
|
const residual = [];
|
|
801
821
|
if (!isInPlace && existsSync(worktreePath)) residual.push(`worktree dir: ${worktreePath}`);
|
|
802
822
|
if (existsSync(metaDir)) residual.push(`meta dir: ${metaDir}`);
|
|
803
|
-
if (!isInPlace && gitQuiet(this.cwd,
|
|
823
|
+
if (!isInPlace && gitQuiet(this.cwd, ['worktree', 'list'], { timeout: 30000 })?.includes(worktreePath)) {
|
|
804
824
|
residual.push('git worktree list still references this worktree');
|
|
805
825
|
}
|
|
806
826
|
if (residual.length > 0) {
|
|
@@ -832,19 +852,79 @@ export class WorktreeManager {
|
|
|
832
852
|
* @param {{ fix?: boolean, staleHours?: number }} opts
|
|
833
853
|
* @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
|
|
834
854
|
*/
|
|
855
|
+
/**
|
|
856
|
+
* doctor --fix 的依赖重供给:先解 worktree/node_modules junction(保护主仓 node_modules),
|
|
857
|
+
* 再 provisionDeps(force:true) 强制走 install 分支重装。
|
|
858
|
+
*
|
|
859
|
+
* Windows 保护(与 cleanup 722-743 同源坑):不解链直接 install 会经 junction 误改主仓
|
|
860
|
+
* node_modules 内容。in-place-fallback 无独立 node_modules(worktreePath 即主仓),跳过解链,
|
|
861
|
+
* 仅 install。force=true 绕过 provisionDeps 的 lockfile 一致快路径,确保真重装而非幂等 linked。
|
|
862
|
+
*
|
|
863
|
+
* @param {string} name - changeName
|
|
864
|
+
* @param {string} wtPath - worktree 根目录(meta.worktreePath)
|
|
865
|
+
* @returns {{ ok: boolean, msg: string }}
|
|
866
|
+
*/
|
|
835
867
|
_doctorReprovision(name, wtPath) {
|
|
836
868
|
try {
|
|
837
|
-
const deps = provisionDeps(wtPath, this.cwd, { specBase: join(this.cwd, '.sillyspec') }) || {};
|
|
838
|
-
const metaPath = join(this.getWorktreePath(name), META_FILE);
|
|
839
869
|
const meta = this.getMeta(name) || {};
|
|
840
|
-
|
|
870
|
+
const isInPlace = meta.mode === 'in-place-fallback';
|
|
871
|
+
// 先解 junction(非 in-place):lstatSync 判 link → Windows rmdir junction / Unix unlinkSync
|
|
872
|
+
if (!isInPlace && existsSync(wtPath)) {
|
|
873
|
+
const wtNodeModules = join(wtPath, 'node_modules');
|
|
874
|
+
if (existsSync(wtNodeModules)) {
|
|
875
|
+
// lstat 失败 fail-loud(D-001@v1)+ 解链失败 fail-loud(D-002@v1 / review-2026-08-09 #4):
|
|
876
|
+
// 原 best-effort「交 provisionDeps install」会经 junction 误改主仓 node_modules,正是 #4 坑
|
|
877
|
+
let isLink;
|
|
878
|
+
try {
|
|
879
|
+
isLink = lstatSync(wtNodeModules).isSymbolicLink();
|
|
880
|
+
} catch (e) {
|
|
881
|
+
throw new Error(`worktree node_modules junction 检测失败(疑似 EPERM:杀毒/索引占用),阻断 doctor reprovision 保护主仓 node_modules:${e.message}。请关闭占用进程或手动 rmdir "${wtNodeModules}" 后重试`);
|
|
882
|
+
}
|
|
883
|
+
if (isLink) {
|
|
884
|
+
try {
|
|
885
|
+
if (process.platform === 'win32') {
|
|
886
|
+
execSync(`rmdir "${wtNodeModules}"`, { shell: 'cmd.exe' });
|
|
887
|
+
} else {
|
|
888
|
+
unlinkSync(wtNodeModules);
|
|
889
|
+
}
|
|
890
|
+
} catch (e) {
|
|
891
|
+
throw new Error(`worktree node_modules junction 解链失败,阻断 doctor reprovision(不调 provisionDeps 避免经 junction 误改主仓):${e.message}。请手动 rmdir "${wtNodeModules}" 后重试`);
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
const deps = provisionDeps(wtPath, this.cwd, {
|
|
897
|
+
specBase: join(this.cwd, '.sillyspec'),
|
|
898
|
+
force: true,
|
|
899
|
+
}) || {};
|
|
900
|
+
const metaPath = join(this.getWorktreePath(name), META_FILE);
|
|
901
|
+
// 成功重供时清掉旧 depsError(provisionDeps 成功结果不含该键,{...meta,...deps} 会残留旧值)
|
|
902
|
+
const merged = { ...meta, ...deps };
|
|
903
|
+
if (deps.depsStatus !== 'failed') delete merged.depsError;
|
|
904
|
+
writeMetaAtomic(metaPath, merged);
|
|
841
905
|
return { ok: true, msg: `re-provisioned ${name}: depsStatus=${deps.depsStatus}` };
|
|
842
906
|
} catch (e) {
|
|
843
907
|
return { ok: false, msg: `re-provision failed for ${name}: ${e.message}` };
|
|
844
908
|
}
|
|
845
909
|
}
|
|
846
910
|
|
|
847
|
-
|
|
911
|
+
/**
|
|
912
|
+
* worktree 健康检查 + 可选修复
|
|
913
|
+
* 检查项:
|
|
914
|
+
* - git worktree list 中的孤儿条目(目录不存在)
|
|
915
|
+
* - worktree 目录存在但 git 不认识
|
|
916
|
+
* - meta 存在但 worktree 目录不存在
|
|
917
|
+
* - worktree 目录存在但 meta 不存在(幽灵目录)
|
|
918
|
+
* - SillySpec 分支残留(sillyspec/* 但无对应 meta)
|
|
919
|
+
* - 超过指定小时的过期 worktree
|
|
920
|
+
* - deps 依赖状态(failed/missing/stale/main-drift,复用 checkDepsFreshness 统一判定)
|
|
921
|
+
*
|
|
922
|
+
* @param {{ fix?: boolean, staleHours?: number, changeName?: string|null }} opts
|
|
923
|
+
* - changeName:非 null 时仅扫描该 change(deps/ghost/stale/orphan-git/orphan-branch 全部按 changeName 过滤);
|
|
924
|
+
* 不传则全量扫(兼容现有行为)。
|
|
925
|
+
* @returns {{ issues: Array<{ type: string, name: string, detail: string, fixable: boolean }>, fixed: string[], unfixable: string[] }}
|
|
926
|
+
*/
|
|
927
|
+
doctor({ fix = false, staleHours = 24, changeName = null } = {}) {
|
|
848
928
|
const issues = [];
|
|
849
929
|
const fixed = [];
|
|
850
930
|
const unfixable = [];
|
|
@@ -852,7 +932,7 @@ export class WorktreeManager {
|
|
|
852
932
|
// 1. 列出 git worktree list 中的条目
|
|
853
933
|
let gitWorktreeList = [];
|
|
854
934
|
try {
|
|
855
|
-
const raw =
|
|
935
|
+
const raw = git(this.cwd, ['worktree', 'list', '--porcelain'], { timeout: 30000 });
|
|
856
936
|
const entries = raw.split(/\n\n/).filter(Boolean);
|
|
857
937
|
for (const entry of entries) {
|
|
858
938
|
const lines = entry.split('\n');
|
|
@@ -880,11 +960,12 @@ export class WorktreeManager {
|
|
|
880
960
|
for (const wt of gitWorktreeList) {
|
|
881
961
|
if (!existsSync(wt.path)) {
|
|
882
962
|
const name = this._pathToChangeName(wt.path);
|
|
963
|
+
if (changeName && name !== changeName) continue; // --change 过滤:仅扫指定 change
|
|
883
964
|
if (wt.missing === true) {
|
|
884
965
|
// git 明确标记目录缺失(git 2.20+)→ 真 orphan,prune 安全
|
|
885
966
|
issues.push({ type: 'orphan-git-entry', name: name || wt.path, detail: `git worktree 标记 missing: ${wt.path}`, fixable: true });
|
|
886
967
|
if (fix) {
|
|
887
|
-
try { gitQuiet(this.cwd, 'worktree prune'); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
|
|
968
|
+
try { gitQuiet(this.cwd, ['worktree', 'prune'], { timeout: 30000 }); fixed.push(`pruned orphan: ${wt.path}`); } catch { unfixable.push(`prune failed for: ${wt.path}`); }
|
|
888
969
|
}
|
|
889
970
|
} else {
|
|
890
971
|
// 目录不可见但 git 未标记 missing → 可能 existsSync 误判,保守不自动 prune
|
|
@@ -901,25 +982,28 @@ export class WorktreeManager {
|
|
|
901
982
|
for (const entry of entries) {
|
|
902
983
|
if (!entry.isDirectory()) continue;
|
|
903
984
|
const name = entry.name;
|
|
985
|
+
if (changeName && name !== changeName) continue; // --change 过滤:仅扫指定 change
|
|
904
986
|
const dirPath = join(this.worktreeBase, name);
|
|
905
987
|
const hasMeta = existsSync(join(dirPath, META_FILE));
|
|
906
988
|
const meta = hasMeta ? this.getMeta(name) : null;
|
|
907
989
|
|
|
908
|
-
// deps 依赖状态检查(change 2026-06-28-worktree-deps-provision)
|
|
909
|
-
|
|
990
|
+
// deps 依赖状态检查(change 2026-06-28-worktree-deps-provision / 2026-08-05-tooling-feedback-fixes)
|
|
991
|
+
// 复用 checkDepsFreshness(H1,worktree-deps.js),优先级 failed→missing→stale→main-drift→fresh。
|
|
992
|
+
// 放宽原 909 in-place-fallback 守卫:in-place 也跑(main-drift/stale/failed 对它同样有意义,
|
|
993
|
+
// 真 in-place 的 wtPath===主仓→wtHash===mainHash→不会误报 main-drift,仅 failed/stale 有意义)。
|
|
994
|
+
// fix 时 _doctorReprovision 自行判断是否解链(in-place 不解链,仅 install)。
|
|
995
|
+
if (meta && meta.worktreePath && existsSync(meta.worktreePath)) {
|
|
910
996
|
const wtPath = meta.worktreePath;
|
|
911
|
-
const
|
|
912
|
-
const
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
if (depsIssue) {
|
|
922
|
-
issues.push({ type: depsIssue.type, name, detail: depsIssue.detail, fixable: true });
|
|
997
|
+
const fresh = checkDepsFreshness(meta, wtPath, this.cwd);
|
|
998
|
+
const issueTypeByStatus = {
|
|
999
|
+
failed: 'deps-failed',
|
|
1000
|
+
missing: 'deps-missing',
|
|
1001
|
+
stale: 'deps-stale',
|
|
1002
|
+
'main-drift': 'deps-main-drift',
|
|
1003
|
+
};
|
|
1004
|
+
const issueType = issueTypeByStatus[fresh.status] || null;
|
|
1005
|
+
if (issueType) {
|
|
1006
|
+
issues.push({ type: issueType, name, detail: fresh.detail, fixable: true });
|
|
923
1007
|
if (fix) {
|
|
924
1008
|
const r = this._doctorReprovision(name, wtPath);
|
|
925
1009
|
(r.ok ? fixed : unfixable).push(r.msg);
|
|
@@ -973,15 +1057,16 @@ export class WorktreeManager {
|
|
|
973
1057
|
|
|
974
1058
|
// 5. 检查 SillySpec 分支残留
|
|
975
1059
|
try {
|
|
976
|
-
const branches =
|
|
1060
|
+
const branches = execFileSync('git', ['branch', '--list', `${BRANCH_PREFIX}*`], { cwd: this.cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
|
|
977
1061
|
if (branches) {
|
|
978
1062
|
for (const line of branches.split('\n').filter(Boolean)) {
|
|
979
1063
|
const branch = line.replace(/^\*?\s+/, '').trim();
|
|
980
1064
|
const name = branch.replace(BRANCH_PREFIX, '');
|
|
1065
|
+
if (changeName && name !== changeName) continue; // --change 过滤:仅扫指定 change
|
|
981
1066
|
if (!metaNames.has(name)) {
|
|
982
1067
|
issues.push({ type: 'orphan-branch', name, detail: `分支残留(无对应 meta): ${branch}`, fixable: true });
|
|
983
1068
|
if (fix) {
|
|
984
|
-
try { gitQuiet(this.cwd,
|
|
1069
|
+
try { gitQuiet(this.cwd, ['branch', '-D', branch]); fixed.push(`deleted orphan branch: ${branch}`); } catch { unfixable.push(`branch delete failed: ${branch}`); }
|
|
985
1070
|
}
|
|
986
1071
|
}
|
|
987
1072
|
}
|
|
@@ -1028,9 +1113,9 @@ export class WorktreeManager {
|
|
|
1028
1113
|
const isDeliverable = f => f && !f.startsWith('.sillyspec/') && f !== 'meta.json';
|
|
1029
1114
|
try {
|
|
1030
1115
|
// 1) 候选交付变更(worktree 工作区相对 diffBase)。--no-renames:rename 退化成 D+A,两侧文件都进集
|
|
1031
|
-
const tracked = (gitQuiet(worktreePath,
|
|
1116
|
+
const tracked = (gitQuiet(worktreePath, ['diff', '--no-renames', '--name-only', diffBase], { timeout: 30000 }) || '')
|
|
1032
1117
|
.split('\n').filter(Boolean).filter(isDeliverable);
|
|
1033
|
-
const untracked = (gitQuiet(worktreePath,
|
|
1118
|
+
const untracked = (gitQuiet(worktreePath, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '')
|
|
1034
1119
|
.split('\n').filter(Boolean).filter(isDeliverable);
|
|
1035
1120
|
|
|
1036
1121
|
if (tracked.length === 0 && untracked.length === 0) {
|
|
@@ -1063,19 +1148,19 @@ export class WorktreeManager {
|
|
|
1063
1148
|
* @returns {string[]} 尚未落到 main HEAD 的文件
|
|
1064
1149
|
*/
|
|
1065
1150
|
_changesAlreadyOnMain(worktreePath, trackedFiles, untrackedFiles) {
|
|
1066
|
-
const mainHead = git(this.cwd, 'rev-parse HEAD'); // 失败即抛 → 外层 catch fail-safe
|
|
1151
|
+
const mainHead = git(this.cwd, ['rev-parse', 'HEAD']); // 失败即抛 → 外层 catch fail-safe
|
|
1067
1152
|
const pending = [];
|
|
1068
1153
|
|
|
1069
1154
|
if (trackedFiles.length > 0) {
|
|
1070
1155
|
const diverged = (gitQuiet(worktreePath,
|
|
1071
|
-
|
|
1156
|
+
['diff', '--no-renames', '--name-only', mainHead, '--', ...trackedFiles], { timeout: 30000 }) || '')
|
|
1072
1157
|
.split('\n').filter(Boolean);
|
|
1073
1158
|
pending.push(...diverged);
|
|
1074
1159
|
}
|
|
1075
1160
|
|
|
1076
1161
|
if (untrackedFiles.length > 0) {
|
|
1077
1162
|
// hash-object 按 argv 顺序逐行输出 blob hash;某文件不存在则整命令失败 → gitQuiet 返回 null
|
|
1078
|
-
const wtHashes = (gitQuiet(worktreePath,
|
|
1163
|
+
const wtHashes = (gitQuiet(worktreePath, ['hash-object', '--', ...untrackedFiles], { timeout: 30000 }) || '')
|
|
1079
1164
|
.split('\n');
|
|
1080
1165
|
const mainBlobs = this._lsTreeBlobs(this.cwd, 'HEAD', untrackedFiles);
|
|
1081
1166
|
for (let i = 0; i < untrackedFiles.length; i++) {
|
|
@@ -1101,7 +1186,7 @@ export class WorktreeManager {
|
|
|
1101
1186
|
_lsTreeBlobs(cwd, treeish, files) {
|
|
1102
1187
|
const map = new Map();
|
|
1103
1188
|
if (!files || files.length === 0) return map; // 空 pathspec 会列整棵树,必须拦
|
|
1104
|
-
const raw = gitQuiet(cwd,
|
|
1189
|
+
const raw = gitQuiet(cwd, ['ls-tree', treeish, '--', ...files]);
|
|
1105
1190
|
if (!raw) return map;
|
|
1106
1191
|
for (const line of raw.split('\n')) {
|
|
1107
1192
|
if (!line) continue;
|
|
@@ -1121,8 +1206,13 @@ export class WorktreeManager {
|
|
|
1121
1206
|
try {
|
|
1122
1207
|
const resolved = resolve(wtPath);
|
|
1123
1208
|
const baseResolved = resolve(this.worktreeBase);
|
|
1124
|
-
|
|
1125
|
-
|
|
1209
|
+
// 用 path.relative 比 startsWith(base + sep) 更稳:
|
|
1210
|
+
// - 自动用当前平台分隔符比较(Windows 上 resolve 返回反斜杠,硬编码 '/' 恒 false)
|
|
1211
|
+
// - 跨盘符(C: vs D:)时返回绝对路径 → isAbsolute 守卫放行失败兜底 null
|
|
1212
|
+
// - 同路径返回 '' → 视作非 changeName
|
|
1213
|
+
const rel = relative(baseResolved, resolved);
|
|
1214
|
+
if (rel && !rel.startsWith('..') && !isAbsolute(rel)) {
|
|
1215
|
+
return rel;
|
|
1126
1216
|
}
|
|
1127
1217
|
} catch {}
|
|
1128
1218
|
return null;
|
|
@@ -1142,16 +1232,21 @@ export class WorktreeManager {
|
|
|
1142
1232
|
|
|
1143
1233
|
try {
|
|
1144
1234
|
// staged 变更
|
|
1145
|
-
const staged = gitQuiet(mainCwd, 'diff --cached --name-only') || '';
|
|
1235
|
+
const staged = gitQuiet(mainCwd, ['diff', '--cached', '--name-only'], { timeout: 30000 }) || '';
|
|
1146
1236
|
if (staged) {
|
|
1147
1237
|
try {
|
|
1148
1238
|
// 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
|
|
1149
|
-
const patchBuf =
|
|
1239
|
+
const patchBuf = execFileSync('git', ['diff', '--cached', '--binary'], { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
|
|
1150
1240
|
if (patchBuf && patchBuf.length > 0) {
|
|
1151
1241
|
const patchFile = join(worktreePath, '.sillyspec-baseline-staged.patch');
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1242
|
+
try {
|
|
1243
|
+
writeFileSync(patchFile, patchBuf);
|
|
1244
|
+
git(worktreePath, ['apply', '--binary', patchFile], { timeout: 30000 });
|
|
1245
|
+
} finally {
|
|
1246
|
+
// git apply 抛错时也要清掉 patch 临时文件,避免泄漏到 worktree/主仓根
|
|
1247
|
+
// (filterDeliverableFiles 不排除 .sillyspec-baseline-*,泄漏会级联 BLOCKED 下游 apply)
|
|
1248
|
+
try { rmSync(patchFile, { force: true }); } catch {}
|
|
1249
|
+
}
|
|
1155
1250
|
}
|
|
1156
1251
|
} catch (e) {
|
|
1157
1252
|
errors.push(`staged: ${e.message}`);
|
|
@@ -1160,16 +1255,20 @@ export class WorktreeManager {
|
|
|
1160
1255
|
}
|
|
1161
1256
|
|
|
1162
1257
|
// unstaged 变更
|
|
1163
|
-
const unstaged = gitQuiet(mainCwd, 'diff --name-only') || '';
|
|
1258
|
+
const unstaged = gitQuiet(mainCwd, ['diff', '--name-only'], { timeout: 30000 }) || '';
|
|
1164
1259
|
if (unstaged) {
|
|
1165
1260
|
try {
|
|
1166
1261
|
// 用 Buffer 模式读取,避免二进制 patch 被 UTF-8 解码损坏
|
|
1167
|
-
const patchBuf =
|
|
1262
|
+
const patchBuf = execFileSync('git', ['diff', '--binary'], { cwd: mainCwd, stdio: ['pipe','pipe','pipe'] });
|
|
1168
1263
|
if (patchBuf && patchBuf.length > 0) {
|
|
1169
1264
|
const patchFile = join(worktreePath, '.sillyspec-baseline-unstaged.patch');
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1265
|
+
try {
|
|
1266
|
+
writeFileSync(patchFile, patchBuf);
|
|
1267
|
+
git(worktreePath, ['apply', '--binary', patchFile], { timeout: 30000 });
|
|
1268
|
+
} finally {
|
|
1269
|
+
// git apply 抛错时也要清掉 patch 临时文件,避免泄漏到 worktree/主仓根
|
|
1270
|
+
try { rmSync(patchFile, { force: true }); } catch {}
|
|
1271
|
+
}
|
|
1173
1272
|
}
|
|
1174
1273
|
} catch (e) {
|
|
1175
1274
|
errors.push(`unstaged: ${e.message}`);
|
|
@@ -1177,18 +1276,20 @@ export class WorktreeManager {
|
|
|
1177
1276
|
files.push(...unstaged.split('\n').filter(Boolean));
|
|
1178
1277
|
}
|
|
1179
1278
|
|
|
1180
|
-
// untracked 文件(排除 .sillyspec/.runtime
|
|
1181
|
-
const untracked = gitQuiet(mainCwd, 'ls-files --others --exclude-standard') || '';
|
|
1279
|
+
// untracked 文件(排除 .sillyspec/.runtime 等);目录跳过避免 readFileSync EISDIR
|
|
1280
|
+
const untracked = gitQuiet(mainCwd, ['ls-files', '--others', '--exclude-standard'], { timeout: 30000 }) || '';
|
|
1281
|
+
const skippedDirs = [];
|
|
1182
1282
|
if (untracked) {
|
|
1183
1283
|
for (const f of untracked.split('\n').filter(Boolean)) {
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
if (
|
|
1187
|
-
|
|
1188
|
-
try { writeFileSync(dst, readFileSync(src)); files.push(f); } catch (e) { errors.push(`untracked ${f}: ${e.message}`); }
|
|
1189
|
-
}
|
|
1284
|
+
const r = copyUntrackedEntry(join(mainCwd, f), join(worktreePath, f));
|
|
1285
|
+
if (r.status === 'copied') files.push(f);
|
|
1286
|
+
else if (r.status === 'skipped-dir') skippedDirs.push(f);
|
|
1287
|
+
else if (r.status === 'error') errors.push(`untracked ${f}: ${r.error}`);
|
|
1190
1288
|
}
|
|
1191
1289
|
}
|
|
1290
|
+
if (skippedDirs.length > 0) {
|
|
1291
|
+
console.log(`ℹ️ baseline overlay 跳过 ${skippedDirs.length} 个 untracked 目录(不读目录避免 EISDIR,坑 execute-worktree-overlay-untracked-dir-eisdir)`);
|
|
1292
|
+
}
|
|
1192
1293
|
|
|
1193
1294
|
if (files.length > 0) {
|
|
1194
1295
|
console.log(`📁 baseline overlay: ${files.length} 个未提交文件已同步到 worktree`);
|
|
@@ -1234,21 +1335,22 @@ export class WorktreeManager {
|
|
|
1234
1335
|
GIT_COMMITTER_EMAIL: 'sillyspec@baseline',
|
|
1235
1336
|
};
|
|
1236
1337
|
try {
|
|
1237
|
-
|
|
1338
|
+
git(worktreePath, ['add', '-A'], { timeout: 30000 });
|
|
1238
1339
|
// 检查是否有实际变更(可能 overlay 后和 HEAD 完全一致)
|
|
1239
|
-
const status = gitQuiet(worktreePath, 'status --porcelain');
|
|
1340
|
+
const status = gitQuiet(worktreePath, ['status', '--porcelain'], { timeout: 30000 });
|
|
1240
1341
|
if (!status) {
|
|
1241
|
-
return gitQuiet(worktreePath, 'rev-parse HEAD');
|
|
1342
|
+
return gitQuiet(worktreePath, ['rev-parse', 'HEAD']);
|
|
1242
1343
|
}
|
|
1243
1344
|
// --no-verify:baseline 是锚点不是交付物,只是把主仓库 dirty 文件快照到 worktree
|
|
1244
1345
|
// 分支上以便区分「前置 baseline」与「子代理新增改动」。它不该触发项目 pre-commit
|
|
1245
1346
|
// hook(如 ruff format),否则主仓库 dirty 文件中任一不达标的会被 hook reformat
|
|
1246
1347
|
// 致 commit 失败 → worktree 创建失败 → execute 无法启动。
|
|
1247
|
-
|
|
1248
|
-
|
|
1348
|
+
execFileSync(
|
|
1349
|
+
'git',
|
|
1350
|
+
['commit', '--no-verify', '-m', `sillyspec: baseline checkpoint for ${changeName}`],
|
|
1249
1351
|
{ cwd: worktreePath, encoding: 'utf8', stdio: ['pipe','pipe','pipe'], env }
|
|
1250
1352
|
);
|
|
1251
|
-
const hash = git(worktreePath, 'rev-parse HEAD');
|
|
1353
|
+
const hash = git(worktreePath, ['rev-parse', 'HEAD']);
|
|
1252
1354
|
console.log(`📌 baseline checkpoint: ${hash}`);
|
|
1253
1355
|
return hash;
|
|
1254
1356
|
} catch (e) {
|