opencode-pilot 0.14.1 → 0.15.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/README.md +21 -6
- package/examples/config.yaml +10 -0
- package/package.json +1 -1
- package/service/actions.js +30 -4
- package/service/poll-service.js +4 -3
- package/service/worktree.js +182 -0
- package/test/unit/actions.test.js +122 -0
- package/test/unit/poll-service.test.js +21 -5
- package/test/unit/worktree.test.js +250 -0
package/README.md
CHANGED
|
@@ -70,6 +70,27 @@ Three ways to configure sources, from simplest to most flexible:
|
|
|
70
70
|
|
|
71
71
|
Create prompt templates as markdown files in `~/.config/opencode/pilot/templates/`. Templates support placeholders like `{title}`, `{body}`, `{number}`, `{html_url}`, etc.
|
|
72
72
|
|
|
73
|
+
### Worktree Support
|
|
74
|
+
|
|
75
|
+
Run sessions in isolated git worktrees instead of the main project directory. This uses OpenCode's built-in worktree management API to create and manage worktrees.
|
|
76
|
+
|
|
77
|
+
```yaml
|
|
78
|
+
sources:
|
|
79
|
+
- preset: github/my-issues
|
|
80
|
+
# Create a fresh worktree for each session
|
|
81
|
+
worktree: "new"
|
|
82
|
+
worktree_name: "issue-{number}" # Optional: name template
|
|
83
|
+
|
|
84
|
+
- preset: linear/my-issues
|
|
85
|
+
# Use an existing worktree by name
|
|
86
|
+
worktree: "my-feature-branch"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**Options:**
|
|
90
|
+
- `worktree: "new"` - Create a new worktree via OpenCode's API
|
|
91
|
+
- `worktree: "name"` - Look up existing worktree by name from project sandboxes
|
|
92
|
+
- `worktree_name` - Template for naming new worktrees (only with `worktree: "new"`)
|
|
93
|
+
|
|
73
94
|
## CLI Commands
|
|
74
95
|
|
|
75
96
|
```bash
|
|
@@ -92,12 +113,6 @@ opencode-pilot test-mapping MCP # Test field mappings
|
|
|
92
113
|
3. **Spawn sessions** - Start `opencode run` with the appropriate prompt template
|
|
93
114
|
4. **Track state** - Remember which items have been processed
|
|
94
115
|
|
|
95
|
-
## Known Issues
|
|
96
|
-
|
|
97
|
-
None currently! Previous issues have been resolved:
|
|
98
|
-
|
|
99
|
-
- ~~Sessions attached to global server run in wrong directory~~ - Fixed in v0.14.0 by using the HTTP API with `?directory=` parameter instead of `opencode run --attach`
|
|
100
|
-
|
|
101
116
|
## Related
|
|
102
117
|
|
|
103
118
|
- [opencode-devcontainers](https://github.com/athal7/opencode-devcontainers) - Run multiple devcontainer instances for OpenCode
|
package/examples/config.yaml
CHANGED
|
@@ -33,6 +33,16 @@ sources:
|
|
|
33
33
|
working_dir: ~/code/myproject
|
|
34
34
|
prompt: worktree
|
|
35
35
|
|
|
36
|
+
# Example with worktree support - run each issue in a fresh git worktree
|
|
37
|
+
# - preset: github/my-issues
|
|
38
|
+
# worktree: "new" # Create a new worktree per session
|
|
39
|
+
# worktree_name: "issue-{number}" # Name template for new worktrees
|
|
40
|
+
# prompt: worktree
|
|
41
|
+
|
|
42
|
+
# Use an existing worktree by name
|
|
43
|
+
# - preset: github/review-requests
|
|
44
|
+
# worktree: "review-worktree" # Must exist in project sandboxes
|
|
45
|
+
|
|
36
46
|
# GitHub shorthand syntax
|
|
37
47
|
# - name: urgent-issues
|
|
38
48
|
# github: "is:issue assignee:@me label:urgent state:open"
|
package/package.json
CHANGED
package/service/actions.js
CHANGED
|
@@ -10,6 +10,7 @@ import { readFileSync, existsSync } from "fs";
|
|
|
10
10
|
import { debug } from "./logger.js";
|
|
11
11
|
import { getNestedValue } from "./utils.js";
|
|
12
12
|
import { getServerPort } from "./repo-config.js";
|
|
13
|
+
import { resolveWorktreeDirectory } from "./worktree.js";
|
|
13
14
|
import path from "path";
|
|
14
15
|
import os from "os";
|
|
15
16
|
|
|
@@ -560,15 +561,40 @@ export async function createSessionViaApi(serverUrl, directory, prompt, options
|
|
|
560
561
|
* @returns {Promise<object>} Result with command, stdout, stderr, exitCode
|
|
561
562
|
*/
|
|
562
563
|
export async function executeAction(item, config, options = {}) {
|
|
563
|
-
// Get working directory first to determine which server to attach to
|
|
564
|
+
// Get base working directory first to determine which server to attach to
|
|
564
565
|
const workingDir = config.working_dir || config.path || config.repo_path || "~";
|
|
565
|
-
const
|
|
566
|
+
const baseCwd = expandPath(workingDir);
|
|
566
567
|
|
|
567
568
|
// Discover running opencode server for this directory
|
|
568
569
|
const discoverFn = options.discoverServer || discoverOpencodeServer;
|
|
569
|
-
const serverUrl = await discoverFn(
|
|
570
|
+
const serverUrl = await discoverFn(baseCwd);
|
|
571
|
+
|
|
572
|
+
debug(`executeAction: discovered server=${serverUrl} for baseCwd=${baseCwd}`);
|
|
573
|
+
|
|
574
|
+
// Resolve worktree directory if configured
|
|
575
|
+
// This allows creating sessions in isolated worktrees instead of the main project
|
|
576
|
+
const worktreeConfig = {
|
|
577
|
+
worktree: config.worktree,
|
|
578
|
+
// Expand worktree_name template with item fields (e.g., "issue-{number}")
|
|
579
|
+
worktreeName: config.worktree_name ? expandTemplate(config.worktree_name, item) : undefined,
|
|
580
|
+
};
|
|
581
|
+
|
|
582
|
+
const worktreeResult = await resolveWorktreeDirectory(
|
|
583
|
+
serverUrl,
|
|
584
|
+
baseCwd,
|
|
585
|
+
worktreeConfig,
|
|
586
|
+
{ fetch: options.fetch }
|
|
587
|
+
);
|
|
588
|
+
|
|
589
|
+
const cwd = expandPath(worktreeResult.directory);
|
|
590
|
+
|
|
591
|
+
if (worktreeResult.worktreeCreated) {
|
|
592
|
+
debug(`executeAction: created new worktree at ${cwd}`);
|
|
593
|
+
} else if (worktreeResult.error) {
|
|
594
|
+
debug(`executeAction: worktree resolution warning - ${worktreeResult.error}`);
|
|
595
|
+
}
|
|
570
596
|
|
|
571
|
-
debug(`executeAction:
|
|
597
|
+
debug(`executeAction: using cwd=${cwd}`);
|
|
572
598
|
|
|
573
599
|
// If a server is running, use the HTTP API to create the session
|
|
574
600
|
// This is a workaround for the known issue where --attach doesn't support --dir
|
package/service/poll-service.js
CHANGED
|
@@ -22,11 +22,12 @@ const DEFAULT_POLL_INTERVAL = 5 * 60 * 1000; // 5 minutes
|
|
|
22
22
|
|
|
23
23
|
/**
|
|
24
24
|
* Check if a source has tool configuration
|
|
25
|
+
* @see getToolConfig in poller.js for actual tool config resolution
|
|
25
26
|
* @param {object} source - Source configuration
|
|
26
|
-
* @returns {boolean} True if source has tool.mcp and tool.name
|
|
27
|
+
* @returns {boolean} True if source has tool.command or (tool.mcp and tool.name)
|
|
27
28
|
*/
|
|
28
29
|
export function hasToolConfig(source) {
|
|
29
|
-
return !!(source.tool && source.tool.mcp && source.tool.name);
|
|
30
|
+
return !!(source.tool && (source.tool.command || (source.tool.mcp && source.tool.name)));
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
/**
|
|
@@ -113,7 +114,7 @@ export async function pollOnce(options = {}) {
|
|
|
113
114
|
const sourceName = source.name || 'unknown';
|
|
114
115
|
|
|
115
116
|
if (!hasToolConfig(source)) {
|
|
116
|
-
console.error(`[poll] Source '${sourceName}' missing tool configuration (requires tool.mcp and tool.name)`);
|
|
117
|
+
console.error(`[poll] Source '${sourceName}' missing tool configuration (requires tool.command or tool.mcp and tool.name)`);
|
|
117
118
|
continue;
|
|
118
119
|
}
|
|
119
120
|
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* worktree.js - Worktree management for OpenCode sessions
|
|
3
|
+
*
|
|
4
|
+
* Interacts with OpenCode server to list and create worktrees (sandboxes).
|
|
5
|
+
* Worktrees allow running sessions in isolated git branches/directories.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { debug } from "./logger.js";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* List available worktrees/sandboxes for a project
|
|
12
|
+
*
|
|
13
|
+
* @param {string} serverUrl - OpenCode server URL (e.g., "http://localhost:4096")
|
|
14
|
+
* @param {object} [options] - Options
|
|
15
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
16
|
+
* @returns {Promise<string[]>} Array of worktree directory paths
|
|
17
|
+
*/
|
|
18
|
+
export async function listWorktrees(serverUrl, options = {}) {
|
|
19
|
+
const fetchFn = options.fetch || fetch;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const response = await fetchFn(`${serverUrl}/experimental/worktree`);
|
|
23
|
+
|
|
24
|
+
if (!response.ok) {
|
|
25
|
+
debug(`listWorktrees: ${serverUrl} returned ${response.status}`);
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const worktrees = await response.json();
|
|
30
|
+
debug(`listWorktrees: found ${worktrees.length} worktrees`);
|
|
31
|
+
return Array.isArray(worktrees) ? worktrees : [];
|
|
32
|
+
} catch (err) {
|
|
33
|
+
debug(`listWorktrees: error - ${err.message}`);
|
|
34
|
+
return [];
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Create a new worktree for a project
|
|
40
|
+
*
|
|
41
|
+
* @param {string} serverUrl - OpenCode server URL (e.g., "http://localhost:4096")
|
|
42
|
+
* @param {object} [options] - Options
|
|
43
|
+
* @param {string} [options.name] - Optional name for the worktree
|
|
44
|
+
* @param {string} [options.startCommand] - Optional startup script to run after creation
|
|
45
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
46
|
+
* @returns {Promise<object>} Result with { success, worktree?, error? }
|
|
47
|
+
*/
|
|
48
|
+
export async function createWorktree(serverUrl, options = {}) {
|
|
49
|
+
const fetchFn = options.fetch || fetch;
|
|
50
|
+
|
|
51
|
+
try {
|
|
52
|
+
const body = {};
|
|
53
|
+
if (options.name) body.name = options.name;
|
|
54
|
+
if (options.startCommand) body.startCommand = options.startCommand;
|
|
55
|
+
|
|
56
|
+
const response = await fetchFn(`${serverUrl}/experimental/worktree`, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'Content-Type': 'application/json' },
|
|
59
|
+
body: JSON.stringify(body),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
if (!response.ok) {
|
|
63
|
+
const errorText = await response.text();
|
|
64
|
+
debug(`createWorktree: ${serverUrl} returned ${response.status} - ${errorText}`);
|
|
65
|
+
return {
|
|
66
|
+
success: false,
|
|
67
|
+
error: `Failed to create worktree: ${response.status} ${errorText}`,
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const worktree = await response.json();
|
|
72
|
+
debug(`createWorktree: created worktree ${worktree.name} at ${worktree.directory}`);
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
success: true,
|
|
76
|
+
worktree,
|
|
77
|
+
};
|
|
78
|
+
} catch (err) {
|
|
79
|
+
debug(`createWorktree: error - ${err.message}`);
|
|
80
|
+
return {
|
|
81
|
+
success: false,
|
|
82
|
+
error: err.message,
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Get project info including sandboxes from the server
|
|
89
|
+
*
|
|
90
|
+
* @param {string} serverUrl - OpenCode server URL
|
|
91
|
+
* @param {object} [options] - Options
|
|
92
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
93
|
+
* @returns {Promise<object|null>} Project info or null if unavailable
|
|
94
|
+
*/
|
|
95
|
+
export async function getProjectInfo(serverUrl, options = {}) {
|
|
96
|
+
const fetchFn = options.fetch || fetch;
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const response = await fetchFn(`${serverUrl}/project/current`);
|
|
100
|
+
|
|
101
|
+
if (!response.ok) {
|
|
102
|
+
debug(`getProjectInfo: ${serverUrl} returned ${response.status}`);
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const project = await response.json();
|
|
107
|
+
debug(`getProjectInfo: project ${project.id} with ${project.sandboxes?.length || 0} sandboxes`);
|
|
108
|
+
return project;
|
|
109
|
+
} catch (err) {
|
|
110
|
+
debug(`getProjectInfo: error - ${err.message}`);
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolve the working directory based on worktree configuration
|
|
117
|
+
*
|
|
118
|
+
* Uses OpenCode's experimental worktree API:
|
|
119
|
+
* - GET /experimental/worktree - List existing worktrees
|
|
120
|
+
* - POST /experimental/worktree - Create new worktree
|
|
121
|
+
*
|
|
122
|
+
* @param {string} serverUrl - OpenCode server URL
|
|
123
|
+
* @param {string} baseDir - Base working directory from config
|
|
124
|
+
* @param {object} worktreeConfig - Worktree configuration
|
|
125
|
+
* @param {string} [worktreeConfig.worktree] - Worktree mode: "new" or worktree name
|
|
126
|
+
* @param {string} [worktreeConfig.worktreeName] - Name for new worktree (only with "new")
|
|
127
|
+
* @param {object} [options] - Options
|
|
128
|
+
* @param {function} [options.fetch] - Custom fetch function (for testing)
|
|
129
|
+
* @returns {Promise<object>} Result with { directory, worktreeCreated?, error? }
|
|
130
|
+
*/
|
|
131
|
+
export async function resolveWorktreeDirectory(serverUrl, baseDir, worktreeConfig, options = {}) {
|
|
132
|
+
// No worktree config - use base directory
|
|
133
|
+
if (!worktreeConfig?.worktree) {
|
|
134
|
+
return { directory: baseDir };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Require server for any worktree operation
|
|
138
|
+
if (!serverUrl) {
|
|
139
|
+
return {
|
|
140
|
+
directory: baseDir,
|
|
141
|
+
error: "Cannot use worktree: no server running",
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const worktreeValue = worktreeConfig.worktree;
|
|
146
|
+
|
|
147
|
+
// "new" - create a fresh worktree via OpenCode API
|
|
148
|
+
if (worktreeValue === "new") {
|
|
149
|
+
const result = await createWorktree(serverUrl, {
|
|
150
|
+
name: worktreeConfig.worktreeName,
|
|
151
|
+
fetch: options.fetch,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
if (!result.success) {
|
|
155
|
+
return {
|
|
156
|
+
directory: baseDir,
|
|
157
|
+
error: result.error,
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
return {
|
|
162
|
+
directory: result.worktree.directory,
|
|
163
|
+
worktreeCreated: true,
|
|
164
|
+
worktree: result.worktree,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Named worktree - look it up from available sandboxes via OpenCode API
|
|
169
|
+
const worktrees = await listWorktrees(serverUrl, options);
|
|
170
|
+
const match = worktrees.find(w => w.includes(worktreeValue));
|
|
171
|
+
if (match) {
|
|
172
|
+
return { directory: match };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
debug(`resolveWorktreeDirectory: worktree "${worktreeValue}" not found in available sandboxes`);
|
|
176
|
+
|
|
177
|
+
// Fallback to base directory
|
|
178
|
+
return {
|
|
179
|
+
directory: baseDir,
|
|
180
|
+
error: `Worktree "${worktreeValue}" not found in project sandboxes`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
@@ -571,6 +571,128 @@ describe('actions.js', () => {
|
|
|
571
571
|
assert.ok(!result.command.includes('--attach'), 'Command should not include --attach flag');
|
|
572
572
|
assert.ok(result.command.includes('opencode run'), 'Command should include opencode run');
|
|
573
573
|
});
|
|
574
|
+
|
|
575
|
+
test('creates new worktree when worktree: "new" is configured (dry run)', async () => {
|
|
576
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
577
|
+
|
|
578
|
+
const item = { number: 123, title: 'Fix bug' };
|
|
579
|
+
const config = {
|
|
580
|
+
path: tempDir,
|
|
581
|
+
prompt: 'default',
|
|
582
|
+
worktree: 'new',
|
|
583
|
+
worktree_name: 'feature-branch'
|
|
584
|
+
};
|
|
585
|
+
|
|
586
|
+
// Mock server discovery
|
|
587
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
588
|
+
|
|
589
|
+
// Mock worktree creation via fetch
|
|
590
|
+
const mockFetch = async (url, opts) => {
|
|
591
|
+
// Worktree creation endpoint
|
|
592
|
+
if (url === 'http://localhost:4096/experimental/worktree' && opts?.method === 'POST') {
|
|
593
|
+
const body = JSON.parse(opts.body);
|
|
594
|
+
assert.strictEqual(body.name, 'feature-branch', 'Should pass worktree name');
|
|
595
|
+
return {
|
|
596
|
+
ok: true,
|
|
597
|
+
json: async () => ({
|
|
598
|
+
name: 'feature-branch',
|
|
599
|
+
branch: 'opencode/feature-branch',
|
|
600
|
+
directory: '/data/worktree/proj123/feature-branch'
|
|
601
|
+
})
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
return { ok: false, text: async () => 'Not found' };
|
|
605
|
+
};
|
|
606
|
+
|
|
607
|
+
const result = await executeAction(item, config, {
|
|
608
|
+
dryRun: true,
|
|
609
|
+
discoverServer: mockDiscoverServer,
|
|
610
|
+
fetch: mockFetch
|
|
611
|
+
});
|
|
612
|
+
|
|
613
|
+
assert.ok(result.dryRun);
|
|
614
|
+
assert.strictEqual(result.method, 'api', 'Should use API method');
|
|
615
|
+
// The directory in the command should be the worktree directory
|
|
616
|
+
assert.ok(result.command.includes('/data/worktree/proj123/feature-branch'),
|
|
617
|
+
'Should use worktree directory in command');
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
test('uses existing worktree by name (dry run)', async () => {
|
|
621
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
622
|
+
|
|
623
|
+
const item = { number: 123, title: 'Fix bug' };
|
|
624
|
+
const config = {
|
|
625
|
+
path: tempDir,
|
|
626
|
+
prompt: 'default',
|
|
627
|
+
worktree: 'my-feature'
|
|
628
|
+
};
|
|
629
|
+
|
|
630
|
+
// Mock server discovery
|
|
631
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
632
|
+
|
|
633
|
+
// Mock worktree list lookup
|
|
634
|
+
const mockFetch = async (url) => {
|
|
635
|
+
if (url === 'http://localhost:4096/experimental/worktree') {
|
|
636
|
+
return {
|
|
637
|
+
ok: true,
|
|
638
|
+
json: async () => [
|
|
639
|
+
'/data/worktree/proj123/other-branch',
|
|
640
|
+
'/data/worktree/proj123/my-feature'
|
|
641
|
+
]
|
|
642
|
+
};
|
|
643
|
+
}
|
|
644
|
+
return { ok: false, text: async () => 'Not found' };
|
|
645
|
+
};
|
|
646
|
+
|
|
647
|
+
const result = await executeAction(item, config, {
|
|
648
|
+
dryRun: true,
|
|
649
|
+
discoverServer: mockDiscoverServer,
|
|
650
|
+
fetch: mockFetch
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
assert.ok(result.dryRun);
|
|
654
|
+
assert.strictEqual(result.method, 'api', 'Should use API method');
|
|
655
|
+
assert.ok(result.command.includes('/data/worktree/proj123/my-feature'),
|
|
656
|
+
'Should use looked up worktree path in command');
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test('falls back to base directory when worktree creation fails (dry run)', async () => {
|
|
660
|
+
const { executeAction } = await import('../../service/actions.js');
|
|
661
|
+
|
|
662
|
+
const item = { number: 123, title: 'Fix bug' };
|
|
663
|
+
const config = {
|
|
664
|
+
path: tempDir,
|
|
665
|
+
prompt: 'default',
|
|
666
|
+
worktree: 'new'
|
|
667
|
+
};
|
|
668
|
+
|
|
669
|
+
// Mock server discovery
|
|
670
|
+
const mockDiscoverServer = async () => 'http://localhost:4096';
|
|
671
|
+
|
|
672
|
+
// Mock worktree creation failure
|
|
673
|
+
const mockFetch = async (url, opts) => {
|
|
674
|
+
if (url === 'http://localhost:4096/experimental/worktree' && opts?.method === 'POST') {
|
|
675
|
+
return {
|
|
676
|
+
ok: false,
|
|
677
|
+
status: 500,
|
|
678
|
+
text: async () => 'Internal server error'
|
|
679
|
+
};
|
|
680
|
+
}
|
|
681
|
+
return { ok: false, text: async () => 'Not found' };
|
|
682
|
+
};
|
|
683
|
+
|
|
684
|
+
const result = await executeAction(item, config, {
|
|
685
|
+
dryRun: true,
|
|
686
|
+
discoverServer: mockDiscoverServer,
|
|
687
|
+
fetch: mockFetch
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
assert.ok(result.dryRun);
|
|
691
|
+
assert.strictEqual(result.method, 'api', 'Should still use API method');
|
|
692
|
+
// Should fall back to base directory
|
|
693
|
+
assert.ok(result.command.includes(tempDir),
|
|
694
|
+
'Should fall back to base directory when worktree creation fails');
|
|
695
|
+
});
|
|
574
696
|
});
|
|
575
697
|
|
|
576
698
|
describe('createSessionViaApi', () => {
|
|
@@ -50,23 +50,39 @@ sources:
|
|
|
50
50
|
test('hasToolConfig validates source configuration', async () => {
|
|
51
51
|
const { hasToolConfig } = await import('../../service/poll-service.js');
|
|
52
52
|
|
|
53
|
-
// Valid config
|
|
54
|
-
const
|
|
53
|
+
// Valid MCP config
|
|
54
|
+
const validMcp = {
|
|
55
55
|
name: 'test',
|
|
56
56
|
tool: { mcp: 'github', name: 'search_issues' },
|
|
57
57
|
args: {}
|
|
58
58
|
};
|
|
59
|
-
assert.strictEqual(hasToolConfig(
|
|
59
|
+
assert.strictEqual(hasToolConfig(validMcp), true);
|
|
60
|
+
|
|
61
|
+
// Valid CLI command config
|
|
62
|
+
const validCli = {
|
|
63
|
+
name: 'test',
|
|
64
|
+
tool: { command: ['gh', 'search', 'issues'] },
|
|
65
|
+
args: {}
|
|
66
|
+
};
|
|
67
|
+
assert.strictEqual(hasToolConfig(validCli), true);
|
|
68
|
+
|
|
69
|
+
// Valid CLI command config (string form)
|
|
70
|
+
const validCliString = {
|
|
71
|
+
name: 'test',
|
|
72
|
+
tool: { command: 'gh search issues' },
|
|
73
|
+
args: {}
|
|
74
|
+
};
|
|
75
|
+
assert.strictEqual(hasToolConfig(validCliString), true);
|
|
60
76
|
|
|
61
77
|
// Missing tool
|
|
62
78
|
const missingTool = { name: 'test' };
|
|
63
79
|
assert.strictEqual(hasToolConfig(missingTool), false);
|
|
64
80
|
|
|
65
|
-
// Missing mcp
|
|
81
|
+
// Missing mcp (and no command)
|
|
66
82
|
const missingMcp = { name: 'test', tool: { name: 'search_issues' } };
|
|
67
83
|
assert.strictEqual(hasToolConfig(missingMcp), false);
|
|
68
84
|
|
|
69
|
-
// Missing tool.name
|
|
85
|
+
// Missing tool.name (and no command)
|
|
70
86
|
const missingName = { name: 'test', tool: { mcp: 'github' } };
|
|
71
87
|
assert.strictEqual(hasToolConfig(missingName), false);
|
|
72
88
|
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { describe, it, mock } from "node:test";
|
|
2
|
+
import assert from "node:assert";
|
|
3
|
+
import {
|
|
4
|
+
listWorktrees,
|
|
5
|
+
createWorktree,
|
|
6
|
+
getProjectInfo,
|
|
7
|
+
resolveWorktreeDirectory,
|
|
8
|
+
} from "../../service/worktree.js";
|
|
9
|
+
|
|
10
|
+
describe("worktree", () => {
|
|
11
|
+
describe("listWorktrees", () => {
|
|
12
|
+
it("returns worktrees from server", async () => {
|
|
13
|
+
const mockFetch = mock.fn(async () => ({
|
|
14
|
+
ok: true,
|
|
15
|
+
json: async () => ["/path/to/worktree1", "/path/to/worktree2"],
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
const result = await listWorktrees("http://localhost:4096", { fetch: mockFetch });
|
|
19
|
+
|
|
20
|
+
assert.deepStrictEqual(result, ["/path/to/worktree1", "/path/to/worktree2"]);
|
|
21
|
+
assert.strictEqual(mockFetch.mock.calls.length, 1);
|
|
22
|
+
assert.strictEqual(mockFetch.mock.calls[0].arguments[0], "http://localhost:4096/experimental/worktree");
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("returns empty array on error", async () => {
|
|
26
|
+
const mockFetch = mock.fn(async () => ({
|
|
27
|
+
ok: false,
|
|
28
|
+
status: 500,
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const result = await listWorktrees("http://localhost:4096", { fetch: mockFetch });
|
|
32
|
+
|
|
33
|
+
assert.deepStrictEqual(result, []);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it("returns empty array on network error", async () => {
|
|
37
|
+
const mockFetch = mock.fn(async () => {
|
|
38
|
+
throw new Error("Network error");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const result = await listWorktrees("http://localhost:4096", { fetch: mockFetch });
|
|
42
|
+
|
|
43
|
+
assert.deepStrictEqual(result, []);
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
describe("createWorktree", () => {
|
|
48
|
+
it("creates a worktree successfully", async () => {
|
|
49
|
+
const worktreeResponse = {
|
|
50
|
+
name: "brave-falcon",
|
|
51
|
+
branch: "opencode/brave-falcon",
|
|
52
|
+
directory: "/data/worktree/abc123/brave-falcon",
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const mockFetch = mock.fn(async () => ({
|
|
56
|
+
ok: true,
|
|
57
|
+
json: async () => worktreeResponse,
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const result = await createWorktree("http://localhost:4096", { fetch: mockFetch });
|
|
61
|
+
|
|
62
|
+
assert.strictEqual(result.success, true);
|
|
63
|
+
assert.deepStrictEqual(result.worktree, worktreeResponse);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("passes name option to server", async () => {
|
|
67
|
+
const mockFetch = mock.fn(async (url, options) => ({
|
|
68
|
+
ok: true,
|
|
69
|
+
json: async () => ({
|
|
70
|
+
name: "my-feature",
|
|
71
|
+
branch: "opencode/my-feature",
|
|
72
|
+
directory: "/data/worktree/abc123/my-feature",
|
|
73
|
+
}),
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
await createWorktree("http://localhost:4096", {
|
|
77
|
+
name: "my-feature",
|
|
78
|
+
fetch: mockFetch,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const body = JSON.parse(mockFetch.mock.calls[0].arguments[1].body);
|
|
82
|
+
assert.strictEqual(body.name, "my-feature");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("returns error on failure", async () => {
|
|
86
|
+
const mockFetch = mock.fn(async () => ({
|
|
87
|
+
ok: false,
|
|
88
|
+
status: 400,
|
|
89
|
+
text: async () => "Invalid request",
|
|
90
|
+
}));
|
|
91
|
+
|
|
92
|
+
const result = await createWorktree("http://localhost:4096", { fetch: mockFetch });
|
|
93
|
+
|
|
94
|
+
assert.strictEqual(result.success, false);
|
|
95
|
+
assert.ok(result.error.includes("400"));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe("getProjectInfo", () => {
|
|
100
|
+
it("returns project info", async () => {
|
|
101
|
+
const projectInfo = {
|
|
102
|
+
id: "abc123",
|
|
103
|
+
worktree: "/path/to/project",
|
|
104
|
+
sandboxes: ["/path/to/sandbox1", "/path/to/sandbox2"],
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const mockFetch = mock.fn(async () => ({
|
|
108
|
+
ok: true,
|
|
109
|
+
json: async () => projectInfo,
|
|
110
|
+
}));
|
|
111
|
+
|
|
112
|
+
const result = await getProjectInfo("http://localhost:4096", { fetch: mockFetch });
|
|
113
|
+
|
|
114
|
+
assert.deepStrictEqual(result, projectInfo);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("returns null on error", async () => {
|
|
118
|
+
const mockFetch = mock.fn(async () => ({
|
|
119
|
+
ok: false,
|
|
120
|
+
status: 404,
|
|
121
|
+
}));
|
|
122
|
+
|
|
123
|
+
const result = await getProjectInfo("http://localhost:4096", { fetch: mockFetch });
|
|
124
|
+
|
|
125
|
+
assert.strictEqual(result, null);
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("resolveWorktreeDirectory", () => {
|
|
130
|
+
it("returns base directory when no worktree config", async () => {
|
|
131
|
+
const result = await resolveWorktreeDirectory(
|
|
132
|
+
"http://localhost:4096",
|
|
133
|
+
"/path/to/project",
|
|
134
|
+
{}
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
assert.strictEqual(result.directory, "/path/to/project");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it("returns base directory when worktree is undefined", async () => {
|
|
141
|
+
const result = await resolveWorktreeDirectory(
|
|
142
|
+
"http://localhost:4096",
|
|
143
|
+
"/path/to/project",
|
|
144
|
+
{ worktree: undefined }
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
assert.strictEqual(result.directory, "/path/to/project");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("creates new worktree when worktree is 'new'", async () => {
|
|
151
|
+
const mockFetch = mock.fn(async () => ({
|
|
152
|
+
ok: true,
|
|
153
|
+
json: async () => ({
|
|
154
|
+
name: "test-worktree",
|
|
155
|
+
branch: "opencode/test-worktree",
|
|
156
|
+
directory: "/data/worktree/abc123/test-worktree",
|
|
157
|
+
}),
|
|
158
|
+
}));
|
|
159
|
+
|
|
160
|
+
const result = await resolveWorktreeDirectory(
|
|
161
|
+
"http://localhost:4096",
|
|
162
|
+
"/path/to/project",
|
|
163
|
+
{ worktree: "new" },
|
|
164
|
+
{ fetch: mockFetch }
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
assert.strictEqual(result.directory, "/data/worktree/abc123/test-worktree");
|
|
168
|
+
assert.strictEqual(result.worktreeCreated, true);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("passes worktreeName when creating new worktree", async () => {
|
|
172
|
+
const mockFetch = mock.fn(async () => ({
|
|
173
|
+
ok: true,
|
|
174
|
+
json: async () => ({
|
|
175
|
+
name: "my-feature",
|
|
176
|
+
branch: "opencode/my-feature",
|
|
177
|
+
directory: "/data/worktree/abc123/my-feature",
|
|
178
|
+
}),
|
|
179
|
+
}));
|
|
180
|
+
|
|
181
|
+
await resolveWorktreeDirectory(
|
|
182
|
+
"http://localhost:4096",
|
|
183
|
+
"/path/to/project",
|
|
184
|
+
{ worktree: "new", worktreeName: "my-feature" },
|
|
185
|
+
{ fetch: mockFetch }
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const body = JSON.parse(mockFetch.mock.calls[0].arguments[1].body);
|
|
189
|
+
assert.strictEqual(body.name, "my-feature");
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it("returns error when no server running", async () => {
|
|
193
|
+
const result = await resolveWorktreeDirectory(
|
|
194
|
+
null,
|
|
195
|
+
"/path/to/project",
|
|
196
|
+
{ worktree: "new" }
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
assert.strictEqual(result.directory, "/path/to/project");
|
|
200
|
+
assert.ok(result.error.includes("no server"));
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("returns error when no server running for named worktree", async () => {
|
|
204
|
+
const result = await resolveWorktreeDirectory(
|
|
205
|
+
null,
|
|
206
|
+
"/path/to/project",
|
|
207
|
+
{ worktree: "my-feature" }
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
assert.strictEqual(result.directory, "/path/to/project");
|
|
211
|
+
assert.ok(result.error.includes("no server"));
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("looks up named worktree from sandboxes", async () => {
|
|
215
|
+
const mockFetch = mock.fn(async () => ({
|
|
216
|
+
ok: true,
|
|
217
|
+
json: async () => [
|
|
218
|
+
"/data/worktree/abc123/brave-falcon",
|
|
219
|
+
"/data/worktree/abc123/my-feature",
|
|
220
|
+
],
|
|
221
|
+
}));
|
|
222
|
+
|
|
223
|
+
const result = await resolveWorktreeDirectory(
|
|
224
|
+
"http://localhost:4096",
|
|
225
|
+
"/path/to/project",
|
|
226
|
+
{ worktree: "my-feature" },
|
|
227
|
+
{ fetch: mockFetch }
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
assert.strictEqual(result.directory, "/data/worktree/abc123/my-feature");
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("returns error when named worktree not found", async () => {
|
|
234
|
+
const mockFetch = mock.fn(async () => ({
|
|
235
|
+
ok: true,
|
|
236
|
+
json: async () => ["/data/worktree/abc123/other-worktree"],
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
const result = await resolveWorktreeDirectory(
|
|
240
|
+
"http://localhost:4096",
|
|
241
|
+
"/path/to/project",
|
|
242
|
+
{ worktree: "nonexistent" },
|
|
243
|
+
{ fetch: mockFetch }
|
|
244
|
+
);
|
|
245
|
+
|
|
246
|
+
assert.strictEqual(result.directory, "/path/to/project");
|
|
247
|
+
assert.ok(result.error.includes("not found"));
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
});
|