opencastle 0.12.0 → 0.14.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.
Files changed (55) hide show
  1. package/dist/cli/convoy/engine.d.ts +38 -0
  2. package/dist/cli/convoy/engine.d.ts.map +1 -0
  3. package/dist/cli/convoy/engine.js +416 -0
  4. package/dist/cli/convoy/engine.js.map +1 -0
  5. package/dist/cli/convoy/engine.test.d.ts +2 -0
  6. package/dist/cli/convoy/engine.test.d.ts.map +1 -0
  7. package/dist/cli/convoy/engine.test.js +1140 -0
  8. package/dist/cli/convoy/engine.test.js.map +1 -0
  9. package/dist/cli/convoy/health.d.ts +23 -0
  10. package/dist/cli/convoy/health.d.ts.map +1 -0
  11. package/dist/cli/convoy/health.js +69 -0
  12. package/dist/cli/convoy/health.js.map +1 -0
  13. package/dist/cli/convoy/health.test.d.ts +2 -0
  14. package/dist/cli/convoy/health.test.d.ts.map +1 -0
  15. package/dist/cli/convoy/health.test.js +392 -0
  16. package/dist/cli/convoy/health.test.js.map +1 -0
  17. package/dist/cli/convoy/merge.d.ts +15 -0
  18. package/dist/cli/convoy/merge.d.ts.map +1 -0
  19. package/dist/cli/convoy/merge.js +62 -0
  20. package/dist/cli/convoy/merge.js.map +1 -0
  21. package/dist/cli/convoy/merge.test.d.ts +2 -0
  22. package/dist/cli/convoy/merge.test.d.ts.map +1 -0
  23. package/dist/cli/convoy/merge.test.js +134 -0
  24. package/dist/cli/convoy/merge.test.js.map +1 -0
  25. package/dist/cli/convoy/worktree.d.ts +13 -0
  26. package/dist/cli/convoy/worktree.d.ts.map +1 -0
  27. package/dist/cli/convoy/worktree.js +90 -0
  28. package/dist/cli/convoy/worktree.js.map +1 -0
  29. package/dist/cli/convoy/worktree.test.d.ts +2 -0
  30. package/dist/cli/convoy/worktree.test.d.ts.map +1 -0
  31. package/dist/cli/convoy/worktree.test.js +146 -0
  32. package/dist/cli/convoy/worktree.test.js.map +1 -0
  33. package/dist/cli/run/adapters/claude-code.js +1 -1
  34. package/dist/cli/run/adapters/claude-code.js.map +1 -1
  35. package/dist/cli/run/adapters/copilot.d.ts.map +1 -1
  36. package/dist/cli/run/adapters/copilot.js +5 -0
  37. package/dist/cli/run/adapters/copilot.js.map +1 -1
  38. package/dist/cli/run/adapters/cursor.js +1 -1
  39. package/dist/cli/run/adapters/cursor.js.map +1 -1
  40. package/dist/cli/types.d.ts +2 -0
  41. package/dist/cli/types.d.ts.map +1 -1
  42. package/package.json +1 -1
  43. package/src/cli/convoy/engine.test.ts +1349 -0
  44. package/src/cli/convoy/engine.ts +521 -0
  45. package/src/cli/convoy/health.test.ts +456 -0
  46. package/src/cli/convoy/health.ts +111 -0
  47. package/src/cli/convoy/merge.test.ts +184 -0
  48. package/src/cli/convoy/merge.ts +89 -0
  49. package/src/cli/convoy/worktree.test.ts +177 -0
  50. package/src/cli/convoy/worktree.ts +116 -0
  51. package/src/cli/run/adapters/claude-code.ts +1 -1
  52. package/src/cli/run/adapters/copilot.ts +5 -0
  53. package/src/cli/run/adapters/cursor.ts +1 -1
  54. package/src/cli/types.ts +2 -0
  55. package/src/dashboard/node_modules/.vite/deps/_metadata.json +6 -6
