opencode-hive 0.6.0 → 0.8.1
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/dist/index.js +19268 -479
- package/package.json +4 -3
- package/dist/e2e/opencode-runtime-smoke.test.d.ts +0 -1
- package/dist/e2e/opencode-runtime-smoke.test.js +0 -243
- package/dist/e2e/plugin-smoke.test.d.ts +0 -1
- package/dist/e2e/plugin-smoke.test.js +0 -127
- package/dist/services/contextService.d.ts +0 -15
- package/dist/services/contextService.js +0 -59
- package/dist/services/featureService.d.ts +0 -14
- package/dist/services/featureService.js +0 -107
- package/dist/services/featureService.test.d.ts +0 -1
- package/dist/services/featureService.test.js +0 -127
- package/dist/services/index.d.ts +0 -5
- package/dist/services/index.js +0 -4
- package/dist/services/planService.d.ts +0 -11
- package/dist/services/planService.js +0 -59
- package/dist/services/planService.test.d.ts +0 -1
- package/dist/services/planService.test.js +0 -115
- package/dist/services/sessionService.d.ts +0 -31
- package/dist/services/sessionService.js +0 -125
- package/dist/services/taskService.d.ts +0 -17
- package/dist/services/taskService.js +0 -230
- package/dist/services/taskService.test.d.ts +0 -1
- package/dist/services/taskService.test.js +0 -159
- package/dist/services/worktreeService.d.ts +0 -66
- package/dist/services/worktreeService.js +0 -498
- package/dist/services/worktreeService.test.d.ts +0 -1
- package/dist/services/worktreeService.test.js +0 -185
- package/dist/tools/contextTools.d.ts +0 -93
- package/dist/tools/contextTools.js +0 -83
- package/dist/tools/execTools.d.ts +0 -66
- package/dist/tools/execTools.js +0 -125
- package/dist/tools/featureTools.d.ts +0 -60
- package/dist/tools/featureTools.js +0 -73
- package/dist/tools/planTools.d.ts +0 -47
- package/dist/tools/planTools.js +0 -65
- package/dist/tools/sessionTools.d.ts +0 -35
- package/dist/tools/sessionTools.js +0 -95
- package/dist/tools/taskTools.d.ts +0 -79
- package/dist/tools/taskTools.js +0 -86
- package/dist/types.d.ts +0 -89
- package/dist/types.js +0 -1
- package/dist/utils/detection.d.ts +0 -12
- package/dist/utils/detection.js +0 -73
- package/dist/utils/paths.d.ts +0 -18
- package/dist/utils/paths.js +0 -74
- package/dist/utils/paths.test.d.ts +0 -1
- package/dist/utils/paths.test.js +0 -100
|
@@ -1,498 +0,0 @@
|
|
|
1
|
-
import * as fs from "fs/promises";
|
|
2
|
-
import * as path from "path";
|
|
3
|
-
import simpleGit from "simple-git";
|
|
4
|
-
export class WorktreeService {
|
|
5
|
-
config;
|
|
6
|
-
constructor(config) {
|
|
7
|
-
this.config = config;
|
|
8
|
-
}
|
|
9
|
-
getGit(cwd) {
|
|
10
|
-
return simpleGit(cwd || this.config.baseDir);
|
|
11
|
-
}
|
|
12
|
-
getWorktreesDir() {
|
|
13
|
-
return path.join(this.config.hiveDir, ".worktrees");
|
|
14
|
-
}
|
|
15
|
-
getWorktreePath(feature, step) {
|
|
16
|
-
return path.join(this.getWorktreesDir(), feature, step);
|
|
17
|
-
}
|
|
18
|
-
async getStepStatusPath(feature, step) {
|
|
19
|
-
const featurePath = path.join(this.config.hiveDir, "features", feature);
|
|
20
|
-
// Check v2 structure first (tasks/)
|
|
21
|
-
const tasksPath = path.join(featurePath, "tasks", step, "status.json");
|
|
22
|
-
try {
|
|
23
|
-
await fs.access(tasksPath);
|
|
24
|
-
return tasksPath;
|
|
25
|
-
}
|
|
26
|
-
catch { }
|
|
27
|
-
// Fall back to v1 structure (execution/)
|
|
28
|
-
return path.join(featurePath, "execution", step, "status.json");
|
|
29
|
-
}
|
|
30
|
-
getBranchName(feature, step) {
|
|
31
|
-
return `hive/${feature}/${step}`;
|
|
32
|
-
}
|
|
33
|
-
async create(feature, step, baseBranch) {
|
|
34
|
-
const worktreePath = this.getWorktreePath(feature, step);
|
|
35
|
-
const branchName = this.getBranchName(feature, step);
|
|
36
|
-
const git = this.getGit();
|
|
37
|
-
await fs.mkdir(path.dirname(worktreePath), { recursive: true });
|
|
38
|
-
const base = baseBranch || (await git.revparse(["HEAD"])).trim();
|
|
39
|
-
const existing = await this.get(feature, step);
|
|
40
|
-
if (existing) {
|
|
41
|
-
return existing;
|
|
42
|
-
}
|
|
43
|
-
try {
|
|
44
|
-
await git.raw(["worktree", "add", "-b", branchName, worktreePath, base]);
|
|
45
|
-
}
|
|
46
|
-
catch {
|
|
47
|
-
try {
|
|
48
|
-
await git.raw(["worktree", "add", worktreePath, branchName]);
|
|
49
|
-
}
|
|
50
|
-
catch (retryError) {
|
|
51
|
-
throw new Error(`Failed to create worktree: ${retryError}`);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
const worktreeGit = this.getGit(worktreePath);
|
|
55
|
-
const commit = (await worktreeGit.revparse(["HEAD"])).trim();
|
|
56
|
-
return {
|
|
57
|
-
path: worktreePath,
|
|
58
|
-
branch: branchName,
|
|
59
|
-
commit,
|
|
60
|
-
feature,
|
|
61
|
-
step,
|
|
62
|
-
};
|
|
63
|
-
}
|
|
64
|
-
async get(feature, step) {
|
|
65
|
-
const worktreePath = this.getWorktreePath(feature, step);
|
|
66
|
-
const branchName = this.getBranchName(feature, step);
|
|
67
|
-
try {
|
|
68
|
-
await fs.access(worktreePath);
|
|
69
|
-
const worktreeGit = this.getGit(worktreePath);
|
|
70
|
-
const commit = (await worktreeGit.revparse(["HEAD"])).trim();
|
|
71
|
-
return {
|
|
72
|
-
path: worktreePath,
|
|
73
|
-
branch: branchName,
|
|
74
|
-
commit,
|
|
75
|
-
feature,
|
|
76
|
-
step,
|
|
77
|
-
};
|
|
78
|
-
}
|
|
79
|
-
catch {
|
|
80
|
-
return null;
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
async getDiff(feature, step, baseCommit) {
|
|
84
|
-
const worktreePath = this.getWorktreePath(feature, step);
|
|
85
|
-
const statusPath = await this.getStepStatusPath(feature, step);
|
|
86
|
-
let base = baseCommit;
|
|
87
|
-
if (!base) {
|
|
88
|
-
try {
|
|
89
|
-
const status = JSON.parse(await fs.readFile(statusPath, "utf-8"));
|
|
90
|
-
base = status.baseCommit; // Read baseCommit directly from task status
|
|
91
|
-
}
|
|
92
|
-
catch { }
|
|
93
|
-
}
|
|
94
|
-
if (!base) {
|
|
95
|
-
base = "HEAD~1";
|
|
96
|
-
}
|
|
97
|
-
const worktreeGit = this.getGit(worktreePath);
|
|
98
|
-
try {
|
|
99
|
-
await worktreeGit.raw(["add", "-A"]);
|
|
100
|
-
const status = await worktreeGit.status();
|
|
101
|
-
const hasStaged = status.staged.length > 0;
|
|
102
|
-
let diffContent = "";
|
|
103
|
-
let stat = "";
|
|
104
|
-
if (hasStaged) {
|
|
105
|
-
diffContent = await worktreeGit.diff(["--cached"]);
|
|
106
|
-
stat = diffContent ? await worktreeGit.diff(["--cached", "--stat"]) : "";
|
|
107
|
-
}
|
|
108
|
-
else {
|
|
109
|
-
diffContent = await worktreeGit.diff([`${base}..HEAD`]).catch(() => "");
|
|
110
|
-
stat = diffContent ? await worktreeGit.diff([`${base}..HEAD`, "--stat"]) : "";
|
|
111
|
-
}
|
|
112
|
-
const statLines = stat.split("\n").filter((l) => l.trim());
|
|
113
|
-
const filesChanged = statLines
|
|
114
|
-
.slice(0, -1)
|
|
115
|
-
.map((line) => line.split("|")[0].trim())
|
|
116
|
-
.filter(Boolean);
|
|
117
|
-
const summaryLine = statLines[statLines.length - 1] || "";
|
|
118
|
-
const insertMatch = summaryLine.match(/(\d+) insertion/);
|
|
119
|
-
const deleteMatch = summaryLine.match(/(\d+) deletion/);
|
|
120
|
-
return {
|
|
121
|
-
hasDiff: diffContent.length > 0,
|
|
122
|
-
diffContent,
|
|
123
|
-
filesChanged,
|
|
124
|
-
insertions: insertMatch ? parseInt(insertMatch[1], 10) : 0,
|
|
125
|
-
deletions: deleteMatch ? parseInt(deleteMatch[1], 10) : 0,
|
|
126
|
-
};
|
|
127
|
-
}
|
|
128
|
-
catch {
|
|
129
|
-
return {
|
|
130
|
-
hasDiff: false,
|
|
131
|
-
diffContent: "",
|
|
132
|
-
filesChanged: [],
|
|
133
|
-
insertions: 0,
|
|
134
|
-
deletions: 0,
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
async exportPatch(feature, step, baseBranch) {
|
|
139
|
-
const worktreePath = this.getWorktreePath(feature, step);
|
|
140
|
-
const patchPath = path.join(worktreePath, "..", `${step}.patch`);
|
|
141
|
-
const base = baseBranch || "HEAD~1";
|
|
142
|
-
const worktreeGit = this.getGit(worktreePath);
|
|
143
|
-
const diff = await worktreeGit.diff([`${base}...HEAD`]);
|
|
144
|
-
await fs.writeFile(patchPath, diff);
|
|
145
|
-
return patchPath;
|
|
146
|
-
}
|
|
147
|
-
async applyDiff(feature, step, baseBranch) {
|
|
148
|
-
const { hasDiff, diffContent, filesChanged } = await this.getDiff(feature, step, baseBranch);
|
|
149
|
-
if (!hasDiff) {
|
|
150
|
-
return { success: true, filesAffected: [] };
|
|
151
|
-
}
|
|
152
|
-
const patchPath = path.join(this.config.hiveDir, ".worktrees", feature, `${step}.patch`);
|
|
153
|
-
try {
|
|
154
|
-
await fs.writeFile(patchPath, diffContent);
|
|
155
|
-
const git = this.getGit();
|
|
156
|
-
await git.applyPatch(patchPath);
|
|
157
|
-
await fs.unlink(patchPath).catch(() => { });
|
|
158
|
-
return { success: true, filesAffected: filesChanged };
|
|
159
|
-
}
|
|
160
|
-
catch (error) {
|
|
161
|
-
await fs.unlink(patchPath).catch(() => { });
|
|
162
|
-
const err = error;
|
|
163
|
-
return {
|
|
164
|
-
success: false,
|
|
165
|
-
error: err.message || "Failed to apply patch",
|
|
166
|
-
filesAffected: [],
|
|
167
|
-
};
|
|
168
|
-
}
|
|
169
|
-
}
|
|
170
|
-
async revertDiff(feature, step, baseBranch) {
|
|
171
|
-
const { hasDiff, diffContent, filesChanged } = await this.getDiff(feature, step, baseBranch);
|
|
172
|
-
if (!hasDiff) {
|
|
173
|
-
return { success: true, filesAffected: [] };
|
|
174
|
-
}
|
|
175
|
-
const patchPath = path.join(this.config.hiveDir, ".worktrees", feature, `${step}.patch`);
|
|
176
|
-
try {
|
|
177
|
-
await fs.writeFile(patchPath, diffContent);
|
|
178
|
-
const git = this.getGit();
|
|
179
|
-
await git.applyPatch(patchPath, ["-R"]);
|
|
180
|
-
await fs.unlink(patchPath).catch(() => { });
|
|
181
|
-
return { success: true, filesAffected: filesChanged };
|
|
182
|
-
}
|
|
183
|
-
catch (error) {
|
|
184
|
-
await fs.unlink(patchPath).catch(() => { });
|
|
185
|
-
const err = error;
|
|
186
|
-
return {
|
|
187
|
-
success: false,
|
|
188
|
-
error: err.message || "Failed to revert patch",
|
|
189
|
-
filesAffected: [],
|
|
190
|
-
};
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
parseFilesFromDiff(diffContent) {
|
|
194
|
-
const files = [];
|
|
195
|
-
const regex = /^diff --git a\/(.+?) b\//gm;
|
|
196
|
-
let match;
|
|
197
|
-
while ((match = regex.exec(diffContent)) !== null) {
|
|
198
|
-
files.push(match[1]);
|
|
199
|
-
}
|
|
200
|
-
return [...new Set(files)];
|
|
201
|
-
}
|
|
202
|
-
async revertFromSavedDiff(diffPath) {
|
|
203
|
-
const diffContent = await fs.readFile(diffPath, "utf-8");
|
|
204
|
-
if (!diffContent.trim()) {
|
|
205
|
-
return { success: true, filesAffected: [] };
|
|
206
|
-
}
|
|
207
|
-
const filesChanged = this.parseFilesFromDiff(diffContent);
|
|
208
|
-
try {
|
|
209
|
-
const git = this.getGit();
|
|
210
|
-
await git.applyPatch(diffContent, ["-R"]);
|
|
211
|
-
return { success: true, filesAffected: filesChanged };
|
|
212
|
-
}
|
|
213
|
-
catch (error) {
|
|
214
|
-
const err = error;
|
|
215
|
-
return {
|
|
216
|
-
success: false,
|
|
217
|
-
error: err.message || "Failed to revert patch",
|
|
218
|
-
filesAffected: [],
|
|
219
|
-
};
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
async remove(feature, step, deleteBranch = false) {
|
|
223
|
-
const worktreePath = this.getWorktreePath(feature, step);
|
|
224
|
-
const branchName = this.getBranchName(feature, step);
|
|
225
|
-
const git = this.getGit();
|
|
226
|
-
try {
|
|
227
|
-
await git.raw(["worktree", "remove", worktreePath, "--force"]);
|
|
228
|
-
}
|
|
229
|
-
catch {
|
|
230
|
-
await fs.rm(worktreePath, { recursive: true, force: true });
|
|
231
|
-
}
|
|
232
|
-
try {
|
|
233
|
-
await git.raw(["worktree", "prune"]);
|
|
234
|
-
}
|
|
235
|
-
catch {
|
|
236
|
-
/* intentional */
|
|
237
|
-
}
|
|
238
|
-
if (deleteBranch) {
|
|
239
|
-
try {
|
|
240
|
-
await git.deleteLocalBranch(branchName, true);
|
|
241
|
-
}
|
|
242
|
-
catch {
|
|
243
|
-
/* intentional */
|
|
244
|
-
}
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
|
-
async list(feature) {
|
|
248
|
-
const worktreesDir = this.getWorktreesDir();
|
|
249
|
-
const results = [];
|
|
250
|
-
try {
|
|
251
|
-
const features = feature ? [feature] : await fs.readdir(worktreesDir);
|
|
252
|
-
for (const feat of features) {
|
|
253
|
-
const featurePath = path.join(worktreesDir, feat);
|
|
254
|
-
const stat = await fs.stat(featurePath).catch(() => null);
|
|
255
|
-
if (!stat?.isDirectory())
|
|
256
|
-
continue;
|
|
257
|
-
const steps = await fs.readdir(featurePath).catch(() => []);
|
|
258
|
-
for (const step of steps) {
|
|
259
|
-
const info = await this.get(feat, step);
|
|
260
|
-
if (info) {
|
|
261
|
-
results.push(info);
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
}
|
|
266
|
-
catch {
|
|
267
|
-
/* intentional */
|
|
268
|
-
}
|
|
269
|
-
return results;
|
|
270
|
-
}
|
|
271
|
-
async cleanup(feature) {
|
|
272
|
-
const removed = [];
|
|
273
|
-
const git = this.getGit();
|
|
274
|
-
try {
|
|
275
|
-
await git.raw(["worktree", "prune"]);
|
|
276
|
-
}
|
|
277
|
-
catch {
|
|
278
|
-
/* intentional */
|
|
279
|
-
}
|
|
280
|
-
const worktreesDir = this.getWorktreesDir();
|
|
281
|
-
const features = feature ? [feature] : await fs.readdir(worktreesDir).catch(() => []);
|
|
282
|
-
for (const feat of features) {
|
|
283
|
-
const featurePath = path.join(worktreesDir, feat);
|
|
284
|
-
const stat = await fs.stat(featurePath).catch(() => null);
|
|
285
|
-
if (!stat?.isDirectory())
|
|
286
|
-
continue;
|
|
287
|
-
const steps = await fs.readdir(featurePath).catch(() => []);
|
|
288
|
-
for (const step of steps) {
|
|
289
|
-
const worktreePath = path.join(featurePath, step);
|
|
290
|
-
const stepStat = await fs.stat(worktreePath).catch(() => null);
|
|
291
|
-
if (!stepStat?.isDirectory())
|
|
292
|
-
continue;
|
|
293
|
-
try {
|
|
294
|
-
const worktreeGit = this.getGit(worktreePath);
|
|
295
|
-
await worktreeGit.revparse(["HEAD"]);
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
await this.remove(feat, step, false);
|
|
299
|
-
removed.push(worktreePath);
|
|
300
|
-
}
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
return { removed, pruned: true };
|
|
304
|
-
}
|
|
305
|
-
async checkConflicts(feature, step, baseBranch) {
|
|
306
|
-
const { hasDiff, diffContent } = await this.getDiff(feature, step, baseBranch);
|
|
307
|
-
if (!hasDiff) {
|
|
308
|
-
return [];
|
|
309
|
-
}
|
|
310
|
-
const patchPath = path.join(this.config.hiveDir, ".worktrees", feature, `${step}-check.patch`);
|
|
311
|
-
try {
|
|
312
|
-
await fs.writeFile(patchPath, diffContent);
|
|
313
|
-
const git = this.getGit();
|
|
314
|
-
await git.applyPatch(patchPath, ["--check"]);
|
|
315
|
-
await fs.unlink(patchPath).catch(() => { });
|
|
316
|
-
return [];
|
|
317
|
-
}
|
|
318
|
-
catch (error) {
|
|
319
|
-
await fs.unlink(patchPath).catch(() => { });
|
|
320
|
-
const err = error;
|
|
321
|
-
const stderr = err.message || "";
|
|
322
|
-
const conflicts = stderr
|
|
323
|
-
.split("\n")
|
|
324
|
-
.filter((line) => line.includes("error: patch failed:"))
|
|
325
|
-
.map((line) => {
|
|
326
|
-
const match = line.match(/error: patch failed: (.+):/);
|
|
327
|
-
return match ? match[1] : null;
|
|
328
|
-
})
|
|
329
|
-
.filter((f) => f !== null);
|
|
330
|
-
return conflicts;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
333
|
-
async checkConflictsFromSavedDiff(diffPath, reverse = false) {
|
|
334
|
-
try {
|
|
335
|
-
await fs.access(diffPath);
|
|
336
|
-
}
|
|
337
|
-
catch {
|
|
338
|
-
return [];
|
|
339
|
-
}
|
|
340
|
-
try {
|
|
341
|
-
const git = this.getGit();
|
|
342
|
-
const options = reverse ? ["--check", "-R"] : ["--check"];
|
|
343
|
-
await git.applyPatch(diffPath, options);
|
|
344
|
-
return [];
|
|
345
|
-
}
|
|
346
|
-
catch (error) {
|
|
347
|
-
const err = error;
|
|
348
|
-
const stderr = err.message || "";
|
|
349
|
-
const conflicts = stderr
|
|
350
|
-
.split("\n")
|
|
351
|
-
.filter((line) => line.includes("error: patch failed:"))
|
|
352
|
-
.map((line) => {
|
|
353
|
-
const match = line.match(/error: patch failed: (.+):/);
|
|
354
|
-
return match ? match[1] : null;
|
|
355
|
-
})
|
|
356
|
-
.filter((f) => f !== null);
|
|
357
|
-
return conflicts;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
async commitChanges(feature, step, message) {
|
|
361
|
-
const worktreePath = this.getWorktreePath(feature, step);
|
|
362
|
-
try {
|
|
363
|
-
await fs.access(worktreePath);
|
|
364
|
-
}
|
|
365
|
-
catch {
|
|
366
|
-
return { committed: false, sha: "", message: "Worktree not found" };
|
|
367
|
-
}
|
|
368
|
-
const worktreeGit = this.getGit(worktreePath);
|
|
369
|
-
try {
|
|
370
|
-
await worktreeGit.add("-A");
|
|
371
|
-
const status = await worktreeGit.status();
|
|
372
|
-
const hasChanges = status.staged.length > 0 || status.modified.length > 0 || status.not_added.length > 0;
|
|
373
|
-
if (!hasChanges) {
|
|
374
|
-
const currentSha = (await worktreeGit.revparse(["HEAD"])).trim();
|
|
375
|
-
return { committed: false, sha: currentSha, message: "No changes to commit" };
|
|
376
|
-
}
|
|
377
|
-
const commitMessage = message || `hive(${step}): task changes`;
|
|
378
|
-
const result = await worktreeGit.commit(commitMessage, ["--allow-empty-message"]);
|
|
379
|
-
return {
|
|
380
|
-
committed: true,
|
|
381
|
-
sha: result.commit,
|
|
382
|
-
message: commitMessage,
|
|
383
|
-
};
|
|
384
|
-
}
|
|
385
|
-
catch (error) {
|
|
386
|
-
const err = error;
|
|
387
|
-
const currentSha = (await worktreeGit.revparse(["HEAD"]).catch(() => "")).trim();
|
|
388
|
-
return {
|
|
389
|
-
committed: false,
|
|
390
|
-
sha: currentSha,
|
|
391
|
-
message: err.message || "Commit failed",
|
|
392
|
-
};
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
async merge(feature, step, strategy = 'merge') {
|
|
396
|
-
const branchName = this.getBranchName(feature, step);
|
|
397
|
-
const git = this.getGit();
|
|
398
|
-
try {
|
|
399
|
-
const branches = await git.branch();
|
|
400
|
-
if (!branches.all.includes(branchName)) {
|
|
401
|
-
return { success: false, merged: false, error: `Branch ${branchName} not found` };
|
|
402
|
-
}
|
|
403
|
-
const currentBranch = branches.current;
|
|
404
|
-
const diffStat = await git.diff([`${currentBranch}...${branchName}`, "--stat"]);
|
|
405
|
-
const filesChanged = diffStat
|
|
406
|
-
.split("\n")
|
|
407
|
-
.filter(l => l.trim() && l.includes("|"))
|
|
408
|
-
.map(l => l.split("|")[0].trim());
|
|
409
|
-
if (strategy === 'squash') {
|
|
410
|
-
await git.raw(["merge", "--squash", branchName]);
|
|
411
|
-
const result = await git.commit(`hive: merge ${step} (squashed)`);
|
|
412
|
-
return {
|
|
413
|
-
success: true,
|
|
414
|
-
merged: true,
|
|
415
|
-
sha: result.commit,
|
|
416
|
-
filesChanged,
|
|
417
|
-
};
|
|
418
|
-
}
|
|
419
|
-
else if (strategy === 'rebase') {
|
|
420
|
-
const commits = await git.log([`${currentBranch}..${branchName}`]);
|
|
421
|
-
const commitsToApply = [...commits.all].reverse();
|
|
422
|
-
for (const commit of commitsToApply) {
|
|
423
|
-
await git.raw(["cherry-pick", commit.hash]);
|
|
424
|
-
}
|
|
425
|
-
const head = (await git.revparse(["HEAD"])).trim();
|
|
426
|
-
return {
|
|
427
|
-
success: true,
|
|
428
|
-
merged: true,
|
|
429
|
-
sha: head,
|
|
430
|
-
filesChanged,
|
|
431
|
-
};
|
|
432
|
-
}
|
|
433
|
-
else {
|
|
434
|
-
const result = await git.merge([branchName, "--no-ff", "-m", `hive: merge ${step}`]);
|
|
435
|
-
const head = (await git.revparse(["HEAD"])).trim();
|
|
436
|
-
return {
|
|
437
|
-
success: true,
|
|
438
|
-
merged: !result.failed,
|
|
439
|
-
sha: head,
|
|
440
|
-
filesChanged,
|
|
441
|
-
conflicts: result.conflicts?.map(c => c.file || String(c)) || [],
|
|
442
|
-
};
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
catch (error) {
|
|
446
|
-
const err = error;
|
|
447
|
-
if (err.message?.includes("CONFLICT") || err.message?.includes("conflict")) {
|
|
448
|
-
await git.raw(["merge", "--abort"]).catch(() => { });
|
|
449
|
-
await git.raw(["rebase", "--abort"]).catch(() => { });
|
|
450
|
-
await git.raw(["cherry-pick", "--abort"]).catch(() => { });
|
|
451
|
-
return {
|
|
452
|
-
success: false,
|
|
453
|
-
merged: false,
|
|
454
|
-
error: "Merge conflicts detected",
|
|
455
|
-
conflicts: this.parseConflictsFromError(err.message || ""),
|
|
456
|
-
};
|
|
457
|
-
}
|
|
458
|
-
return {
|
|
459
|
-
success: false,
|
|
460
|
-
merged: false,
|
|
461
|
-
error: err.message || "Merge failed",
|
|
462
|
-
};
|
|
463
|
-
}
|
|
464
|
-
}
|
|
465
|
-
async hasUncommittedChanges(feature, step) {
|
|
466
|
-
const worktreePath = this.getWorktreePath(feature, step);
|
|
467
|
-
try {
|
|
468
|
-
const worktreeGit = this.getGit(worktreePath);
|
|
469
|
-
const status = await worktreeGit.status();
|
|
470
|
-
return status.modified.length > 0 ||
|
|
471
|
-
status.not_added.length > 0 ||
|
|
472
|
-
status.staged.length > 0 ||
|
|
473
|
-
status.deleted.length > 0 ||
|
|
474
|
-
status.created.length > 0;
|
|
475
|
-
}
|
|
476
|
-
catch {
|
|
477
|
-
return false;
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
parseConflictsFromError(errorMessage) {
|
|
481
|
-
const conflicts = [];
|
|
482
|
-
const lines = errorMessage.split("\n");
|
|
483
|
-
for (const line of lines) {
|
|
484
|
-
if (line.includes("CONFLICT") && line.includes("Merge conflict in")) {
|
|
485
|
-
const match = line.match(/Merge conflict in (.+)/);
|
|
486
|
-
if (match)
|
|
487
|
-
conflicts.push(match[1]);
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
return conflicts;
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
export function createWorktreeService(projectDir) {
|
|
494
|
-
return new WorktreeService({
|
|
495
|
-
baseDir: projectDir,
|
|
496
|
-
hiveDir: path.join(projectDir, ".hive"),
|
|
497
|
-
});
|
|
498
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|
|
@@ -1,185 +0,0 @@
|
|
|
1
|
-
import { describe, it, expect, beforeEach, afterEach } from "bun:test";
|
|
2
|
-
import * as fs from "fs";
|
|
3
|
-
import * as path from "path";
|
|
4
|
-
import { WorktreeService } from "./worktreeService";
|
|
5
|
-
const TEST_ROOT = "/tmp/hive-test-worktree";
|
|
6
|
-
describe("WorktreeService", () => {
|
|
7
|
-
let service;
|
|
8
|
-
beforeEach(async () => {
|
|
9
|
-
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
|
10
|
-
fs.mkdirSync(TEST_ROOT, { recursive: true });
|
|
11
|
-
const { execSync } = await import("child_process");
|
|
12
|
-
execSync("git init", { cwd: TEST_ROOT });
|
|
13
|
-
execSync("git config user.email 'test@test.com'", { cwd: TEST_ROOT });
|
|
14
|
-
execSync("git config user.name 'Test'", { cwd: TEST_ROOT });
|
|
15
|
-
fs.writeFileSync(path.join(TEST_ROOT, "README.md"), "# Test");
|
|
16
|
-
execSync("git add . && git commit -m 'init'", { cwd: TEST_ROOT });
|
|
17
|
-
service = new WorktreeService({ baseDir: TEST_ROOT, hiveDir: path.join(TEST_ROOT, ".hive") });
|
|
18
|
-
});
|
|
19
|
-
afterEach(() => {
|
|
20
|
-
fs.rmSync(TEST_ROOT, { recursive: true, force: true });
|
|
21
|
-
});
|
|
22
|
-
describe("create", () => {
|
|
23
|
-
it("creates a worktree", async () => {
|
|
24
|
-
const result = await service.create("my-feature", "01-setup");
|
|
25
|
-
expect(result.path).toContain(".hive/.worktrees/my-feature/01-setup");
|
|
26
|
-
expect(result.branch).toBe("hive/my-feature/01-setup");
|
|
27
|
-
expect(fs.existsSync(result.path)).toBe(true);
|
|
28
|
-
});
|
|
29
|
-
it("worktree contains files from base branch", async () => {
|
|
30
|
-
const result = await service.create("my-feature", "01-setup");
|
|
31
|
-
expect(fs.existsSync(path.join(result.path, "README.md"))).toBe(true);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
describe("get", () => {
|
|
35
|
-
it("returns null for non-existing worktree", async () => {
|
|
36
|
-
const result = await service.get("nope", "nope");
|
|
37
|
-
expect(result).toBeNull();
|
|
38
|
-
});
|
|
39
|
-
it("returns worktree info after creation", async () => {
|
|
40
|
-
await service.create("my-feature", "01-task");
|
|
41
|
-
const result = await service.get("my-feature", "01-task");
|
|
42
|
-
expect(result).not.toBeNull();
|
|
43
|
-
expect(result.feature).toBe("my-feature");
|
|
44
|
-
expect(result.step).toBe("01-task");
|
|
45
|
-
});
|
|
46
|
-
});
|
|
47
|
-
describe("list", () => {
|
|
48
|
-
it("returns empty array when no worktrees", async () => {
|
|
49
|
-
const result = await service.list();
|
|
50
|
-
expect(result).toEqual([]);
|
|
51
|
-
});
|
|
52
|
-
it("lists all worktrees", async () => {
|
|
53
|
-
await service.create("feature-a", "01-task");
|
|
54
|
-
await service.create("feature-b", "01-task");
|
|
55
|
-
const result = await service.list();
|
|
56
|
-
expect(result.length).toBe(2);
|
|
57
|
-
});
|
|
58
|
-
it("filters by feature", async () => {
|
|
59
|
-
await service.create("feature-a", "01-task");
|
|
60
|
-
await service.create("feature-a", "02-task");
|
|
61
|
-
await service.create("feature-b", "01-task");
|
|
62
|
-
const result = await service.list("feature-a");
|
|
63
|
-
expect(result.length).toBe(2);
|
|
64
|
-
expect(result.every(w => w.feature === "feature-a")).toBe(true);
|
|
65
|
-
});
|
|
66
|
-
});
|
|
67
|
-
describe("remove", () => {
|
|
68
|
-
it("removes worktree", async () => {
|
|
69
|
-
const created = await service.create("my-feature", "01-task");
|
|
70
|
-
expect(fs.existsSync(created.path)).toBe(true);
|
|
71
|
-
await service.remove("my-feature", "01-task");
|
|
72
|
-
expect(fs.existsSync(created.path)).toBe(false);
|
|
73
|
-
});
|
|
74
|
-
it("removes branch when deleteBranch is true", async () => {
|
|
75
|
-
await service.create("my-feature", "01-task");
|
|
76
|
-
const { execSync } = await import("child_process");
|
|
77
|
-
await service.remove("my-feature", "01-task", true);
|
|
78
|
-
const branches = execSync("git branch", { cwd: TEST_ROOT, encoding: "utf-8" });
|
|
79
|
-
expect(branches).not.toContain("hive/my-feature/01-task");
|
|
80
|
-
});
|
|
81
|
-
});
|
|
82
|
-
describe("getDiff", () => {
|
|
83
|
-
it("returns empty diff when no changes", async () => {
|
|
84
|
-
await service.create("my-feature", "01-task");
|
|
85
|
-
const diff = await service.getDiff("my-feature", "01-task");
|
|
86
|
-
expect(diff.hasDiff).toBe(false);
|
|
87
|
-
expect(diff.diffContent).toBe("");
|
|
88
|
-
expect(diff.filesChanged).toEqual([]);
|
|
89
|
-
});
|
|
90
|
-
it("returns diff when files changed and committed", async () => {
|
|
91
|
-
const worktree = await service.create("my-feature", "01-task");
|
|
92
|
-
fs.writeFileSync(path.join(worktree.path, "new-file.txt"), "content");
|
|
93
|
-
const { execSync } = await import("child_process");
|
|
94
|
-
execSync("git add .", { cwd: worktree.path });
|
|
95
|
-
execSync("git commit -m 'add file'", { cwd: worktree.path });
|
|
96
|
-
const diff = await service.getDiff("my-feature", "01-task");
|
|
97
|
-
expect(diff.hasDiff).toBe(true);
|
|
98
|
-
expect(diff.filesChanged).toContain("new-file.txt");
|
|
99
|
-
});
|
|
100
|
-
it("returns diff for uncommitted staged changes", async () => {
|
|
101
|
-
const worktree = await service.create("my-feature", "01-task");
|
|
102
|
-
fs.writeFileSync(path.join(worktree.path, "uncommitted.txt"), "staged content");
|
|
103
|
-
const diff = await service.getDiff("my-feature", "01-task");
|
|
104
|
-
expect(diff.hasDiff).toBe(true);
|
|
105
|
-
expect(diff.filesChanged).toContain("uncommitted.txt");
|
|
106
|
-
});
|
|
107
|
-
});
|
|
108
|
-
describe("commitChanges", () => {
|
|
109
|
-
it("commits all staged changes", async () => {
|
|
110
|
-
const worktree = await service.create("my-feature", "01-task");
|
|
111
|
-
fs.writeFileSync(path.join(worktree.path, "file.txt"), "content");
|
|
112
|
-
const result = await service.commitChanges("my-feature", "01-task", "test commit");
|
|
113
|
-
expect(result.committed).toBe(true);
|
|
114
|
-
expect(result.sha).toBeTruthy();
|
|
115
|
-
});
|
|
116
|
-
it("returns committed=false when no changes", async () => {
|
|
117
|
-
await service.create("my-feature", "01-task");
|
|
118
|
-
const result = await service.commitChanges("my-feature", "01-task");
|
|
119
|
-
expect(result.committed).toBe(false);
|
|
120
|
-
expect(result.message).toBe("No changes to commit");
|
|
121
|
-
});
|
|
122
|
-
it("returns error when worktree not found", async () => {
|
|
123
|
-
const result = await service.commitChanges("nope", "nope");
|
|
124
|
-
expect(result.committed).toBe(false);
|
|
125
|
-
expect(result.message).toBe("Worktree not found");
|
|
126
|
-
});
|
|
127
|
-
});
|
|
128
|
-
describe("hasUncommittedChanges", () => {
|
|
129
|
-
it("returns false when no changes", async () => {
|
|
130
|
-
await service.create("my-feature", "01-task");
|
|
131
|
-
const result = await service.hasUncommittedChanges("my-feature", "01-task");
|
|
132
|
-
expect(result).toBe(false);
|
|
133
|
-
});
|
|
134
|
-
it("returns true when files modified", async () => {
|
|
135
|
-
const worktree = await service.create("my-feature", "01-task");
|
|
136
|
-
fs.writeFileSync(path.join(worktree.path, "new.txt"), "content");
|
|
137
|
-
const result = await service.hasUncommittedChanges("my-feature", "01-task");
|
|
138
|
-
expect(result).toBe(true);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
141
|
-
describe("merge", () => {
|
|
142
|
-
it("merges task branch into main", async () => {
|
|
143
|
-
const worktree = await service.create("my-feature", "01-task");
|
|
144
|
-
fs.writeFileSync(path.join(worktree.path, "feature.txt"), "feature content");
|
|
145
|
-
const { execSync } = await import("child_process");
|
|
146
|
-
execSync("git add . && git commit -m 'feature'", { cwd: worktree.path });
|
|
147
|
-
const result = await service.merge("my-feature", "01-task");
|
|
148
|
-
expect(result.success).toBe(true);
|
|
149
|
-
expect(result.merged).toBe(true);
|
|
150
|
-
expect(fs.existsSync(path.join(TEST_ROOT, "feature.txt"))).toBe(true);
|
|
151
|
-
});
|
|
152
|
-
it("returns error for non-existent branch", async () => {
|
|
153
|
-
const result = await service.merge("nope", "nope");
|
|
154
|
-
expect(result.success).toBe(false);
|
|
155
|
-
expect(result.error).toContain("not found");
|
|
156
|
-
});
|
|
157
|
-
it("supports squash strategy", async () => {
|
|
158
|
-
const worktree = await service.create("my-feature", "01-task");
|
|
159
|
-
fs.writeFileSync(path.join(worktree.path, "file1.txt"), "1");
|
|
160
|
-
const { execSync } = await import("child_process");
|
|
161
|
-
execSync("git add . && git commit -m 'commit 1'", { cwd: worktree.path });
|
|
162
|
-
fs.writeFileSync(path.join(worktree.path, "file2.txt"), "2");
|
|
163
|
-
execSync("git add . && git commit -m 'commit 2'", { cwd: worktree.path });
|
|
164
|
-
const result = await service.merge("my-feature", "01-task", "squash");
|
|
165
|
-
expect(result.success).toBe(true);
|
|
166
|
-
expect(result.merged).toBe(true);
|
|
167
|
-
});
|
|
168
|
-
});
|
|
169
|
-
describe("cleanup", () => {
|
|
170
|
-
it("removes invalid worktrees for a feature", async () => {
|
|
171
|
-
const wt1 = await service.create("cleanup-test", "01-task");
|
|
172
|
-
const wt2 = await service.create("cleanup-test", "02-task");
|
|
173
|
-
fs.writeFileSync(path.join(wt1.path, ".git"), "gitdir: /nonexistent\n");
|
|
174
|
-
fs.writeFileSync(path.join(wt2.path, ".git"), "gitdir: /nonexistent\n");
|
|
175
|
-
expect(fs.existsSync(wt1.path)).toBe(true);
|
|
176
|
-
expect(fs.existsSync(wt2.path)).toBe(true);
|
|
177
|
-
const result = await service.cleanup("cleanup-test");
|
|
178
|
-
expect(result.removed.length).toBe(2);
|
|
179
|
-
expect(fs.existsSync(wt1.path)).toBe(false);
|
|
180
|
-
expect(fs.existsSync(wt2.path)).toBe(false);
|
|
181
|
-
const remaining = await service.list("cleanup-test");
|
|
182
|
-
expect(remaining).toEqual([]);
|
|
183
|
-
});
|
|
184
|
-
});
|
|
185
|
-
});
|