-
Notifications
You must be signed in to change notification settings - Fork 3k
feat(web-shell): show current git branch in composer toolbar #6725
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
a84c7de
6cccd16
ad13473
9965417
4cc37c9
130e986
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| /** | ||
| * @license | ||
| * Copyright 2026 Qwen Team | ||
| * SPDX-License-Identifier: Apache-2.0 | ||
| */ | ||
|
|
||
| import express from 'express'; | ||
| import request from 'supertest'; | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import type { AcpSessionBridge } from '../acp-session-bridge.js'; | ||
| import { sendBridgeError } from '../server/error-response.js'; | ||
| import type { WorkspaceGitState } from '../workspace-git-state.js'; | ||
| import type { | ||
| WorkspaceRegistry, | ||
| WorkspaceRuntime, | ||
| } from '../workspace-registry.js'; | ||
| import { | ||
| registerWorkspaceGitRoutes, | ||
| registerWorkspaceQualifiedGitRoutes, | ||
| } from './workspace-git.js'; | ||
|
|
||
| function runtime( | ||
| workspaceId: string, | ||
| workspaceCwd: string, | ||
| trusted: boolean, | ||
| ): WorkspaceRuntime { | ||
| return { | ||
| workspaceId, | ||
| workspaceCwd, | ||
| primary: workspaceId === 'primary', | ||
| trusted, | ||
| bridge: { publishWorkspaceEvent: vi.fn() } as unknown as AcpSessionBridge, | ||
| } as WorkspaceRuntime; | ||
| } | ||
|
|
||
| function registry(runtimes: WorkspaceRuntime[]): WorkspaceRegistry { | ||
| return { | ||
| primary: runtimes[0]!, | ||
| list: () => runtimes, | ||
| getByWorkspaceCwd: (cwd) => | ||
| runtimes.find((item) => item.workspaceCwd === cwd), | ||
| getByWorkspaceId: (id) => runtimes.find((item) => item.workspaceId === id), | ||
| resolveWorkspaceCwd: (cwd) => | ||
| cwd === undefined | ||
| ? runtimes[0] | ||
| : runtimes.find((item) => item.workspaceCwd === cwd), | ||
| resolveLiveSessionOwner: () => ({ kind: 'not_found' }), | ||
| add: () => {}, | ||
| }; | ||
| } | ||
|
|
||
| describe('workspace Git routes', () => { | ||
| it('returns Git status for the bound workspace', async () => { | ||
| const app = express(); | ||
| const bridge = runtime('primary', '/work/main', true).bridge; | ||
| const getStatus = vi.fn(async () => ({ | ||
| v: 1 as const, | ||
| workspaceCwd: '/work/main', | ||
| branch: 'main', | ||
| })); | ||
| registerWorkspaceGitRoutes(app, { | ||
| boundWorkspace: '/work/main', | ||
| bridge, | ||
| gitState: { getStatus } as unknown as WorkspaceGitState, | ||
| sendBridgeError, | ||
| }); | ||
|
|
||
| const response = await request(app).get('/workspace/git'); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(response.body).toEqual({ | ||
| v: 1, | ||
| workspaceCwd: '/work/main', | ||
| branch: 'main', | ||
| }); | ||
| expect(getStatus).toHaveBeenCalledWith('/work/main', bridge); | ||
| }); | ||
|
|
||
| it('returns a structured error when bound Git status fails', async () => { | ||
| const app = express(); | ||
| const bridge = runtime('primary', '/work/main', true).bridge; | ||
| const getStatus = vi.fn(async () => { | ||
| throw Object.assign(new Error('git failed'), { | ||
| code: 'git_status_failed', | ||
| }); | ||
| }); | ||
| registerWorkspaceGitRoutes(app, { | ||
| boundWorkspace: '/work/main', | ||
| bridge, | ||
| gitState: { getStatus } as unknown as WorkspaceGitState, | ||
| sendBridgeError, | ||
| }); | ||
|
|
||
| const response = await request(app).get('/workspace/git'); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| expect(response.body).toMatchObject({ | ||
| error: 'git failed', | ||
| code: 'git_status_failed', | ||
| }); | ||
| }); | ||
|
|
||
| it('uses the selected trusted workspace runtime', async () => { | ||
| const app = express(); | ||
| const primary = runtime('primary', '/work/main', true); | ||
| const secondary = runtime('secondary', '/work/secondary', true); | ||
| const getStatus = vi.fn(async () => ({ | ||
| v: 1 as const, | ||
| workspaceCwd: secondary.workspaceCwd, | ||
| branch: 'feature/web-shell', | ||
| })); | ||
| registerWorkspaceQualifiedGitRoutes(app, { | ||
| workspaceRegistry: registry([primary, secondary]), | ||
| gitState: { getStatus } as unknown as WorkspaceGitState, | ||
| sendBridgeError, | ||
| }); | ||
|
|
||
| const response = await request(app).get('/workspaces/secondary/git'); | ||
|
|
||
| expect(response.status).toBe(200); | ||
| expect(response.body.branch).toBe('feature/web-shell'); | ||
| expect(getStatus).toHaveBeenCalledWith( | ||
| secondary.workspaceCwd, | ||
| secondary.bridge, | ||
| ); | ||
| }); | ||
|
|
||
| it('rejects an untrusted workspace before reading Git status', async () => { | ||
| const app = express(); | ||
| const primary = runtime('primary', '/work/main', true); | ||
| const untrusted = runtime('untrusted', '/work/untrusted', false); | ||
| const getStatus = vi.fn(); | ||
| registerWorkspaceQualifiedGitRoutes(app, { | ||
| workspaceRegistry: registry([primary, untrusted]), | ||
| gitState: { getStatus } as unknown as WorkspaceGitState, | ||
| sendBridgeError, | ||
| }); | ||
|
|
||
| const response = await request(app).get('/workspaces/untrusted/git'); | ||
|
|
||
| expect(response.status).toBe(403); | ||
| expect(response.body.code).toBe('untrusted_workspace'); | ||
| expect(getStatus).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('rejects an unknown workspace before reading Git status', async () => { | ||
| const app = express(); | ||
| const primary = runtime('primary', '/work/main', true); | ||
| const getStatus = vi.fn(); | ||
| registerWorkspaceQualifiedGitRoutes(app, { | ||
| workspaceRegistry: registry([primary]), | ||
| gitState: { getStatus } as unknown as WorkspaceGitState, | ||
| sendBridgeError, | ||
| }); | ||
|
|
||
| const response = await request(app).get('/workspaces/missing/git'); | ||
|
|
||
| expect(response.status).toBe(400); | ||
| expect(response.body).toMatchObject({ | ||
| code: 'workspace_mismatch', | ||
| }); | ||
| expect(getStatus).not.toHaveBeenCalled(); | ||
| }); | ||
|
|
||
| it('returns a structured error when qualified Git status fails', async () => { | ||
| const app = express(); | ||
| const primary = runtime('primary', '/work/main', true); | ||
| const getStatus = vi.fn(async () => { | ||
| throw Object.assign(new Error('qualified git failed'), { | ||
| data: { reason: 'watcher' }, | ||
| }); | ||
| }); | ||
| registerWorkspaceQualifiedGitRoutes(app, { | ||
| workspaceRegistry: registry([primary]), | ||
| gitState: { getStatus } as unknown as WorkspaceGitState, | ||
| sendBridgeError, | ||
| }); | ||
|
|
||
| const response = await request(app).get('/workspaces/primary/git'); | ||
|
|
||
| expect(response.status).toBe(500); | ||
| expect(response.body).toMatchObject({ | ||
| error: 'qualified git failed', | ||
| data: { reason: 'watcher' }, | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,72 @@ | ||||||||||||||||||||||||||||||||||
| /** | ||||||||||||||||||||||||||||||||||
| * @license | ||||||||||||||||||||||||||||||||||
| * Copyright 2026 Qwen Team | ||||||||||||||||||||||||||||||||||
| * SPDX-License-Identifier: Apache-2.0 | ||||||||||||||||||||||||||||||||||
| */ | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| import type { Application, Request, Response } from 'express'; | ||||||||||||||||||||||||||||||||||
| import type { AcpSessionBridge } from '../acp-session-bridge.js'; | ||||||||||||||||||||||||||||||||||
| import type { SendBridgeError } from '../server/error-response.js'; | ||||||||||||||||||||||||||||||||||
| import type { WorkspaceGitState } from '../workspace-git-state.js'; | ||||||||||||||||||||||||||||||||||
| import type { | ||||||||||||||||||||||||||||||||||
| WorkspaceRegistry, | ||||||||||||||||||||||||||||||||||
| WorkspaceRuntime, | ||||||||||||||||||||||||||||||||||
| } from '../workspace-registry.js'; | ||||||||||||||||||||||||||||||||||
| import { | ||||||||||||||||||||||||||||||||||
| requireTrustedWorkspaceRuntime, | ||||||||||||||||||||||||||||||||||
| resolveWorkspaceRuntimeFromParam, | ||||||||||||||||||||||||||||||||||
| } from '../workspace-route-runtime.js'; | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export function registerWorkspaceGitRoutes( | ||||||||||||||||||||||||||||||||||
| app: Application, | ||||||||||||||||||||||||||||||||||
| deps: { | ||||||||||||||||||||||||||||||||||
| boundWorkspace: string; | ||||||||||||||||||||||||||||||||||
| bridge: AcpSessionBridge; | ||||||||||||||||||||||||||||||||||
| gitState: WorkspaceGitState; | ||||||||||||||||||||||||||||||||||
| sendBridgeError: SendBridgeError; | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||
| app.get('/workspace/git', async (_req, res) => { | ||||||||||||||||||||||||||||||||||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Suggestion] Both route handlers (
Suggested change
The same pattern should be applied to the workspace-qualified route. Consider also adding a test that mocks — qwen3.7-max via Qwen Code /review |
||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| res | ||||||||||||||||||||||||||||||||||
| .status(200) | ||||||||||||||||||||||||||||||||||
| .json(await deps.gitState.getStatus(deps.boundWorkspace, deps.bridge)); | ||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||
| deps.sendBridgeError(res, err, { route: 'GET /workspace/git' }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| function resolveTrustedRuntime( | ||||||||||||||||||||||||||||||||||
| registry: WorkspaceRegistry, | ||||||||||||||||||||||||||||||||||
| req: Request, | ||||||||||||||||||||||||||||||||||
| res: Response, | ||||||||||||||||||||||||||||||||||
| ): WorkspaceRuntime | null { | ||||||||||||||||||||||||||||||||||
| const runtime = resolveWorkspaceRuntimeFromParam(registry, req, res); | ||||||||||||||||||||||||||||||||||
| if (!runtime) return null; | ||||||||||||||||||||||||||||||||||
| return requireTrustedWorkspaceRuntime(runtime, res) ? runtime : null; | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||
| export function registerWorkspaceQualifiedGitRoutes( | ||||||||||||||||||||||||||||||||||
| app: Application, | ||||||||||||||||||||||||||||||||||
| deps: { | ||||||||||||||||||||||||||||||||||
| workspaceRegistry: WorkspaceRegistry; | ||||||||||||||||||||||||||||||||||
| gitState: WorkspaceGitState; | ||||||||||||||||||||||||||||||||||
| sendBridgeError: SendBridgeError; | ||||||||||||||||||||||||||||||||||
| }, | ||||||||||||||||||||||||||||||||||
| ): void { | ||||||||||||||||||||||||||||||||||
| app.get('/workspaces/:workspace/git', async (req, res) => { | ||||||||||||||||||||||||||||||||||
| const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res); | ||||||||||||||||||||||||||||||||||
| if (!runtime) return; | ||||||||||||||||||||||||||||||||||
| const route = 'GET /workspaces/:workspace/git'; | ||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||
| res | ||||||||||||||||||||||||||||||||||
| .status(200) | ||||||||||||||||||||||||||||||||||
| .json( | ||||||||||||||||||||||||||||||||||
| await deps.gitState.getStatus(runtime.workspaceCwd, runtime.bridge), | ||||||||||||||||||||||||||||||||||
| ); | ||||||||||||||||||||||||||||||||||
| } catch (err) { | ||||||||||||||||||||||||||||||||||
| deps.sendBridgeError(res, err, { route }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
| }); | ||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[Suggestion] No test covers the workspace-not-found path (e.g.,
GET /workspaces/nonexistent/git). TheresolveWorkspaceRuntimeFromParamutility correctly sends a 400 withworkspace_mismatchwhen the workspace doesn't exist, but this behavior is never exercised for the new git route. Adding a test with a nonexistent workspace ID would guard against future changes to the resolution chain.— qwen3.7-max via Qwen Code /review