sillyspec 3.11.7 → 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/brainstorm.js +12 -1
- package/src/stages/execute.js +57 -11
- package/src/stages/quick.js +42 -3
- package/src/stages/scan.js +32 -0
- package/src/worktree-apply.js +266 -0
- package/src/worktree.js +226 -0
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
|
+
}
|