opencode-hive 0.5.1 → 0.6.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/dist/index.js
CHANGED
|
@@ -12,7 +12,7 @@ const HIVE_SYSTEM_PROMPT = `
|
|
|
12
12
|
|
|
13
13
|
Plan-first development: Write plan → User reviews → Approve → Execute tasks
|
|
14
14
|
|
|
15
|
-
### Tools (
|
|
15
|
+
### Tools (17 total)
|
|
16
16
|
|
|
17
17
|
| Domain | Tools |
|
|
18
18
|
|--------|-------|
|
|
@@ -20,6 +20,7 @@ Plan-first development: Write plan → User reviews → Approve → Execute task
|
|
|
20
20
|
| Plan | hive_plan_write, hive_plan_read, hive_plan_approve |
|
|
21
21
|
| Task | hive_tasks_sync, hive_task_create, hive_task_update |
|
|
22
22
|
| Exec | hive_exec_start, hive_exec_complete, hive_exec_abort |
|
|
23
|
+
| Merge | hive_merge, hive_worktree_list |
|
|
23
24
|
| Context | hive_context_write, hive_context_read, hive_context_list |
|
|
24
25
|
| Session | hive_session_open, hive_session_list |
|
|
25
26
|
|
|
@@ -30,7 +31,11 @@ Plan-first development: Write plan → User reviews → Approve → Execute task
|
|
|
30
31
|
3. User adds comments in VSCode → \`hive_plan_read\` to see them
|
|
31
32
|
4. Revise plan → User approves
|
|
32
33
|
5. \`hive_tasks_sync()\` - Generate tasks from plan
|
|
33
|
-
6. \`hive_exec_start(task)\` → work → \`hive_exec_complete(task, summary)\`
|
|
34
|
+
6. \`hive_exec_start(task)\` → work in worktree → \`hive_exec_complete(task, summary)\`
|
|
35
|
+
7. \`hive_merge(task)\` - Merge task branch into main (when ready)
|
|
36
|
+
|
|
37
|
+
**Important:** \`hive_exec_complete\` commits changes to task branch but does NOT merge.
|
|
38
|
+
Use \`hive_merge\` to explicitly integrate changes. Worktrees persist until manually removed.
|
|
34
39
|
|
|
35
40
|
### Plan Format
|
|
36
41
|
|
|
@@ -299,7 +304,7 @@ const plugin = async (ctx) => {
|
|
|
299
304
|
},
|
|
300
305
|
}),
|
|
301
306
|
hive_exec_complete: tool({
|
|
302
|
-
description: 'Complete task:
|
|
307
|
+
description: 'Complete task: commit changes to branch, write report (does NOT merge or cleanup)',
|
|
303
308
|
args: {
|
|
304
309
|
task: tool.schema.string().describe('Task folder name'),
|
|
305
310
|
summary: tool.schema.string().describe('Summary of what was done'),
|
|
@@ -314,22 +319,15 @@ const plugin = async (ctx) => {
|
|
|
314
319
|
return `Error: Task "${task}" not found`;
|
|
315
320
|
if (taskInfo.status !== 'in_progress')
|
|
316
321
|
return "Error: Task not in progress";
|
|
322
|
+
const commitResult = await worktreeService.commitChanges(feature, task, `hive(${task}): ${summary.slice(0, 50)}`);
|
|
317
323
|
const diff = await worktreeService.getDiff(feature, task);
|
|
318
|
-
let changesApplied = false;
|
|
319
|
-
let applyError = "";
|
|
320
|
-
if (diff?.hasDiff) {
|
|
321
|
-
const result = await worktreeService.applyDiff(feature, task);
|
|
322
|
-
changesApplied = result.success;
|
|
323
|
-
if (!result.success) {
|
|
324
|
-
applyError = result.error || "Unknown apply error";
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
324
|
const reportLines = [
|
|
328
325
|
`# Task Report: ${task}`,
|
|
329
326
|
'',
|
|
330
327
|
`**Feature:** ${feature}`,
|
|
331
328
|
`**Completed:** ${new Date().toISOString()}`,
|
|
332
|
-
`**Status:**
|
|
329
|
+
`**Status:** success`,
|
|
330
|
+
`**Commit:** ${commitResult.sha || 'none'}`,
|
|
333
331
|
'',
|
|
334
332
|
'---',
|
|
335
333
|
'',
|
|
@@ -353,11 +351,8 @@ const plugin = async (ctx) => {
|
|
|
353
351
|
}
|
|
354
352
|
taskService.writeReport(feature, task, reportLines.join('\n'));
|
|
355
353
|
taskService.update(feature, task, { status: 'done', summary });
|
|
356
|
-
await worktreeService.
|
|
357
|
-
|
|
358
|
-
return `Task "${task}" completed but changes failed to apply: ${applyError}`;
|
|
359
|
-
}
|
|
360
|
-
return `Task "${task}" completed.${changesApplied ? " Changes applied." : ""}`;
|
|
354
|
+
const worktree = await worktreeService.get(feature, task);
|
|
355
|
+
return `Task "${task}" completed. Changes committed to branch ${worktree?.branch || 'unknown'}.\nUse hive_merge to integrate changes. Worktree preserved at ${worktree?.path || 'unknown'}.`;
|
|
361
356
|
},
|
|
362
357
|
}),
|
|
363
358
|
hive_exec_abort: tool({
|
|
@@ -375,6 +370,52 @@ const plugin = async (ctx) => {
|
|
|
375
370
|
return `Task "${task}" aborted. Status reset to pending.`;
|
|
376
371
|
},
|
|
377
372
|
}),
|
|
373
|
+
hive_merge: tool({
|
|
374
|
+
description: 'Merge completed task branch into current branch (explicit integration)',
|
|
375
|
+
args: {
|
|
376
|
+
task: tool.schema.string().describe('Task folder name to merge'),
|
|
377
|
+
strategy: tool.schema.enum(['merge', 'squash', 'rebase']).optional().describe('Merge strategy (default: merge)'),
|
|
378
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
379
|
+
},
|
|
380
|
+
async execute({ task, strategy = 'merge', feature: explicitFeature }) {
|
|
381
|
+
const feature = resolveFeature(explicitFeature);
|
|
382
|
+
if (!feature)
|
|
383
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
384
|
+
const taskInfo = taskService.get(feature, task);
|
|
385
|
+
if (!taskInfo)
|
|
386
|
+
return `Error: Task "${task}" not found`;
|
|
387
|
+
if (taskInfo.status !== 'done')
|
|
388
|
+
return "Error: Task must be completed before merging. Use hive_exec_complete first.";
|
|
389
|
+
const result = await worktreeService.merge(feature, task, strategy);
|
|
390
|
+
if (!result.success) {
|
|
391
|
+
if (result.conflicts && result.conflicts.length > 0) {
|
|
392
|
+
return `Merge failed with conflicts in:\n${result.conflicts.map(f => `- ${f}`).join('\n')}\n\nResolve conflicts manually or try a different strategy.`;
|
|
393
|
+
}
|
|
394
|
+
return `Merge failed: ${result.error}`;
|
|
395
|
+
}
|
|
396
|
+
return `Task "${task}" merged successfully using ${strategy} strategy.\nCommit: ${result.sha}\nFiles changed: ${result.filesChanged?.length || 0}`;
|
|
397
|
+
},
|
|
398
|
+
}),
|
|
399
|
+
hive_worktree_list: tool({
|
|
400
|
+
description: 'List all worktrees for current feature',
|
|
401
|
+
args: {
|
|
402
|
+
feature: tool.schema.string().optional().describe('Feature name (defaults to active)'),
|
|
403
|
+
},
|
|
404
|
+
async execute({ feature: explicitFeature }) {
|
|
405
|
+
const feature = resolveFeature(explicitFeature);
|
|
406
|
+
if (!feature)
|
|
407
|
+
return "Error: No feature specified. Create a feature or provide feature param.";
|
|
408
|
+
const worktrees = await worktreeService.list(feature);
|
|
409
|
+
if (worktrees.length === 0)
|
|
410
|
+
return "No worktrees found for this feature.";
|
|
411
|
+
const lines = ['| Task | Branch | Has Changes |', '|------|--------|-------------|'];
|
|
412
|
+
for (const wt of worktrees) {
|
|
413
|
+
const hasChanges = await worktreeService.hasUncommittedChanges(wt.feature, wt.step);
|
|
414
|
+
lines.push(`| ${wt.step} | ${wt.branch} | ${hasChanges ? 'Yes' : 'No'} |`);
|
|
415
|
+
}
|
|
416
|
+
return lines.join('\n');
|
|
417
|
+
},
|
|
418
|
+
}),
|
|
378
419
|
// Context Tools
|
|
379
420
|
hive_context_write: tool({
|
|
380
421
|
description: 'Write a context file for the feature. Context files store persistent notes, decisions, and reference material.',
|
|
@@ -17,6 +17,19 @@ export interface ApplyResult {
|
|
|
17
17
|
error?: string;
|
|
18
18
|
filesAffected: string[];
|
|
19
19
|
}
|
|
20
|
+
export interface CommitResult {
|
|
21
|
+
committed: boolean;
|
|
22
|
+
sha: string;
|
|
23
|
+
message?: string;
|
|
24
|
+
}
|
|
25
|
+
export interface MergeResult {
|
|
26
|
+
success: boolean;
|
|
27
|
+
merged: boolean;
|
|
28
|
+
sha?: string;
|
|
29
|
+
filesChanged?: string[];
|
|
30
|
+
conflicts?: string[];
|
|
31
|
+
error?: string;
|
|
32
|
+
}
|
|
20
33
|
export interface WorktreeConfig {
|
|
21
34
|
baseDir: string;
|
|
22
35
|
hiveDir: string;
|
|
@@ -45,5 +58,9 @@ export declare class WorktreeService {
|
|
|
45
58
|
}>;
|
|
46
59
|
checkConflicts(feature: string, step: string, baseBranch?: string): Promise<string[]>;
|
|
47
60
|
checkConflictsFromSavedDiff(diffPath: string, reverse?: boolean): Promise<string[]>;
|
|
61
|
+
commitChanges(feature: string, step: string, message?: string): Promise<CommitResult>;
|
|
62
|
+
merge(feature: string, step: string, strategy?: 'merge' | 'squash' | 'rebase'): Promise<MergeResult>;
|
|
63
|
+
hasUncommittedChanges(feature: string, step: string): Promise<boolean>;
|
|
64
|
+
private parseConflictsFromError;
|
|
48
65
|
}
|
|
49
66
|
export declare function createWorktreeService(projectDir: string): WorktreeService;
|
|
@@ -97,8 +97,18 @@ export class WorktreeService {
|
|
|
97
97
|
const worktreeGit = this.getGit(worktreePath);
|
|
98
98
|
try {
|
|
99
99
|
await worktreeGit.raw(["add", "-A"]);
|
|
100
|
-
const
|
|
101
|
-
const
|
|
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
|
+
}
|
|
102
112
|
const statLines = stat.split("\n").filter((l) => l.trim());
|
|
103
113
|
const filesChanged = statLines
|
|
104
114
|
.slice(0, -1)
|
|
@@ -347,6 +357,138 @@ export class WorktreeService {
|
|
|
347
357
|
return conflicts;
|
|
348
358
|
}
|
|
349
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
|
+
}
|
|
350
492
|
}
|
|
351
493
|
export function createWorktreeService(projectDir) {
|
|
352
494
|
return new WorktreeService({
|
|
@@ -87,7 +87,7 @@ describe("WorktreeService", () => {
|
|
|
87
87
|
expect(diff.diffContent).toBe("");
|
|
88
88
|
expect(diff.filesChanged).toEqual([]);
|
|
89
89
|
});
|
|
90
|
-
it("returns diff when files changed", async () => {
|
|
90
|
+
it("returns diff when files changed and committed", async () => {
|
|
91
91
|
const worktree = await service.create("my-feature", "01-task");
|
|
92
92
|
fs.writeFileSync(path.join(worktree.path, "new-file.txt"), "content");
|
|
93
93
|
const { execSync } = await import("child_process");
|
|
@@ -97,6 +97,74 @@ describe("WorktreeService", () => {
|
|
|
97
97
|
expect(diff.hasDiff).toBe(true);
|
|
98
98
|
expect(diff.filesChanged).toContain("new-file.txt");
|
|
99
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
|
+
});
|
|
100
168
|
});
|
|
101
169
|
describe("cleanup", () => {
|
|
102
170
|
it("removes invalid worktrees for a feature", async () => {
|