Skip to content

Commit b9236f7

Browse files
feat(worktree): worktree tabs, server env vars, and multi-directory SSE (#240)
* loop: worktree-tabs-2 completed after 4 iterations * loop: server-env-vars completed after 7 iterations * feat(worktree): add worktree tabs and server env vars settings * refactor(worktree): multi-directory SSE support and workspace chip row * feat(workspace): collapsible workspace manager and sibling filtering * feat(workspace): workspace creation endpoint, refactored workspace manager, and tests * refactor(frontend): unify workspace label and remove dead code - extract shared workspaceLabel helper into api/repos and reuse in WorkspaceManager and RepoDetail directoryLabels - remove unused useSessions hook and subscribeToDirectory subscription - drop redundant frontend workspace dedup now guaranteed by backend - align WorktreeTabs empty-state test with create-workspace button
1 parent 60ed756 commit b9236f7

38 files changed

Lines changed: 2423 additions & 133 deletions

backend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
"build": "bun build src/index.ts --outdir=dist --target=bun",
1010
"typecheck": "tsc --noEmit",
1111
"test": "pnpm run test:bun && pnpm run test:vitest",
12-
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts src/db/model-state.test.ts src/routes/providers.test.ts",
12+
"test:bun": "bun test test/services/assistant-mode.test.ts test/services/internal-token.test.ts test/auth/internal-token-middleware.test.ts test/routes/internal-schedules.test.ts test/routes/internal-notifications.test.ts test/routes/internal-settings.test.ts test/routes/internal-repos.test.ts src/db/model-state.test.ts src/routes/providers.test.ts src/routes/repos.test.ts",
1313
"test:vitest": "vitest run",
1414
"test:ui": "vitest --ui",
1515
"test:watch": "vitest --watch",

backend/src/routes/repos.test.ts

Lines changed: 333 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,333 @@
1+
import { describe, it, expect, beforeEach, afterEach, mock } from 'bun:test'
2+
import { Hono } from 'hono'
3+
import { Database } from 'bun:sqlite'
4+
import { migrate } from '../db/migration-runner'
5+
import { allMigrations } from '../db/migrations'
6+
import { createRepoRoutes } from './repos'
7+
import { createRepo } from '../db/queries'
8+
import { createStubOpenCodeClient } from '../../test/helpers/stub-opencode-client'
9+
import type { GitAuthService } from '../services/git-auth'
10+
import type { OpenCodeClient } from '../services/opencode/client'
11+
import { getReposPath } from '@opencode-manager/shared/config/env'
12+
import path from 'path'
13+
14+
beforeEach(() => {
15+
mock.module('../services/project-id-resolver', () => ({
16+
resolveProjectId: (() => null) as any,
17+
isGitMainCheckout: (() => Promise.resolve(false)) as any,
18+
}))
19+
})
20+
21+
afterEach(() => {
22+
mock.restore()
23+
})
24+
25+
const stubGitAuthService = {
26+
getGitEnvironment: () => ({}),
27+
getGitCredentials: async () => [],
28+
} as unknown as GitAuthService
29+
30+
function createTestApp(db: Database, openCodeClient: OpenCodeClient = createStubOpenCodeClient({
31+
getJson: mock(async () => []) as any,
32+
})): Hono {
33+
const app = new Hono()
34+
const scheduleService = {
35+
createSchedule: () => {},
36+
getScheduleById: () => null,
37+
listSchedules: () => [],
38+
updateSchedule: () => {},
39+
deleteSchedule: () => {},
40+
} as any
41+
app.route('/repos', createRepoRoutes(db, stubGitAuthService, scheduleService, openCodeClient))
42+
return app
43+
}
44+
45+
function createTestDb(): Database {
46+
const db = new Database(':memory:')
47+
migrate(db, allMigrations)
48+
return db
49+
}
50+
51+
describe('GET /api/repos/:id/siblings', () => {
52+
let db: Database
53+
let app: Hono
54+
55+
beforeEach(() => {
56+
db = createTestDb()
57+
app = createTestApp(db)
58+
})
59+
60+
it('returns siblings including self with currentBranch', async () => {
61+
mock.module('../services/project-id-resolver', () => ({
62+
resolveProjectId: ((path: string) => Promise.resolve(
63+
path.includes('repo-unrelated') ? 'commit-B' : 'commit-A'
64+
)) as any,
65+
isGitMainCheckout: (() => Promise.resolve(false)) as any,
66+
}))
67+
68+
createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
69+
createRepo(db, { localPath: 'repo-b', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
70+
createRepo(db, { localPath: 'repo-c', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
71+
createRepo(db, { localPath: 'repo-unrelated', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
72+
73+
const res = await app.request('/repos/1/siblings')
74+
expect(res.status).toBe(200)
75+
const data = await res.json() as Array<{ id: number; currentBranch: string | null | undefined }>
76+
expect(data).toHaveLength(3)
77+
expect(data.map((d) => d.id).sort((a, b) => a - b)).toEqual([1, 2, 3])
78+
})
79+
80+
it('includes OpenCode workspaces that are not manager repo rows', async () => {
81+
mock.module('../services/project-id-resolver', () => ({
82+
resolveProjectId: (() => Promise.resolve('commit-A')) as any,
83+
isGitMainCheckout: (() => Promise.resolve(false)) as any,
84+
}))
85+
86+
createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
87+
app = createTestApp(db, createStubOpenCodeClient({
88+
getJson: mock(async () => ([{
89+
id: 'wrk_test',
90+
type: 'worktree',
91+
name: 'plugin-workspace',
92+
branch: 'plugin-branch',
93+
directory: '/tmp/plugin-workspace',
94+
projectID: 'commit-A',
95+
}])) as any,
96+
}))
97+
98+
const res = await app.request('/repos/1/siblings')
99+
expect(res.status).toBe(200)
100+
const data = await res.json() as Array<{ id: number; workspaceId?: string; currentBranch?: string }>
101+
expect(data).toHaveLength(2)
102+
expect(data[1]).toMatchObject({
103+
id: -1,
104+
workspaceId: 'wrk_test',
105+
currentBranch: 'plugin-branch',
106+
})
107+
})
108+
109+
it('deduplicates OpenCode workspaces with the same directory', async () => {
110+
mock.module('../services/project-id-resolver', () => ({
111+
resolveProjectId: (() => Promise.resolve('commit-A')) as any,
112+
isGitMainCheckout: (() => Promise.resolve(false)) as any,
113+
}))
114+
115+
createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
116+
app = createTestApp(db, createStubOpenCodeClient({
117+
getJson: mock(async () => ([
118+
{
119+
id: 'wrk_first',
120+
type: 'worktree',
121+
name: 'duplicate-workspace',
122+
branch: 'duplicate-branch',
123+
directory: '/tmp/duplicate-workspace',
124+
projectID: 'commit-A',
125+
},
126+
{
127+
id: 'wrk_second',
128+
type: 'worktree',
129+
name: 'duplicate-workspace',
130+
branch: 'duplicate-branch',
131+
directory: '/tmp/duplicate-workspace/',
132+
projectID: 'commit-A',
133+
},
134+
])) as any,
135+
}))
136+
137+
const res = await app.request('/repos/1/siblings')
138+
expect(res.status).toBe(200)
139+
const data = await res.json() as Array<{ workspaceId?: string }>
140+
expect(data.filter((entry) => entry.workspaceId)).toHaveLength(1)
141+
expect(data.some((entry) => entry.workspaceId === 'wrk_first')).toBe(true)
142+
})
143+
144+
it('excludes a workspace pointing at the repo directory so it cannot be deleted', async () => {
145+
mock.module('../services/project-id-resolver', () => ({
146+
resolveProjectId: (() => Promise.resolve('commit-A')) as any,
147+
isGitMainCheckout: (() => Promise.resolve(false)) as any,
148+
}))
149+
150+
createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
151+
const repoDirectory = path.join(getReposPath(), 'repo-a')
152+
app = createTestApp(db, createStubOpenCodeClient({
153+
getJson: mock(async () => ([{
154+
id: 'wrk_self',
155+
type: 'worktree',
156+
name: 'self-workspace',
157+
branch: 'main',
158+
directory: `${repoDirectory}/`,
159+
projectID: 'commit-A',
160+
}])) as any,
161+
}))
162+
163+
const res = await app.request('/repos/1/siblings')
164+
expect(res.status).toBe(200)
165+
const data = await res.json() as Array<{ id: number; workspaceId?: string }>
166+
expect(data).toHaveLength(1)
167+
expect(data.some((d) => d.workspaceId === 'wrk_self')).toBe(false)
168+
})
169+
170+
it('excludes a workspace that is a git main checkout so the main repo cannot be deleted', async () => {
171+
mock.module('../services/project-id-resolver', () => ({
172+
resolveProjectId: (() => Promise.resolve('commit-A')) as any,
173+
isGitMainCheckout: ((dir: string) =>
174+
Promise.resolve(dir === '/Users/dev/main-repo')) as any,
175+
}))
176+
177+
createRepo(db, { localPath: 'repo-wt', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
178+
app = createTestApp(db, createStubOpenCodeClient({
179+
getJson: mock(async () => ([
180+
{
181+
id: 'wrk_main',
182+
type: 'worktree',
183+
name: 'main-checkout',
184+
branch: 'dev',
185+
directory: '/Users/dev/main-repo',
186+
projectID: 'commit-A',
187+
},
188+
{
189+
id: 'wrk_linked',
190+
type: 'worktree',
191+
name: 'feature',
192+
branch: 'feature/x',
193+
directory: '/Users/dev/worktrees/feature-x',
194+
projectID: 'commit-A',
195+
},
196+
])) as any,
197+
}))
198+
199+
const res = await app.request('/repos/1/siblings')
200+
expect(res.status).toBe(200)
201+
const data = await res.json() as Array<{ workspaceId?: string }>
202+
expect(data.some((d) => d.workspaceId === 'wrk_main')).toBe(false)
203+
expect(data.some((d) => d.workspaceId === 'wrk_linked')).toBe(true)
204+
})
205+
206+
it('excludes repos with non-matching projectID', async () => {
207+
mock.module('../services/project-id-resolver', () => ({
208+
resolveProjectId: ((path: string) => Promise.resolve(
209+
path.includes('repo-only') ? 'commit-X' : 'commit-Y'
210+
)) as any,
211+
isGitMainCheckout: (() => Promise.resolve(false)) as any,
212+
}))
213+
214+
createRepo(db, { localPath: 'repo-only', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
215+
createRepo(db, { localPath: 'repo-other', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
216+
217+
const res = await app.request('/repos/1/siblings')
218+
expect(res.status).toBe(200)
219+
const data = await res.json() as Array<{ id: number }>
220+
expect(data).toHaveLength(1)
221+
expect(data[0]!.id).toBe(1)
222+
})
223+
224+
it('returns empty when target has no projectID', async () => {
225+
mock.module('../services/project-id-resolver', () => ({
226+
resolveProjectId: (() => null) as any,
227+
}))
228+
229+
createRepo(db, { localPath: 'repo-no-project', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
230+
231+
const res = await app.request('/repos/1/siblings')
232+
expect(res.status).toBe(200)
233+
const data = await res.json() as unknown[]
234+
expect(data).toEqual([])
235+
})
236+
237+
it('returns empty when target cloneStatus !== ready', async () => {
238+
mock.module('../services/project-id-resolver', () => ({
239+
resolveProjectId: (() => 'commit-A') as any,
240+
isGitMainCheckout: (() => Promise.resolve(false)) as any,
241+
}))
242+
243+
createRepo(db, { localPath: 'repo-cloning', defaultBranch: 'main', cloneStatus: 'cloning', clonedAt: Date.now(), isLocal: true })
244+
245+
const res = await app.request('/repos/1/siblings')
246+
expect(res.status).toBe(200)
247+
const data = await res.json() as unknown[]
248+
expect(data).toEqual([])
249+
})
250+
251+
it('returns empty when target missing', async () => {
252+
mock.module('../services/project-id-resolver', () => ({
253+
resolveProjectId: (() => 'commit-A') as any,
254+
isGitMainCheckout: (() => Promise.resolve(false)) as any,
255+
}))
256+
257+
const res = await app.request('/repos/9999/siblings')
258+
expect(res.status).toBe(200)
259+
const data = await res.json() as unknown[]
260+
expect(data).toEqual([])
261+
})
262+
263+
it('invalid id returns 400', async () => {
264+
const res = await app.request('/repos/abc/siblings')
265+
expect(res.status).toBe(400)
266+
const data = await res.json() as { error: string }
267+
expect(data.error).toBe('Invalid repo id')
268+
})
269+
})
270+
271+
describe('DELETE /api/repos/:id/workspaces/:workspaceId', () => {
272+
let db: Database
273+
let captured: { path: string; directory?: string } | null
274+
275+
beforeEach(() => {
276+
db = createTestDb()
277+
captured = null
278+
})
279+
280+
afterEach(() => {
281+
db.close()
282+
})
283+
284+
it('forwards workspace delete to OpenCode with repo directory', async () => {
285+
createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
286+
const forward = mock(async (req: Parameters<OpenCodeClient['forward']>[0]) => {
287+
captured = { path: req.path, directory: req.directory }
288+
return new Response(JSON.stringify({ id: 'wrk_test' }), { status: 200 })
289+
})
290+
const app = createTestApp(db, createStubOpenCodeClient({
291+
forward,
292+
}))
293+
294+
const res = await app.request('/repos/1/workspaces/wrk_test', { method: 'DELETE' })
295+
296+
expect(res.status).toBe(200)
297+
expect(captured?.path).toBe('/experimental/workspace/wrk_test')
298+
expect(captured?.directory?.endsWith('/repos/repo-a')).toBe(true)
299+
})
300+
})
301+
302+
describe('POST /api/repos/:id/workspaces', () => {
303+
let db: Database
304+
let captured: { path: string; directory?: string; body?: string } | null
305+
306+
beforeEach(() => {
307+
db = createTestDb()
308+
captured = null
309+
})
310+
311+
afterEach(() => {
312+
db.close()
313+
})
314+
315+
it('forwards workspace creation to OpenCode with repo directory', async () => {
316+
createRepo(db, { localPath: 'repo-a', defaultBranch: 'main', cloneStatus: 'ready', clonedAt: Date.now(), isLocal: true })
317+
const forward = mock(async (req: Parameters<OpenCodeClient['forward']>[0]) => {
318+
captured = { path: req.path, directory: req.directory, body: req.body }
319+
return new Response(JSON.stringify({ id: 'wrk_test', type: 'worktree', directory: '/tmp/wrk-test' }), { status: 200 })
320+
})
321+
const app = createTestApp(db, createStubOpenCodeClient({
322+
forward,
323+
}))
324+
325+
const res = await app.request('/repos/1/workspaces', { method: 'POST' })
326+
327+
expect(res.status).toBe(200)
328+
expect(captured?.path).toBe('/experimental/workspace')
329+
expect(captured?.directory?.endsWith('/repos/repo-a')).toBe(true)
330+
expect(JSON.parse(captured?.body ?? '{}')).toEqual({ type: 'worktree', branch: null })
331+
expect(await res.json()).toMatchObject({ id: 'wrk_test', type: 'worktree' })
332+
})
333+
})

0 commit comments

Comments
 (0)