@@ -0,0 +1,184 @@
1
+ import { mkdtempSync, rmSync, realpathSync, writeFileSync, mkdirSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { execFile as execFileCb } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
+ import { createMergeQueue } from './merge.js'
8
+ import type { MergeQueue } from './merge.js'
9
+
10
+ const execFile = promisify(execFileCb)
11
+
12
+ // ── helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ let repoPath: string
15
+ let featureBranch: string
16
+ let queue: MergeQueue
17
+
18
+ async function setupTestRepo(): Promise<{ repoPath: string; featureBranch: string }> {
19
+ const path = realpathSync(mkdtempSync(join(tmpdir(), 'merge-test-')))
20
+ await execFile('git', ['init', path])
21
+ await execFile('git', ['-C', path, 'config', 'user.email', 'test@test.com'])
22
+ await execFile('git', ['-C', path, 'config', 'user.name', 'Test'])
23
+ writeFileSync(join(path, 'README.md'), '# Test')
24
+ await execFile('git', ['-C', path, 'add', '-A'])
25
+ await execFile('git', ['-C', path, 'commit', '-m', 'Initial commit'])
26
+ await execFile('git', ['-C', path, 'checkout', '-b', 'feat/test'])
27
+ return { repoPath: path, featureBranch: 'feat/test' }
28
+ }
29
+
30
+ async function addWorktree(repo: string, workerId: string, branch: string): Promise<string> {
31
+ const worktreesDir = join(repo, '.opencastle', 'worktrees')
32
+ mkdirSync(worktreesDir, { recursive: true })
33
+ const worktreePath = join(worktreesDir, workerId)
34
+ await execFile('git', ['-C', repo, 'worktree', 'add', worktreePath, '-b', `convoy-${workerId}`, branch])
35
+ return worktreePath
36
+ }
37
+
38
+ beforeEach(async () => {
39
+ const result = await setupTestRepo()
40
+ repoPath = result.repoPath
41
+ featureBranch = result.featureBranch
42
+ queue = createMergeQueue(repoPath)
43
+ })
44
+
45
+ afterEach(() => {
46
+ rmSync(repoPath, { recursive: true, force: true })
47
+ })
48
+
49
+ // ── successful merge ──────────────────────────────────────────────────────────
50
+
51
+ describe('merge - successful merge', () => {
52
+ it('stages, commits, and merges worktree changes to the target branch', async () => {
53
+ const worktreePath = await addWorktree(repoPath, 'worker1', featureBranch)
54
+ writeFileSync(join(worktreePath, 'output.txt'), 'worker output')
55
+
56
+ const result = await queue.merge(worktreePath, 'convoy-worker1', featureBranch)
57
+
58
+ expect(result).toEqual({ success: true, conflicted: false, message: 'Merged successfully' })
59
+ })
60
+
61
+ it('makes the merged file available in the target branch working tree', async () => {
62
+ const worktreePath = await addWorktree(repoPath, 'worker1', featureBranch)
63
+ writeFileSync(join(worktreePath, 'output.txt'), 'worker output')
64
+
65
+ await queue.merge(worktreePath, 'convoy-worker1', featureBranch)
66
+
67
+ const { existsSync } = await import('node:fs')
68
+ expect(existsSync(join(repoPath, 'output.txt'))).toBe(true)
69
+ })
70
+
71
+ it('creates an auto-commit on the worktree branch with the convoy message', async () => {
72
+ const worktreePath = await addWorktree(repoPath, 'worker1', featureBranch)
73
+ writeFileSync(join(worktreePath, 'output.txt'), 'worker output')
74
+
75
+ await queue.merge(worktreePath, 'convoy-worker1', featureBranch)
76
+
77
+ const { stdout } = await execFile('git', ['-C', repoPath, 'log', '--oneline', 'convoy-worker1'])
78
+ expect(stdout).toContain('convoy: convoy-worker1 completed')
79
+ })
80
+ })
81
+
82
+ // ── no changes ────────────────────────────────────────────────────────────────
83
+
84
+ describe('merge - no changes', () => {
85
+ it('returns success with "No changes to merge" when the worker made no file changes', async () => {
86
+ const worktreePath = await addWorktree(repoPath, 'worker1', featureBranch)
87
+
88
+ const result = await queue.merge(worktreePath, 'convoy-worker1', featureBranch)
89
+
90
+ expect(result).toEqual({ success: true, conflicted: false, message: 'No changes to merge' })
91
+ })
92
+
93
+ it('does not create a commit when there is nothing to stage', async () => {
94
+ const worktreePath = await addWorktree(repoPath, 'worker1', featureBranch)
95
+
96
+ await queue.merge(worktreePath, 'convoy-worker1', featureBranch)
97
+
98
+ const { stdout } = await execFile('git', ['-C', repoPath, 'log', '--oneline', 'convoy-worker1'])
99
+ expect(stdout).not.toContain('convoy: convoy-worker1 completed')
100
+ })
101
+ })
102
+
103
+ // ── merge conflict ────────────────────────────────────────────────────────────
104
+
105
+ describe('merge - conflict', () => {
106
+ it('returns conflicted: true and aborts when two worktrees edit the same file', async () => {
107
+ const worktree1 = await addWorktree(repoPath, 'worker1', featureBranch)
108
+ const worktree2 = await addWorktree(repoPath, 'worker2', featureBranch)
109
+
110
+ writeFileSync(join(worktree1, 'shared.txt'), 'content from worker 1')
111
+ writeFileSync(join(worktree2, 'shared.txt'), 'content from worker 2')
112
+
113
+ const first = await queue.merge(worktree1, 'convoy-worker1', featureBranch)
114
+ expect(first).toEqual({ success: true, conflicted: false, message: 'Merged successfully' })
115
+
116
+ const second = await queue.merge(worktree2, 'convoy-worker2', featureBranch)
117
+ expect(second.success).toBe(false)
118
+ expect(second.conflicted).toBe(true)
119
+ expect(second.message).toContain('conflict')
120
+ })
121
+
122
+ it('leaves the repo in a clean state (no pending merge) after aborting a conflict', async () => {
123
+ const worktree1 = await addWorktree(repoPath, 'worker1', featureBranch)
124
+ const worktree2 = await addWorktree(repoPath, 'worker2', featureBranch)
125
+
126
+ writeFileSync(join(worktree1, 'shared.txt'), 'content from worker 1')
127
+ writeFileSync(join(worktree2, 'shared.txt'), 'content from worker 2')
128
+
129
+ await queue.merge(worktree1, 'convoy-worker1', featureBranch)
130
+ await queue.merge(worktree2, 'convoy-worker2', featureBranch)
131
+
132
+ // --untracked-files=no excludes the .opencastle/worktrees/ dir from the check;
133
+ // we only want to verify there is no pending merge (no staged/modified tracked files).
134
+ const { stdout } = await execFile('git', ['-C', repoPath, 'status', '--porcelain', '--untracked-files=no'])
135
+ expect(stdout.trim()).toBe('')
136
+ })
137
+ })
138
+
139
+ // ── already committed changes ─────────────────────────────────────────────────
140
+
141
+ describe('merge - already committed changes', () => {
142
+ it('merges pre-committed changes without auto-committing', async () => {
143
+ const worktreePath = await addWorktree(repoPath, 'worker1', featureBranch)
144
+
145
+ writeFileSync(join(worktreePath, 'output.txt'), 'pre-committed content')
146
+ await execFile('git', ['-C', worktreePath, 'add', '-A'])
147
+ await execFile('git', ['-C', worktreePath, 'commit', '-m', 'Worker manual commit'])
148
+
149
+ const result = await queue.merge(worktreePath, 'convoy-worker1', featureBranch)
150
+
151
+ expect(result).toEqual({ success: true, conflicted: false, message: 'Merged successfully' })
152
+ })
153
+
154
+ it('makes pre-committed files available in the target branch after merge', async () => {
155
+ const worktreePath = await addWorktree(repoPath, 'worker1', featureBranch)
156
+
157
+ writeFileSync(join(worktreePath, 'output.txt'), 'pre-committed content')
158
+ await execFile('git', ['-C', worktreePath, 'add', '-A'])
159
+ await execFile('git', ['-C', worktreePath, 'commit', '-m', 'Worker manual commit'])
160
+
161
+ await queue.merge(worktreePath, 'convoy-worker1', featureBranch)
162
+
163
+ const { existsSync } = await import('node:fs')
164
+ expect(existsSync(join(repoPath, 'output.txt'))).toBe(true)
165
+ })
166
+ })
167
+
168
+ // ── error handling ────────────────────────────────────────────────────────────
169
+
170
+ describe('merge - error handling', () => {
171
+ it('throws when the worktree branch does not exist in the repo', async () => {
172
+ const worktreePath = await addWorktree(repoPath, 'worker1', featureBranch)
173
+
174
+ await expect(
175
+ queue.merge(worktreePath, 'nonexistent-branch', featureBranch),
176
+ ).rejects.toThrow()
177
+ })
178
+
179
+ it('throws when path is outside the managed worktrees directory', async () => {
180
+ await expect(
181
+ queue.merge('/tmp/evil', 'some-branch', featureBranch),
182
+ ).rejects.toThrow(/outside the managed worktrees directory/)
183
+ })
184
+ })
@@ -0,0 +1,89 @@
1
+ import { execFile as execFileCb } from 'node:child_process'
2
+ import { resolve, join, sep } from 'node:path'
3
+ import { promisify } from 'node:util'
4
+
5
+ const execFile = promisify(execFileCb)
6
+
7
+ export interface MergeResult {
8
+ success: boolean
9
+ conflicted: boolean
10
+ message: string
11
+ }
12
+
13
+ export interface MergeQueue {
14
+ /**
15
+ * Merge a single worktree's changes back onto the target branch.
16
+ * Stages all changes in the worktree, commits them if necessary, then merges
17
+ * the worktree branch into the target branch.
18
+ */
19
+ merge(worktreePath: string, worktreeBranch: string, targetBranch: string): Promise<MergeResult>
20
+ }
21
+
22
+ export function createMergeQueue(repoPath: string): MergeQueue {
23
+ const worktreesDir = resolve(join(repoPath, '.opencastle', 'worktrees'))
24
+
25
+ async function merge(
26
+ worktreePath: string,
27
+ worktreeBranch: string,
28
+ targetBranch: string,
29
+ ): Promise<MergeResult> {
30
+ const resolvedWorktree = resolve(worktreePath)
31
+ if (!resolvedWorktree.startsWith(worktreesDir + sep)) {
32
+ throw new Error(`Path "${worktreePath}" is outside the managed worktrees directory`)
33
+ }
34
+
35
+ // Stage all untracked/modified files in the worktree
36
+ await execFile('git', ['-C', resolvedWorktree, 'add', '-A'])
37
+
38
+ // List staged files — non-empty output means there are changes to commit.
39
+ // Uses --name-only (exits 0 regardless of diff size) rather than --quiet
40
+ // (exits 1 when changes exist) so the check is output-based, not exit-code-based.
41
+ const { stdout: staged } = await execFile('git', [
42
+ '-C',
43
+ resolvedWorktree,
44
+ 'diff',
45
+ '--cached',
46
+ '--name-only',
47
+ ])
48
+ const hasUncommitted = staged.trim().length > 0
49
+
50
+ if (hasUncommitted) {
51
+ await execFile('git', [
52
+ '-C',
53
+ resolvedWorktree,
54
+ 'commit',
55
+ '-m',
56
+ `convoy: ${worktreeBranch} completed`,
57
+ ])
58
+ }
59
+
60
+ // Merge the worktree branch into the target branch in the main repo
61
+ await execFile('git', ['-C', repoPath, 'checkout', targetBranch])
62
+
63
+ try {
64
+ const { stdout } = await execFile('git', [
65
+ '-C',
66
+ repoPath,
67
+ 'merge',
68
+ worktreeBranch,
69
+ '--no-edit',
70
+ ])
71
+ if (stdout.includes('Already up to date')) {
72
+ return { success: true, conflicted: false, message: 'No changes to merge' }
73
+ }
74
+ return { success: true, conflicted: false, message: 'Merged successfully' }
75
+ } catch (err) {
76
+ const error = err as { code?: number | string; stderr?: string; stdout?: string }
77
+ const isConflict =
78
+ error.code === 1 &&
79
+ ((error.stderr ?? '').includes('CONFLICT') || (error.stdout ?? '').includes('CONFLICT'))
80
+ if (isConflict) {
81
+ await execFile('git', ['-C', repoPath, 'merge', '--abort'])
82
+ return { success: false, conflicted: true, message: 'Merge conflict detected; merge aborted' }
83
+ }
84
+ throw err
85
+ }
86
+ }
87
+
88
+ return { merge }
89
+ }
@@ -0,0 +1,177 @@
1
+ import { mkdtempSync, rmSync, realpathSync } from 'node:fs'
2
+ import { tmpdir } from 'node:os'
3
+ import { join } from 'node:path'
4
+ import { execFile as execFileCb } from 'node:child_process'
5
+ import { promisify } from 'node:util'
6
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest'
7
+ import { createWorktreeManager } from './worktree.js'
8
+ import type { WorktreeManager } from './worktree.js'
9
+
10
+ const execFile = promisify(execFileCb)
11
+
12
+ // ── helpers ───────────────────────────────────────────────────────────────────
13
+
14
+ let tmpDir: string
15
+ let manager: WorktreeManager
16
+
17
+ async function initGitRepo(dir: string): Promise<void> {
18
+ await execFile('git', ['init'], { cwd: dir })
19
+ await execFile('git', ['config', 'user.email', 'test@test.com'], { cwd: dir })
20
+ await execFile('git', ['config', 'user.name', 'Test User'], { cwd: dir })
21
+ await execFile('git', ['commit', '--allow-empty', '-m', 'Initial commit'], { cwd: dir })
22
+ }
23
+
24
+ beforeEach(async () => {
25
+ tmpDir = realpathSync(mkdtempSync(join(tmpdir(), 'convoy-worktree-test-')))
26
+ await initGitRepo(tmpDir)
27
+ manager = createWorktreeManager(tmpDir)
28
+ })
29
+
30
+ afterEach(() => {
31
+ rmSync(tmpDir, { recursive: true, force: true })
32
+ })
33
+
34
+ // ── create ────────────────────────────────────────────────────────────────────
35
+
36
+ describe('create', () => {
37
+ it('creates a worktree and returns its absolute path', async () => {
38
+ const worktreePath = await manager.create('worker1', 'HEAD')
39
+ expect(worktreePath).toBe(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
40
+ })
41
+
42
+ it('creates the .opencastle/worktrees directory when it does not exist', async () => {
43
+ const { existsSync } = await import('node:fs')
44
+ const worktreePath = await manager.create('worker1', 'HEAD')
45
+ expect(existsSync(worktreePath)).toBe(true)
46
+ })
47
+
48
+ it('creates the new branch in the worktree', async () => {
49
+ await manager.create('worker1', 'HEAD')
50
+ const { stdout } = await execFile('git', ['branch', '--list', 'convoy-worker1'], { cwd: tmpDir })
51
+ // git prefixes branches checked out in a worktree with '+ '
52
+ expect(stdout.trim().replace(/^[*+]\s+/, '')).toBe('convoy-worker1')
53
+ })
54
+
55
+ it('throws when featureBranch does not exist', async () => {
56
+ await expect(manager.create('worker1', 'nonexistent-branch')).rejects.toThrow()
57
+ })
58
+
59
+ it('throws when workerId is already in use', async () => {
60
+ await manager.create('worker1', 'HEAD')
61
+ await expect(manager.create('worker1', 'HEAD')).rejects.toThrow()
62
+ })
63
+
64
+ it('throws for workerId with path traversal characters', async () => {
65
+ await expect(manager.create('../escape', 'HEAD')).rejects.toThrow(/Invalid workerId/)
66
+ })
67
+
68
+ it('throws for workerId with slashes', async () => {
69
+ await expect(manager.create('a/b', 'HEAD')).rejects.toThrow(/Invalid workerId/)
70
+ })
71
+ })
72
+
73
+ // ── remove ────────────────────────────────────────────────────────────────────
74
+
75
+ describe('remove', () => {
76
+ it('removes the worktree so it no longer appears in list()', async () => {
77
+ const path = await manager.create('worker1', 'HEAD')
78
+ await manager.remove(path)
79
+ const worktrees = await manager.list()
80
+ expect(worktrees).toHaveLength(0)
81
+ })
82
+
83
+ it('deletes the convoy branch after removing the worktree', async () => {
84
+ const path = await manager.create('worker1', 'HEAD')
85
+ await manager.remove(path)
86
+ const { stdout } = await execFile('git', ['branch', '--list', 'convoy-worker1'], { cwd: tmpDir })
87
+ expect(stdout.trim()).toBe('')
88
+ })
89
+
90
+ it('is idempotent — does not throw for a non-existent worktree path', async () => {
91
+ const nonExistent = join(tmpDir, '.opencastle', 'worktrees', 'ghost')
92
+ await expect(manager.remove(nonExistent)).resolves.toBeUndefined()
93
+ })
94
+
95
+ it('re-throws git errors that are not "not a working tree"', async () => {
96
+ const path = await manager.create('worker1', 'HEAD')
97
+ // Lock the worktree so that single --force removal fails
98
+ await execFile('git', ['worktree', 'lock', path], { cwd: tmpDir })
99
+ await expect(manager.remove(path)).rejects.toThrow()
100
+ // Cleanup: unlock and remove manually
101
+ await execFile('git', ['worktree', 'unlock', path], { cwd: tmpDir })
102
+ })
103
+
104
+ it('throws when path is outside the managed worktrees directory', async () => {
105
+ await expect(manager.remove('/some/arbitrary/path')).rejects.toThrow(
106
+ /outside the managed worktrees directory/,
107
+ )
108
+ })
109
+ })
110
+
111
+ // ── list ──────────────────────────────────────────────────────────────────────
112
+
113
+ describe('list', () => {
114
+ it('returns an empty array when no convoy worktrees exist', async () => {
115
+ const worktrees = await manager.list()
116
+ expect(worktrees).toHaveLength(0)
117
+ })
118
+
119
+ it('does not include the main worktree', async () => {
120
+ const worktrees = await manager.list()
121
+ for (const wt of worktrees) {
122
+ expect(wt.path).toContain('.opencastle/worktrees')
123
+ }
124
+ })
125
+
126
+ it('returns the correct WorktreeInfo for a created worktree', async () => {
127
+ await manager.create('worker1', 'HEAD')
128
+ const worktrees = await manager.list()
129
+ expect(worktrees).toHaveLength(1)
130
+ expect(worktrees[0].path).toBe(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
131
+ expect(worktrees[0].branch).toBe('refs/heads/convoy-worker1')
132
+ expect(worktrees[0].head).toMatch(/^[0-9a-f]{40}$/)
133
+ })
134
+
135
+ it('returns multiple worktrees when several have been created', async () => {
136
+ await manager.create('worker1', 'HEAD')
137
+ await manager.create('worker2', 'HEAD')
138
+ const worktrees = await manager.list()
139
+ expect(worktrees).toHaveLength(2)
140
+ const paths = worktrees.map(w => w.path)
141
+ expect(paths).toContain(join(tmpDir, '.opencastle', 'worktrees', 'worker1'))
142
+ expect(paths).toContain(join(tmpDir, '.opencastle', 'worktrees', 'worker2'))
143
+ })
144
+
145
+ it('handles detached HEAD worktrees by returning empty branch string', async () => {
146
+ // git worktree list --porcelain outputs 'detached' (not 'branch refs/...') for
147
+ // detached-HEAD worktrees — this exercises the else-if fallthrough in parseWorktreeList
148
+ const { existsSync, mkdirSync } = await import('node:fs')
149
+ const detachedPath = join(tmpDir, '.opencastle', 'worktrees', 'detached-test')
150
+ if (!existsSync(join(tmpDir, '.opencastle', 'worktrees'))) {
151
+ mkdirSync(join(tmpDir, '.opencastle', 'worktrees'), { recursive: true })
152
+ }
153
+ const { stdout: sha } = await execFile('git', ['rev-parse', 'HEAD'], { cwd: tmpDir })
154
+ await execFile('git', ['worktree', 'add', '--detach', detachedPath, sha.trim()], { cwd: tmpDir })
155
+ const worktrees = await manager.list()
156
+ const detached = worktrees.find(w => w.path === detachedPath)
157
+ expect(detached).toBeDefined()
158
+ expect(detached!.branch).toBe('')
159
+ expect(detached!.head).toMatch(/^[0-9a-f]{40}$/)
160
+ })
161
+ })
162
+
163
+ // ── removeAll ─────────────────────────────────────────────────────────────────
164
+
165
+ describe('removeAll', () => {
166
+ it('removes all convoy worktrees', async () => {
167
+ await manager.create('worker1', 'HEAD')
168
+ await manager.create('worker2', 'HEAD')
169
+ await manager.removeAll()
170
+ const worktrees = await manager.list()
171
+ expect(worktrees).toHaveLength(0)
172
+ })
173
+
174
+ it('is a no-op when no convoy worktrees exist', async () => {
175
+ await expect(manager.removeAll()).resolves.toBeUndefined()
176
+ })
177
+ })
@@ -0,0 +1,116 @@
1
+ import { execFile as execFileCb } from 'node:child_process'
2
+ import { mkdir } from 'node:fs/promises'
3
+ import { realpathSync } from 'node:fs'
4
+ import { join, basename, resolve, sep } from 'node:path'
5
+ import { promisify } from 'node:util'
6
+
7
+ const execFile = promisify(execFileCb)
8
+
9
+ export interface WorktreeInfo {
10
+ path: string
11
+ branch: string
12
+ head: string
13
+ }
14
+
15
+ export interface WorktreeManager {
16
+ create(workerId: string, featureBranch: string): Promise<string>
17
+ remove(worktreePath: string): Promise<void>
18
+ list(): Promise<WorktreeInfo[]>
19
+ removeAll(): Promise<void>
20
+ }
21
+
22
+ export function createWorktreeManager(basePath: string): WorktreeManager {
23
+ const resolvedBase = realpathSync(resolve(basePath))
24
+ const worktreesDir = join(resolvedBase, '.opencastle', 'worktrees')
25
+
26
+ async function create(workerId: string, featureBranch: string): Promise<string> {
27
+ if (!/^[a-zA-Z0-9_-]+$/.test(workerId)) {
28
+ throw new Error(
29
+ `Invalid workerId "${workerId}": must only contain alphanumeric characters, hyphens, and underscores`,
30
+ )
31
+ }
32
+ const worktreePath = join(worktreesDir, workerId)
33
+ await mkdir(worktreesDir, { recursive: true })
34
+ await execFile(
35
+ 'git',
36
+ ['worktree', 'add', worktreePath, '-b', `convoy-${workerId}`, featureBranch],
37
+ { cwd: resolvedBase },
38
+ )
39
+ return worktreePath
40
+ }
41
+
42
+ async function remove(worktreePath: string): Promise<void> {
43
+ let resolved: string
44
+ try {
45
+ resolved = realpathSync(worktreePath)
46
+ } catch {
47
+ resolved = resolve(worktreePath)
48
+ }
49
+ if (!resolved.startsWith(worktreesDir + sep)) {
50
+ throw new Error(`Path "${worktreePath}" is outside the managed worktrees directory`)
51
+ }
52
+ const workerId = basename(resolved)
53
+ try {
54
+ await execFile('git', ['worktree', 'remove', resolved, '--force'], {
55
+ cwd: resolvedBase,
56
+ })
57
+ } catch (err) {
58
+ const stderr = (err as { stderr?: string }).stderr ?? ''
59
+ if (stderr.includes('is not a working tree')) {
60
+ return
61
+ }
62
+ throw err
63
+ }
64
+ try {
65
+ await execFile('git', ['branch', '-D', `convoy-${workerId}`], { cwd: resolvedBase })
66
+ } catch {
67
+ // Branch may already be deleted — ignore
68
+ }
69
+ }
70
+
71
+
72
+ async function list(): Promise<WorktreeInfo[]> {
73
+ const { stdout } = await execFile('git', ['worktree', 'list', '--porcelain'], {
74
+ cwd: resolvedBase,
75
+ })
76
+ return parseWorktreeList(stdout, worktreesDir)
77
+ }
78
+
79
+ async function removeAll(): Promise<void> {
80
+ const worktrees = await list()
81
+ for (const wt of worktrees) {
82
+ await remove(wt.path)
83
+ }
84
+ }
85
+
86
+ return { create, remove, list, removeAll }
87
+ }
88
+
89
+ function parseWorktreeList(output: string, worktreesDir: string): WorktreeInfo[] {
90
+ const results: WorktreeInfo[] = []
91
+ const blocks = output.trim().split(/\n\n+/).filter(Boolean)
92
+
93
+ for (const block of blocks) {
94
+ const lines = block.split('\n')
95
+ let path = ''
96
+ let head = ''
97
+ let branch = ''
98
+
99
+ for (const line of lines) {
100
+ if (line.startsWith('worktree ')) {
101
+ path = line.slice('worktree '.length)
102
+ } else if (line.startsWith('HEAD ')) {
103
+ head = line.slice('HEAD '.length)
104
+ } else if (line.startsWith('branch ')) {
105
+ branch = line.slice('branch '.length)
106
+ }
107
+ }
108
+
109
+ // Only include worktrees that live under .opencastle/worktrees/
110
+ if (path.startsWith(worktreesDir + sep)) {
111
+ results.push({ path, branch, head })
112
+ }
113
+ }
114
+
115
+ return results
116
+ }
@@ -38,7 +38,7 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
38
38
  const proc = spawn('claude', args, {
39
39
  stdio: ['ignore', 'pipe', 'pipe'],
40
40
  env: { ...process.env },
41
- cwd: process.cwd(),
41
+ cwd: options?.cwd ?? process.cwd(),
42
42
  })
43
43
 
44
44
  let stdout = ''
@@ -61,6 +61,11 @@ async function getClient(): Promise<CopilotClientType> {
61
61
  * - Streaming enabled in verbose mode for live output
62
62
  */
63
63
  export async function execute(task: Task, options: ExecuteOptions = {}): Promise<ExecuteResult> {
64
+ // NOTE: The Copilot SDK CopilotClient is a shared singleton. Per-task cwd
65
+ // isolation requires SDK support for per-session workingDirectory, which is
66
+ // not yet available. When running in convoy mode with worktrees, prefer
67
+ // subprocess-based adapters (claude-code, cursor) that support options.cwd
68
+ // natively. Copilot SDK per-session cwd support is tracked for Phase 3.
64
69
  let prompt = `You are a ${task.agent}. ${task.prompt}`
65
70
 
66
71
  if (task.files && task.files.length > 0) {
@@ -37,7 +37,7 @@ export async function execute(task: Task, options: ExecuteOptions = {}): Promise
37
37
  const proc = spawn('agent', args, {
38
38
  stdio: ['ignore', 'pipe', 'pipe'],
39
39
  env: { ...process.env },
40
- cwd: process.cwd(),
40
+ cwd: options?.cwd ?? process.cwd(),
41
41
  })
42
42
 
43
43
  let stdout = ''
package/src/cli/types.ts CHANGED
@@ -234,6 +234,8 @@ export interface AgentAdapter {
234
234
  /** Options for agent execution. */
235
235
  export interface ExecuteOptions {
236
236
  verbose?: boolean;
237
+ /** Working directory for the agent process (defaults to process.cwd()). */
238
+ cwd?: string;
237
239
  }
238
240
 
239
241
  /** Result from an agent adapter execution. */
@@ -1,25 +1,25 @@
1
1
  {
2
- "hash": "7398c476",
2
+ "hash": "a6abe049",
3
3
  "configHash": "30f8ea04",
4
- "lockfileHash": "35ec1b3d",
5
- "browserHash": "6a8b9ff7",
4
+ "lockfileHash": "c72e777b",
5
+ "browserHash": "737e5c4c",
6
6
  "optimized": {
7
7
  "astro > cssesc": {
8
8
  "src": "../../../../../node_modules/cssesc/cssesc.js",
9
9
  "file": "astro___cssesc.js",
10
- "fileHash": "ca3e0c44",
10
+ "fileHash": "3a9e4516",
11
11
  "needsInterop": true
12
12
  },
13
13
  "astro > aria-query": {
14
14
  "src": "../../../../../node_modules/aria-query/lib/index.js",
15
15
  "file": "astro___aria-query.js",
16
- "fileHash": "9cd2129a",
16
+ "fileHash": "774ff115",
17
17
  "needsInterop": true
18
18
  },
19
19
  "astro > axobject-query": {
20
20
  "src": "../../../../../node_modules/axobject-query/lib/index.js",
21
21
  "file": "astro___axobject-query.js",
22
- "fileHash": "cba29d71",
22
+ "fileHash": "bd1844a6",
23
23
  "needsInterop": true
24
24
  }
25
25
  },