sillyspec 3.11.8 → 3.11.9
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/docs/worktree-isolation.md +197 -0
- package/package.json +1 -1
- package/src/change-list.js +52 -0
- package/src/hooks/claude-pre-tool-use.cjs +125 -0
- package/src/hooks/worktree-guard.js +453 -0
- package/src/index.js +115 -0
- package/src/progress.js +57 -1
- package/src/stages/execute.js +57 -11
- package/src/stages/quick.js +42 -3
- package/src/worktree-apply.js +266 -0
- package/src/worktree.js +226 -0
package/src/stages/execute.js
CHANGED
|
@@ -47,6 +47,24 @@ const fixedPrefix = [
|
|
|
47
47
|
outputHint: '上下文摘要',
|
|
48
48
|
optional: false
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
name: '创建 worktree',
|
|
52
|
+
prompt: `为本次执行创建隔离的 git worktree。
|
|
53
|
+
|
|
54
|
+
### 操作
|
|
55
|
+
1. 运行 \`sillyspec worktree create <change-name>\`
|
|
56
|
+
2. 记录输出的 worktree 路径
|
|
57
|
+
3. 后续所有子代理的 cwd 设为该 worktree 路径
|
|
58
|
+
4. 如果创建失败 → 报错并停止(不要在无隔离状态下继续)
|
|
59
|
+
|
|
60
|
+
### 输出
|
|
61
|
+
worktree 路径 + 分支名
|
|
62
|
+
|
|
63
|
+
### 完成后执行
|
|
64
|
+
sillyspec run execute --done --output "worktree 路径 + 分支名"`,
|
|
65
|
+
outputHint: 'worktree 路径 + 分支名',
|
|
66
|
+
optional: false
|
|
67
|
+
},
|
|
50
68
|
{
|
|
51
69
|
name: '确认执行范围',
|
|
52
70
|
prompt: `解析任务,确认执行范围和确认模式。
|
|
@@ -149,7 +167,7 @@ const fixedSuffix = [
|
|
|
149
167
|
prompt: `检查本轮执行产生的新知识。
|
|
150
168
|
|
|
151
169
|
### 操作
|
|
152
|
-
1. 检查
|
|
170
|
+
1. 检查 \.sillyspec/knowledge/uncategorized.md\` 中待确认条目
|
|
153
171
|
2. 如有 → 提示用户审阅
|
|
154
172
|
3. 用户确认后改为 [已确认],可归类到专题文件
|
|
155
173
|
|
|
@@ -162,18 +180,29 @@ const fixedSuffix = [
|
|
|
162
180
|
name: '完成确认',
|
|
163
181
|
prompt: `所有任务完成后的收尾。
|
|
164
182
|
|
|
165
|
-
###
|
|
166
|
-
1.
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
183
|
+
### 操作(有 worktree)
|
|
184
|
+
1. 运行 \`sillyspec worktree apply --check-only <change-name>\`
|
|
185
|
+
2. 展示 diff 摘要(文件列表 + 变更统计)
|
|
186
|
+
3. 检查结果说明(是否通过文件清单校验)
|
|
187
|
+
4. 用户确认后运行 \`sillyspec worktree apply <change-name>\`
|
|
188
|
+
5. apply 成功 → 自动 cleanup
|
|
189
|
+
6. apply 失败 → 展示错误详情,用户选择重试或手动处理
|
|
190
|
+
7. 如果用户不想 apply → 运行 \`sillyspec worktree cleanup <change-name>\` 丢弃
|
|
191
|
+
8. 建议下一步:\`sillyspec run verify\`
|
|
192
|
+
|
|
193
|
+
### 操作(无 worktree / --no-worktree 模式)
|
|
194
|
+
1. 跳过 apply 和 cleanup 步骤(因为没有 worktree)
|
|
195
|
+
2. 展示本次执行摘要
|
|
196
|
+
3. 提示用户直接使用 \`git diff\` 查看变更
|
|
197
|
+
4. 建议下一步:\`sillyspec run verify\`
|
|
170
198
|
|
|
171
199
|
### 输出
|
|
172
|
-
|
|
200
|
+
apply 结果 + 下一步建议(或执行摘要)
|
|
173
201
|
|
|
174
202
|
### 注意
|
|
203
|
+
- 如果用户不想 apply → 运行 cleanup 丢弃
|
|
175
204
|
- 完成后运行 \`sillyspec run execute --done\` 即可自动推进阶段`,
|
|
176
|
-
outputHint: '
|
|
205
|
+
outputHint: 'apply 结果',
|
|
177
206
|
optional: false
|
|
178
207
|
}
|
|
179
208
|
]
|
|
@@ -242,7 +271,7 @@ function parseWavesFromPlan(planContent) {
|
|
|
242
271
|
/**
|
|
243
272
|
* 为 Wave 生成 prompt(强制子代理执行)
|
|
244
273
|
*/
|
|
245
|
-
function buildWavePrompt(wave, waveIndex, changeDir) {
|
|
274
|
+
function buildWavePrompt(wave, waveIndex, changeDir, worktreePath) {
|
|
246
275
|
// 构建任务摘要(不再内联完整蓝图,减少上下文污染)
|
|
247
276
|
const taskSummary = wave.tasks.map((t, ti) => {
|
|
248
277
|
const taskNum = String(t.index || (ti + 1)).padStart(2, '0')
|
|
@@ -260,6 +289,17 @@ function buildWavePrompt(wave, waveIndex, changeDir) {
|
|
|
260
289
|
return s
|
|
261
290
|
}).join('\n')
|
|
262
291
|
|
|
292
|
+
const worktreeSection = (worktreePath)
|
|
293
|
+
? `
|
|
294
|
+
### 工作目录
|
|
295
|
+
你必须在以下 worktree 中工作(子代理的 cwd 设为此路径):
|
|
296
|
+
\`${worktreePath}\`
|
|
297
|
+
|
|
298
|
+
不要在主工作区修改源码文件。所有代码变更只在 worktree 中进行。
|
|
299
|
+
子代理的 cwd 参数设为 \`${worktreePath}\`。
|
|
300
|
+
`
|
|
301
|
+
: ''
|
|
302
|
+
|
|
263
303
|
return `## Wave ${waveIndex}: 执行以下任务
|
|
264
304
|
|
|
265
305
|
## 执行方式(必须严格遵守)
|
|
@@ -272,6 +312,7 @@ function buildWavePrompt(wave, waveIndex, changeDir) {
|
|
|
272
312
|
3. 勾选 plan.md 中的 checkbox
|
|
273
313
|
4. 记录改动文件和测试结果
|
|
274
314
|
|
|
315
|
+
${worktreeSection}
|
|
275
316
|
### 任务摘要(按需读取完整蓝图)
|
|
276
317
|
为每个任务启动子代理时,**只需告知任务目标和蓝图文件路径,让子代理按需读取**:
|
|
277
318
|
|
|
@@ -315,9 +356,11 @@ ${taskList}
|
|
|
315
356
|
/**
|
|
316
357
|
* 动态构建 execute 步骤列表
|
|
317
358
|
* @param {string|null} planFilePath - plan 文件路径,null 则用默认 3 Wave
|
|
359
|
+
* @param {{ worktreePath?: string, noWorktree?: boolean }} options
|
|
318
360
|
* @returns {Array} 步骤列表
|
|
319
361
|
*/
|
|
320
|
-
export function buildExecuteSteps(planFilePath = null) {
|
|
362
|
+
export function buildExecuteSteps(planFilePath = null, options = {}) {
|
|
363
|
+
const noWorktree = !!options.noWorktree
|
|
321
364
|
let waves
|
|
322
365
|
let changeDir = null
|
|
323
366
|
|
|
@@ -336,10 +379,13 @@ export function buildExecuteSteps(planFilePath = null) {
|
|
|
336
379
|
}
|
|
337
380
|
}
|
|
338
381
|
|
|
382
|
+
// 尝试获取 worktree 路径(可能由前缀步骤创建)
|
|
383
|
+
const worktreePath = options.worktreePath || null
|
|
384
|
+
|
|
339
385
|
const waveSteps = waves.map((wave, i) => ({
|
|
340
386
|
name: `Wave ${i + 1} 执行`,
|
|
341
387
|
mode: 'implementation',
|
|
342
|
-
prompt: buildWavePrompt(wave, i + 1, changeDir),
|
|
388
|
+
prompt: buildWavePrompt(wave, i + 1, changeDir, worktreePath),
|
|
343
389
|
outputHint: `Wave ${i + 1} 执行结果`,
|
|
344
390
|
optional: false
|
|
345
391
|
}))
|
package/src/stages/quick.js
CHANGED
|
@@ -20,7 +20,7 @@ export const definition = {
|
|
|
20
20
|
### 创建任务记录(必须执行)
|
|
21
21
|
理解完任务后,立即创建记录文件:
|
|
22
22
|
1. \`git config user.name\` 获取用户名
|
|
23
|
-
2. 无 \`--change\`:创建
|
|
23
|
+
2. 无 \`--change\`:创建 .sillyspec/quicklog/QUICKLOG-<git用户名>.md\`(已存在则追加),写入:
|
|
24
24
|
\`\`\`
|
|
25
25
|
## YYYY-MM-DD HH:mm:ss — <一句话任务描述>
|
|
26
26
|
状态:进行中
|
|
@@ -28,19 +28,40 @@ export const definition = {
|
|
|
28
28
|
\`\`\`
|
|
29
29
|
3. 有 \`--change\`:在 \`.sillyspec/changes/<change-name>/tasks.md\` 追加未勾选的 task
|
|
30
30
|
|
|
31
|
-
这样 Gate 检测到
|
|
31
|
+
这样 Gate 检测到 .sillyspec/\` 下有变更,就不会拦截后续的代码修改。
|
|
32
32
|
|
|
33
33
|
### 输出
|
|
34
34
|
任务理解 + 上下文摘要 + quicklog 已创建`,
|
|
35
35
|
outputHint: '任务理解',
|
|
36
36
|
optional: false
|
|
37
37
|
},
|
|
38
|
+
{
|
|
39
|
+
name: '创建 worktree',
|
|
40
|
+
prompt: `为本次 quick 任务创建隔离的 git worktree。
|
|
41
|
+
|
|
42
|
+
### 操作
|
|
43
|
+
1. 确定变更名(change name):
|
|
44
|
+
- 如携带 \`--change <变更名>\`,使用该变更名
|
|
45
|
+
- 否则,生成临时变更名:\`quick-<当前时间戳 YYYYMMDD-HHmmss>\`
|
|
46
|
+
2. 运行 \`sillyspec worktree create <变更名>\`
|
|
47
|
+
3. 记录输出的 worktree 路径(后续步骤需要使用)
|
|
48
|
+
4. 如果创建失败 → 报错并停止(不要在无隔离状态下继续)
|
|
49
|
+
|
|
50
|
+
### 输出
|
|
51
|
+
worktree 路径 + 变更名 + 分支名`,
|
|
52
|
+
outputHint: 'worktree 路径',
|
|
53
|
+
optional: false
|
|
54
|
+
},
|
|
38
55
|
{
|
|
39
56
|
name: '实现并验证',
|
|
40
57
|
prompt: `实现任务。
|
|
41
58
|
|
|
59
|
+
### 工作目录
|
|
60
|
+
你必须在 worktree 中工作(将子代理的 cwd 设为上一步记录的 worktree 路径)。
|
|
61
|
+
不要在主工作区修改源码文件。所有代码变更只在 worktree 中进行。
|
|
62
|
+
|
|
42
63
|
### 操作
|
|
43
|
-
1. 先读后写:调用已有方法前 \`cat\` 源文件确认签名,\`grep\`
|
|
64
|
+
1. 先读后写:调用已有方法前 \`cat\` 源文件确认签名,\`grep\` 确认方法存在(在 worktree 中读取)
|
|
44
65
|
2. 写代码完成任务
|
|
45
66
|
3. 如涉及逻辑变更,建议写单元测试验证(不强制,纯配置/文档/小改动可跳过)
|
|
46
67
|
4. **不要编译!** 除非用户明确要求或改动量很大
|
|
@@ -55,6 +76,24 @@ export const definition = {
|
|
|
55
76
|
outputHint: '实现摘要',
|
|
56
77
|
optional: false
|
|
57
78
|
},
|
|
79
|
+
{
|
|
80
|
+
name: 'apply 并 cleanup',
|
|
81
|
+
prompt: `将 worktree 中的变更应用到主工作区并清理。
|
|
82
|
+
|
|
83
|
+
### 操作
|
|
84
|
+
1. 运行 \`sillyspec worktree apply --check-only <变更名>\`
|
|
85
|
+
2. 展示 diff 摘要(文件列表 + 变更统计)
|
|
86
|
+
3. 展示检查结果(是否通过文件清单校验)
|
|
87
|
+
4. 用户确认后运行 \`sillyspec worktree apply <变更名>\`
|
|
88
|
+
5. apply 成功 → 自动 cleanup,进入下一步
|
|
89
|
+
6. apply 失败 → 展示错误详情,用户选择重试或手动处理
|
|
90
|
+
7. 如果用户不想 apply → 运行 \`sillyspec worktree cleanup <变更名>\` 丢弃变更
|
|
91
|
+
|
|
92
|
+
### 输出
|
|
93
|
+
apply 结果 + 下一步建议`,
|
|
94
|
+
outputHint: 'apply 结果',
|
|
95
|
+
optional: false
|
|
96
|
+
},
|
|
58
97
|
{
|
|
59
98
|
name: '暂存和更新记录',
|
|
60
99
|
prompt: `Git 暂存并更新任务记录。
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SillySpec applyWorktree — 将 worktree 中的变更应用到主工作区
|
|
3
|
+
*
|
|
4
|
+
* 流程:
|
|
5
|
+
* 1. 读取 meta.json 获取 baseHash
|
|
6
|
+
* 2. git diff --name-only baseHash 获取 worktree 中所有变更文件
|
|
7
|
+
* 3. 从 design.md 解析文件变更清单(无清单 = 允许所有)
|
|
8
|
+
* 4. 校验:变更文件 ⊆ 清单
|
|
9
|
+
* 5. 校验:主工作区文件 base hash 一致
|
|
10
|
+
* 6. --check-only 模式只输出检查结果
|
|
11
|
+
* 7. 非 checkOnly:生成 patch → apply --check → apply --3way
|
|
12
|
+
* 8. 成功后自动 cleanup
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { execSync } from 'child_process';
|
|
16
|
+
import { existsSync, unlinkSync, writeFileSync, mkdtempSync, rmSync } from 'fs';
|
|
17
|
+
import { join, resolve } from 'path';
|
|
18
|
+
import { tmpdir } from 'os';
|
|
19
|
+
import { WorktreeManager } from './worktree.js';
|
|
20
|
+
import { parseFileChangeList } from './change-list.js';
|
|
21
|
+
|
|
22
|
+
const CHANGES_REL = '.sillyspec/changes';
|
|
23
|
+
|
|
24
|
+
function git(cwd, args) {
|
|
25
|
+
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function gitQuiet(cwd, args) {
|
|
29
|
+
try {
|
|
30
|
+
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* 获取文件在 git 中的 blob hash(基于某个 commit/tree)
|
|
38
|
+
* @param {string} cwd - git 工作区路径
|
|
39
|
+
* @param {string} treeish - commit hash、分支等
|
|
40
|
+
* @param {string} filePath - 相对路径
|
|
41
|
+
* @returns {string|null} blob hash,文件不存在返回 null
|
|
42
|
+
*/
|
|
43
|
+
function getFileBlobHash(cwd, treeish, filePath) {
|
|
44
|
+
return gitQuiet(cwd, `rev-parse ${treeish}:${filePath}`);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* apply worktree 变更到主工作区
|
|
49
|
+
*
|
|
50
|
+
* @param {string} changeName - 变更名
|
|
51
|
+
* @param {{ cwd?: string, checkOnly?: boolean }} opts
|
|
52
|
+
* @returns {{
|
|
53
|
+
* ok: boolean,
|
|
54
|
+
* changedFiles: string[],
|
|
55
|
+
* extraFiles: string[],
|
|
56
|
+
* hashMismatchFiles: string[],
|
|
57
|
+
* patchPath: string|null,
|
|
58
|
+
* errors: string[]
|
|
59
|
+
* }}
|
|
60
|
+
*/
|
|
61
|
+
export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
62
|
+
const projectRoot = cwd || process.cwd();
|
|
63
|
+
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
64
|
+
const meta = wm.getMeta(changeName);
|
|
65
|
+
const result = {
|
|
66
|
+
ok: false,
|
|
67
|
+
changedFiles: [],
|
|
68
|
+
extraFiles: [],
|
|
69
|
+
hashMismatchFiles: [],
|
|
70
|
+
patchPath: null,
|
|
71
|
+
errors: [],
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
// --- 1. 校验 worktree 存在 + meta.json 有效 ---
|
|
75
|
+
if (!meta) {
|
|
76
|
+
result.errors.push(`worktree not found: ${changeName}。meta.json 不存在或已损坏。`);
|
|
77
|
+
return result;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { worktreePath, baseHash } = meta;
|
|
81
|
+
|
|
82
|
+
if (!existsSync(worktreePath)) {
|
|
83
|
+
result.errors.push(`worktree 目录不存在: ${worktreePath}`);
|
|
84
|
+
return result;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- 2. 获取变更文件列表 ---
|
|
88
|
+
// worktree 内修改可能没有 commit,用 git diff <baseHash>(比较 baseHash 到工作区内容)
|
|
89
|
+
// 同时检测 untracked 新文件(git diff 不包含 untracked)
|
|
90
|
+
let changedFiles;
|
|
91
|
+
try {
|
|
92
|
+
// tracked 文件的变更(modified/deleted)
|
|
93
|
+
const trackedRaw = git(worktreePath, `diff --name-only ${baseHash}`);
|
|
94
|
+
const trackedFiles = trackedRaw ? trackedRaw.split('\n').filter(Boolean) : [];
|
|
95
|
+
|
|
96
|
+
// untracked 新文件(baseHash 中不存在的文件)
|
|
97
|
+
const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`);
|
|
98
|
+
const untrackedFiles = untrackedRaw
|
|
99
|
+
? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
|
|
100
|
+
: [];
|
|
101
|
+
|
|
102
|
+
changedFiles = [...new Set([...trackedFiles, ...untrackedFiles])];
|
|
103
|
+
} catch (e) {
|
|
104
|
+
result.errors.push(`获取变更文件列表失败: ${e.message}`);
|
|
105
|
+
return result;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
result.changedFiles = changedFiles;
|
|
109
|
+
|
|
110
|
+
if (changedFiles.length === 0) {
|
|
111
|
+
// 没有变更
|
|
112
|
+
if (!checkOnly) {
|
|
113
|
+
wm.cleanup(changeName);
|
|
114
|
+
}
|
|
115
|
+
result.ok = true;
|
|
116
|
+
return result;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- 3. 解析 design.md 文件变更清单 ---
|
|
120
|
+
const designPath = join(projectRoot, CHANGES_REL, changeName, 'design.md');
|
|
121
|
+
const allowSet = parseFileChangeList(designPath);
|
|
122
|
+
const hasAllowList = allowSet.size > 0;
|
|
123
|
+
|
|
124
|
+
// --- 4. 校验:变更文件 ⊆ 清单(无清单则跳过)---
|
|
125
|
+
if (hasAllowList) {
|
|
126
|
+
for (const f of changedFiles) {
|
|
127
|
+
if (!allowSet.has(f)) {
|
|
128
|
+
result.extraFiles.push(f);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (result.extraFiles.length > 0) {
|
|
132
|
+
result.errors.push(
|
|
133
|
+
`文件清单校验失败:以下变更文件不在 design.md 清单中:\n ${result.extraFiles.join('\n ')}`
|
|
134
|
+
);
|
|
135
|
+
return result;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// --- 5. 校验:主工作区文件 base hash 一致 ---
|
|
140
|
+
// 5a. 检查主工作区是否有未 commit 的脏文件(会影响 apply)
|
|
141
|
+
const mainDirtyRaw = gitQuiet(projectRoot, 'diff --name-only HEAD');
|
|
142
|
+
const mainDirtyFiles = mainDirtyRaw ? mainDirtyRaw.split('\n').filter(Boolean) : [];
|
|
143
|
+
if (mainDirtyFiles.length > 0) {
|
|
144
|
+
// 如果脏文件和本次 apply 的文件有交集 → 报错
|
|
145
|
+
const conflictDirty = mainDirtyFiles.filter(f => changedFiles.includes(f));
|
|
146
|
+
if (conflictDirty.length > 0) {
|
|
147
|
+
result.errors.push(
|
|
148
|
+
`主工作区有以下未 commit 的变更,会影响 apply:\n ${conflictDirty.join('\n ')}\n请先 commit 或 stash 这些变更。`
|
|
149
|
+
);
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 5b. 对比 worktree 的 baseHash 和主工作区 HEAD 中每个清单文件的 blob hash
|
|
155
|
+
const targetFiles = hasAllowList ? [...allowSet] : changedFiles;
|
|
156
|
+
for (const f of targetFiles) {
|
|
157
|
+
const wtBlob = getFileBlobHash(worktreePath, baseHash, f);
|
|
158
|
+
const mainBlob = getFileBlobHash(projectRoot, 'HEAD', f);
|
|
159
|
+
|
|
160
|
+
// 两者都为 null(文件在 base 时不存在)→ OK
|
|
161
|
+
if (wtBlob === null && mainBlob === null) continue;
|
|
162
|
+
// 两者一致 → OK
|
|
163
|
+
if (wtBlob === mainBlob) continue;
|
|
164
|
+
// 不一致 → 主工作区已被修改
|
|
165
|
+
result.hashMismatchFiles.push(f);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (result.hashMismatchFiles.length > 0) {
|
|
169
|
+
result.errors.push(
|
|
170
|
+
`base hash 不一致:以下文件在主工作区已被修改:\n ${result.hashMismatchFiles.join('\n ')}`
|
|
171
|
+
);
|
|
172
|
+
return result;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- 6. checkOnly 模式:到此返回 ---
|
|
176
|
+
if (checkOnly) {
|
|
177
|
+
result.ok = true;
|
|
178
|
+
return result;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// --- 7. 生成 patch 并 apply ---
|
|
182
|
+
// 确定要包含在 patch 中的文件:有清单用清单交集,无清单用全部变更
|
|
183
|
+
const patchFiles = hasAllowList
|
|
184
|
+
? [...allowSet].filter(f => changedFiles.includes(f))
|
|
185
|
+
: changedFiles;
|
|
186
|
+
const fileArgs = patchFiles.map(f => `-- ${f}`).join(' ');
|
|
187
|
+
|
|
188
|
+
// 创建临时文件
|
|
189
|
+
const tmpDir = mkdtempSync(join(tmpdir(), 'sillyspec-patch-'));
|
|
190
|
+
const patchPath = join(tmpDir, 'apply.patch');
|
|
191
|
+
result.patchPath = patchPath;
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
let patchContent = '';
|
|
195
|
+
|
|
196
|
+
// 分 tracked 变更和 untracked 新文件生成 patch
|
|
197
|
+
const trackedFiles = patchFiles.filter(f => {
|
|
198
|
+
// untracked 文件在 baseHash 的 tree 中不存在
|
|
199
|
+
return gitQuiet(worktreePath, `cat-file -e ${baseHash}:${f}`) !== null;
|
|
200
|
+
});
|
|
201
|
+
const untrackedPatchFiles = patchFiles.filter(f => !trackedFiles.includes(f));
|
|
202
|
+
|
|
203
|
+
// tracked 文件:git diff baseHash
|
|
204
|
+
if (trackedFiles.length > 0) {
|
|
205
|
+
const trackedArgs = trackedFiles.map(f => `-- ${f}`).join(' ');
|
|
206
|
+
patchContent += execSync(
|
|
207
|
+
`git diff --binary ${baseHash} ${trackedArgs}`,
|
|
208
|
+
{ cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// untracked 新文件:git add 到 index,git diff --cached,然后 reset
|
|
213
|
+
if (untrackedPatchFiles.length > 0) {
|
|
214
|
+
const addArgs = untrackedPatchFiles.map(f => `-- ${f}`).join(' ');
|
|
215
|
+
git(worktreePath, `add ${addArgs}`);
|
|
216
|
+
try {
|
|
217
|
+
patchContent += execSync(
|
|
218
|
+
`git diff --binary --cached ${untrackedPatchFiles.map(f => `-- ${f}`).join(' ')}`,
|
|
219
|
+
{ cwd: worktreePath, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }
|
|
220
|
+
);
|
|
221
|
+
} finally {
|
|
222
|
+
// 重置 index(不保留 staged 状态)
|
|
223
|
+
gitQuiet(worktreePath, `reset HEAD -- ${addArgs}`);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
if (!patchContent.trim()) {
|
|
228
|
+
// patch 为空(清单中部分文件可能没实际变更)
|
|
229
|
+
result.ok = true;
|
|
230
|
+
rmSync(tmpDir, { recursive: true, force: true });
|
|
231
|
+
return result;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
writeFileSync(patchPath, patchContent);
|
|
235
|
+
|
|
236
|
+
// apply --check 预检
|
|
237
|
+
try {
|
|
238
|
+
git(projectRoot, `apply --check ${patchPath}`);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
result.errors.push(`patch 预检失败: ${e.message}`);
|
|
241
|
+
return result;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// apply --3way 正式应用
|
|
245
|
+
try {
|
|
246
|
+
git(projectRoot, `apply --3way ${patchPath}`);
|
|
247
|
+
} catch (e) {
|
|
248
|
+
result.errors.push(`patch apply 失败: ${e.message}`);
|
|
249
|
+
return result;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
result.ok = true;
|
|
253
|
+
|
|
254
|
+
// --- 8. 成功后自动 cleanup ---
|
|
255
|
+
wm.cleanup(changeName);
|
|
256
|
+
|
|
257
|
+
} catch (e) {
|
|
258
|
+
result.errors.push(`patch 生成/应用异常: ${e.message}`);
|
|
259
|
+
return result;
|
|
260
|
+
} finally {
|
|
261
|
+
// 清理临时目录
|
|
262
|
+
try { rmSync(tmpDir, { recursive: true, force: true }); } catch {}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return result;
|
|
266
|
+
}
|
package/src/worktree.js
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SillySpec WorktreeManager — git worktree 生命周期管理
|
|
3
|
+
*
|
|
4
|
+
* 封装 git worktree 的 create/list/cleanup/getMeta 操作,
|
|
5
|
+
* 为 execute 阶段提供代码隔离环境。
|
|
6
|
+
*
|
|
7
|
+
* worktree 存储目录:.sillyspec/.runtime/worktrees/<change-name>/
|
|
8
|
+
* 分支命名:sillyspec/<change-name>
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execSync } from 'child_process';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync, readdirSync } from 'fs';
|
|
13
|
+
import { join, resolve } from 'path';
|
|
14
|
+
|
|
15
|
+
const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
|
|
16
|
+
const BRANCH_PREFIX = 'sillyspec/';
|
|
17
|
+
const META_FILE = 'meta.json';
|
|
18
|
+
|
|
19
|
+
function git(cwd, args) {
|
|
20
|
+
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function gitQuiet(cwd, args) {
|
|
24
|
+
try {
|
|
25
|
+
return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function parseJSON(raw) {
|
|
32
|
+
try { return JSON.parse(raw); } catch { return null; }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function validateChangeName(changeName) {
|
|
36
|
+
if (!changeName || typeof changeName !== 'string' || changeName.trim() === '') {
|
|
37
|
+
throw new Error('changeName 不能为空');
|
|
38
|
+
}
|
|
39
|
+
const trimmed = changeName.trim();
|
|
40
|
+
// 禁止路径穿越
|
|
41
|
+
if (trimmed.includes('..') || trimmed.includes('/') || trimmed.includes('\\')) {
|
|
42
|
+
throw new Error(`changeName 不合法: "${changeName}",不能包含 ..、/ 或 \\`);
|
|
43
|
+
}
|
|
44
|
+
return trimmed;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 检测 git worktree 是否可用
|
|
49
|
+
* @param {string} cwd
|
|
50
|
+
* @returns {{ supported: boolean, version: string|null, reason?: string }}
|
|
51
|
+
*/
|
|
52
|
+
export function isGitWorktreeSupported(cwd = process.cwd()) {
|
|
53
|
+
try {
|
|
54
|
+
const raw = execSync('git --version', { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
|
|
55
|
+
const match = raw.match(/git version (\d+)\.(\d+)/);
|
|
56
|
+
if (!match) return { supported: false, version: raw, reason: 'cannot parse version' };
|
|
57
|
+
const major = parseInt(match[1], 10);
|
|
58
|
+
const minor = parseInt(match[2], 10);
|
|
59
|
+
if (major > 2 || (major === 2 && minor >= 15)) {
|
|
60
|
+
return { supported: true, version: raw };
|
|
61
|
+
}
|
|
62
|
+
return { supported: false, version: raw, reason: 'git version < 2.15' };
|
|
63
|
+
} catch {
|
|
64
|
+
return { supported: false, version: null, reason: 'git not found' };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export class WorktreeManager {
|
|
69
|
+
constructor({ cwd, worktreeDir } = {}) {
|
|
70
|
+
this.cwd = cwd || process.cwd();
|
|
71
|
+
this.worktreeBase = worktreeDir || resolve(this.cwd, WORKTREES_REL);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* 获取 worktree 目录绝对路径
|
|
76
|
+
* @param {string} changeName
|
|
77
|
+
* @returns {string}
|
|
78
|
+
*/
|
|
79
|
+
getWorktreePath(changeName) {
|
|
80
|
+
return resolve(this.worktreeBase, changeName);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* 读取 worktree 元数据
|
|
85
|
+
* @param {string} changeName
|
|
86
|
+
* @returns {object|null} meta.json 内容,不存在或损坏返回 null
|
|
87
|
+
*/
|
|
88
|
+
getMeta(changeName) {
|
|
89
|
+
const name = validateChangeName(changeName);
|
|
90
|
+
const metaPath = join(this.getWorktreePath(name), META_FILE);
|
|
91
|
+
if (!existsSync(metaPath)) return null;
|
|
92
|
+
return parseJSON(readFileSync(metaPath, 'utf8'));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 创建 worktree
|
|
97
|
+
* @param {string} changeName - 变更名
|
|
98
|
+
* @param {{ base?: string }} opts - base: 基础分支,默认当前 HEAD
|
|
99
|
+
* @returns {{ branch: string, worktreePath: string, baseHash: string }}
|
|
100
|
+
* @throws {Error} worktree 已存在、git 不可用、changeName 为空
|
|
101
|
+
*/
|
|
102
|
+
create(changeName, { base } = {}) {
|
|
103
|
+
const name = validateChangeName(changeName);
|
|
104
|
+
const worktreePath = this.getWorktreePath(name);
|
|
105
|
+
const branch = BRANCH_PREFIX + name;
|
|
106
|
+
|
|
107
|
+
// 1. 检查 worktree 是否已存在
|
|
108
|
+
if (existsSync(worktreePath)) {
|
|
109
|
+
throw new Error(`worktree already exists: ${name}. Run cleanup first.`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// 2. 检查分支是否已存在
|
|
113
|
+
if (gitQuiet(this.cwd, `rev-parse --verify refs/heads/${branch}`)) {
|
|
114
|
+
throw new Error(`branch already exists: ${branch}. Run cleanup first.`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 3. 解析 base 分支
|
|
118
|
+
let baseBranch = base;
|
|
119
|
+
let baseHash;
|
|
120
|
+
if (baseBranch) {
|
|
121
|
+
baseHash = git(this.cwd, `rev-parse ${baseBranch}`);
|
|
122
|
+
} else {
|
|
123
|
+
// 默认用当前 HEAD
|
|
124
|
+
baseBranch = gitQuiet(this.cwd, `symbolic-ref --short HEAD`) || git(this.cwd, `rev-parse HEAD`);
|
|
125
|
+
baseHash = git(this.cwd, `rev-parse HEAD`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// 4. 创建 worktree 根目录
|
|
129
|
+
if (!existsSync(this.worktreeBase)) {
|
|
130
|
+
mkdirSync(this.worktreeBase, { recursive: true });
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// 5. 创建 worktree(含版本检测)
|
|
134
|
+
try {
|
|
135
|
+
git(this.cwd, `worktree add ${worktreePath} -b ${branch} ${baseHash}`);
|
|
136
|
+
} catch (e) {
|
|
137
|
+
const check = isGitWorktreeSupported(this.cwd);
|
|
138
|
+
if (!check.supported) {
|
|
139
|
+
throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 使用 --no-worktree 标志跳过隔离,或升级 git 到 >= 2.15`);
|
|
140
|
+
}
|
|
141
|
+
throw new Error(`git worktree add 失败: ${e.stderr || e.message}`);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// 6. 写入 meta.json
|
|
145
|
+
const meta = {
|
|
146
|
+
changeName: name,
|
|
147
|
+
branch,
|
|
148
|
+
baseBranch,
|
|
149
|
+
baseHash,
|
|
150
|
+
createdAt: new Date().toISOString(),
|
|
151
|
+
worktreePath,
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const metaPath = join(worktreePath, META_FILE);
|
|
155
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
|
|
156
|
+
|
|
157
|
+
return { branch, worktreePath, baseHash };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* 列出所有活跃 worktree
|
|
162
|
+
* @returns {Array<{ changeName: string, branch: string, baseHash: string, createdAt: string, worktreePath: string }>}
|
|
163
|
+
*/
|
|
164
|
+
list() {
|
|
165
|
+
const results = [];
|
|
166
|
+
if (!existsSync(this.worktreeBase)) return results;
|
|
167
|
+
|
|
168
|
+
const entries = readdirSync(this.worktreeBase, { withFileTypes: true });
|
|
169
|
+
for (const entry of entries) {
|
|
170
|
+
if (!entry.isDirectory()) continue;
|
|
171
|
+
const metaPath = join(this.worktreeBase, entry.name, META_FILE);
|
|
172
|
+
if (!existsSync(metaPath)) continue;
|
|
173
|
+
const meta = parseJSON(readFileSync(metaPath, 'utf8'));
|
|
174
|
+
if (!meta) continue;
|
|
175
|
+
results.push({
|
|
176
|
+
changeName: meta.changeName,
|
|
177
|
+
branch: meta.branch,
|
|
178
|
+
baseHash: meta.baseHash,
|
|
179
|
+
baseBranch: meta.baseBranch,
|
|
180
|
+
createdAt: meta.createdAt,
|
|
181
|
+
worktreePath: meta.worktreePath,
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return results;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* 清理 worktree(强制删除,不 apply)
|
|
190
|
+
* @param {string} changeName
|
|
191
|
+
* @throws {Error} worktree 不存在
|
|
192
|
+
*/
|
|
193
|
+
cleanup(changeName) {
|
|
194
|
+
const name = validateChangeName(changeName);
|
|
195
|
+
const meta = this.getMeta(name);
|
|
196
|
+
|
|
197
|
+
if (!meta) {
|
|
198
|
+
throw new Error(`worktree not found: ${name}。meta.json 不存在,可能已被清理或从未创建。`);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const worktreePath = meta.worktreePath || this.getWorktreePath(name);
|
|
202
|
+
const branch = meta.branch || BRANCH_PREFIX + name;
|
|
203
|
+
|
|
204
|
+
// 2. 移除 git worktree
|
|
205
|
+
try {
|
|
206
|
+
git(this.cwd, `worktree remove ${worktreePath} --force`);
|
|
207
|
+
} catch {
|
|
208
|
+
// git worktree remove 失败,尝试直接删除目录
|
|
209
|
+
try {
|
|
210
|
+
if (existsSync(worktreePath)) {
|
|
211
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
212
|
+
}
|
|
213
|
+
} catch (e) {
|
|
214
|
+
throw new Error(`清理 worktree 目录失败: ${e.message}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. 删除分支(忽略分支不存在的错误)
|
|
219
|
+
gitQuiet(this.cwd, `branch -D ${branch}`);
|
|
220
|
+
|
|
221
|
+
// 4. 确保目录已删除
|
|
222
|
+
if (existsSync(worktreePath)) {
|
|
223
|
+
rmSync(worktreePath, { recursive: true, force: true });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|