Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
117 changes: 117 additions & 0 deletions packages/cli/src/serve/routes/workspace-git.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
/**
* @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 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,
});

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('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,
});

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,
});

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();
});
});
57 changes: 57 additions & 0 deletions packages/cli/src/serve/routes/workspace-git.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/**
* @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 { 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;
},
): void {
app.get('/workspace/git', async (_req, res) => {
res
.status(200)
.json(await deps.gitState.getStatus(deps.boundWorkspace, deps.bridge));
});
}

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 },
): void {
app.get('/workspaces/:workspace/git', async (req, res) => {
const runtime = resolveTrustedRuntime(deps.workspaceRegistry, req, res);
if (!runtime) return;
res
.status(200)
.json(
await deps.gitState.getStatus(runtime.workspaceCwd, runtime.bridge),
);
});
}
3 changes: 3 additions & 0 deletions packages/cli/src/serve/run-qwen-serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4127,6 +4127,9 @@ export async function runQwenServe(
(
app.locals as { stopScheduledTaskKeepalive?: () => void }
).stopScheduledTaskKeepalive?.();
(
app.locals as { stopWorkspaceGitState?: () => void }
).stopWorkspaceGitState?.();
// Same rationale for the create_sub_session launchers: stop accepting
// new sub-session spawns before the bridges are torn down. Calls
// every workspace's launcher stop (primary + secondaries).
Expand Down
17 changes: 17 additions & 0 deletions packages/cli/src/serve/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,11 @@ import {
registerWorkspaceQualifiedLifecycleRoutes,
} from './routes/workspace-lifecycle.js';
import { registerWorkspaceManagementRoutes } from './routes/workspace-management.js';
import {
registerWorkspaceGitRoutes,
registerWorkspaceQualifiedGitRoutes,
} from './routes/workspace-git.js';
import { WorkspaceGitState } from './workspace-git-state.js';
import {
registerWorkspaceMcpControlRoutes,
registerWorkspaceQualifiedMcpControlRoutes,
Expand Down Expand Up @@ -782,6 +787,9 @@ export function createServeApp(
const primaryBridge = primaryRuntime.bridge;
const primaryWorkspace = primaryRuntime.workspaceService;
const primaryRouteFileSystemFactory = primaryRuntime.routeFileSystemFactory;
const workspaceGitState = new WorkspaceGitState();
(app.locals as { stopWorkspaceGitState?: () => void }).stopWorkspaceGitState =
() => workspaceGitState.dispose();
const workspaceQualifiedAcpEnabled =
resolveAcpHttpEnabled() && workspaceRegistry.list().length > 1;

Expand Down Expand Up @@ -977,6 +985,15 @@ export function createServeApp(
workspaceRegistry,
sendBridgeError,
});
registerWorkspaceGitRoutes(app, {
boundWorkspace: primaryBoundWorkspace,
bridge: primaryBridge,
gitState: workspaceGitState,
});
registerWorkspaceQualifiedGitRoutes(app, {
workspaceRegistry,
gitState: workspaceGitState,
});

// Workspace memory + agents CRUD routes.
mountWorkspaceMemoryRoutes(app, {
Expand Down
85 changes: 85 additions & 0 deletions packages/cli/src/serve/workspace-git-state.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { beforeEach, describe, expect, it, vi } from 'vitest';
import { resolveBranchName, watchRepoBranch } from '@qwen-code/qwen-code-core';
import type { AcpSessionBridge } from './acp-session-bridge.js';
import { WorkspaceGitState } from './workspace-git-state.js';

vi.mock('@qwen-code/qwen-code-core', () => ({
resolveBranchName: vi.fn(),
watchRepoBranch: vi.fn(),
}));

const resolveBranchNameMock = vi.mocked(resolveBranchName);
const watchRepoBranchMock = vi.mocked(watchRepoBranch);

describe('WorkspaceGitState', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns the current branch and publishes only real changes', async () => {
let onChange: (() => void) | undefined;
const dispose = vi.fn();
const publishWorkspaceEvent = vi.fn();
resolveBranchNameMock
.mockResolvedValueOnce('main')
.mockResolvedValueOnce('main')
.mockResolvedValueOnce('feature/web-shell');
watchRepoBranchMock.mockImplementation(async (_cwd, callback) => {
onChange = callback;
return dispose;
});
const state = new WorkspaceGitState();

await expect(
state.getStatus('/workspace', {
publishWorkspaceEvent,
} as unknown as AcpSessionBridge),
).resolves.toEqual({
v: 1,
workspaceCwd: '/workspace',
branch: 'main',
});

onChange?.();
await vi.waitFor(() =>
expect(resolveBranchNameMock).toHaveBeenCalledTimes(2),
);
expect(publishWorkspaceEvent).not.toHaveBeenCalled();

onChange?.();
await vi.waitFor(() =>
expect(publishWorkspaceEvent).toHaveBeenCalledWith({
type: 'git_branch_changed',
data: { workspaceCwd: '/workspace', branch: 'feature/web-shell' },
}),
);

state.dispose();
await vi.waitFor(() => expect(dispose).toHaveBeenCalledOnce());
});

it('returns null and keeps the watcher shared for a non-git workspace', async () => {
resolveBranchNameMock.mockResolvedValue(undefined);
watchRepoBranchMock.mockResolvedValue(() => {});
const state = new WorkspaceGitState();
const bridge = {
publishWorkspaceEvent: vi.fn(),
} as unknown as AcpSessionBridge;

const [first, second] = await Promise.all([
state.getStatus('/plain', bridge),
state.getStatus('/plain', bridge),
]);

expect(first.branch).toBeNull();
expect(second.branch).toBeNull();
expect(resolveBranchNameMock).toHaveBeenCalledOnce();
expect(watchRepoBranchMock).toHaveBeenCalledOnce();
});
});
76 changes: 76 additions & 0 deletions packages/cli/src/serve/workspace-git-state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { resolveBranchName, watchRepoBranch } from '@qwen-code/qwen-code-core';
import type { AcpSessionBridge } from './acp-session-bridge.js';

export interface WorkspaceGitStatus {
v: 1;
workspaceCwd: string;
branch: string | null;
}

interface WorkspaceGitEntry {
branch: string | undefined;
dispose: () => void;
}

export class WorkspaceGitState {
private readonly entries = new Map<string, Promise<WorkspaceGitEntry>>();

async getStatus(
workspaceCwd: string,
bridge: AcpSessionBridge,
): Promise<WorkspaceGitStatus> {
const entry = await this.getOrCreateEntry(workspaceCwd, bridge);
return { v: 1, workspaceCwd, branch: entry.branch ?? null };
}

dispose(): void {
for (const pending of this.entries.values()) {
void pending.then((entry) => entry.dispose()).catch(() => {});
}
this.entries.clear();
}

private getOrCreateEntry(
workspaceCwd: string,
bridge: AcpSessionBridge,
): Promise<WorkspaceGitEntry> {
const existing = this.entries.get(workspaceCwd);
if (existing) return existing;

const pending = this.createEntry(workspaceCwd, bridge).catch((error) => {
this.entries.delete(workspaceCwd);
throw error;
});
this.entries.set(workspaceCwd, pending);
return pending;
}

private async createEntry(
workspaceCwd: string,
bridge: AcpSessionBridge,
): Promise<WorkspaceGitEntry> {
const entry: WorkspaceGitEntry = {
branch: await resolveBranchName(workspaceCwd),
dispose: () => {},
};
const refresh = async () => {
const branch = await resolveBranchName(workspaceCwd);
if (branch === entry.branch) return;
entry.branch = branch;
bridge.publishWorkspaceEvent({
type: 'git_branch_changed',
data: { workspaceCwd, branch: branch ?? null },
});
};
entry.dispose = await watchRepoBranch(workspaceCwd, () => {
void refresh().catch(() => {});
});
return entry;
}
}
Loading
Loading