Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
18f8a9d
feat(web-shell): git status chip, visual working-tree diff, and sideb…
wenshao Jul 16, 2026
895a682
fix(web-shell): themed tooltips and git-chip review follow-ups
wenshao Jul 16, 2026
c25ec11
fix(web-shell): address git-integration review suggestions
wenshao Jul 17, 2026
52e4df2
fix(web-shell): focus-visible ring for git chip button; align doc pol…
wenshao Jul 17, 2026
192db5f
fix(web-shell): surface capped diffs, catch row-build failures, cover…
Jul 17, 2026
d3a0bff
fix(web-shell): drop dialog backdrop-blur that froze the page on open
wenshao Jul 17, 2026
0c9edbd
fix(core): guard synthesizeUntrackedHunk against non-regular files
wenshao Jul 17, 2026
9786618
fix(web-shell,core): rename expansion, no-newline marker, chip measur…
wenshao Jul 17, 2026
a27840e
fix(build): generate git-commit info even when prepare build is skipped
wenshao Jul 17, 2026
d3df892
merge: resolve conflicts with origin/main for PR #7054
qwen-code-dev-bot Jul 17, 2026
39e9714
fix(web-shell,cli): address round-6 review suggestions
wenshao Jul 17, 2026
62379e9
fix(web-shell,cli,core): address round-7 review suggestions
wenshao Jul 17, 2026
ab3f3e8
fix(web-shell,cli,core): address round-8 review suggestions
wenshao Jul 17, 2026
6c690c5
fix(cli,web-shell): address round-9 review findings
wenshao Jul 17, 2026
76bfedb
fix(core,cli,web-shell): rename-aware single-file diff (old→new)
wenshao Jul 17, 2026
70dbc9a
fix(cli): address round-10 review suggestions
wenshao Jul 18, 2026
fa6c4e0
test(sdk),docs: cover diff client methods; align design doc
wenshao Jul 18, 2026
b122c9f
fix(web-shell,core): address round-11 review suggestions
wenshao Jul 18, 2026
0f138bd
fix(web-shell,cli): address round-12 review suggestions
wenshao Jul 18, 2026
7fc2656
fix(core,docs): address round-13 review suggestions
wenshao Jul 18, 2026
bf25097
test(core): cover stray no-newline marker before any hunk header
wenshao Jul 18, 2026
d3b4f12
fix(web-shell): unstick per-file diff loading and skip non-path git poll
wenshao Jul 18, 2026
114dd33
fix(web-shell,cli): address review suggestions on the git diff surface
wenshao Jul 18, 2026
09e3562
test(web-shell,cli): cover git chip clean/reload/traversal paths, fix…
wenshao Jul 18, 2026
7740a9b
fix(core): allow literal `..foo` paths in diff normalization
wenshao Jul 18, 2026
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
895 changes: 895 additions & 0 deletions docs/design/2026-07-16-webshell-git-status-diff.md

Large diffs are not rendered by default.

352 changes: 352 additions & 0 deletions docs/plans/2026-07-16-webshell-git-integration.md

Large diffs are not rendered by default.

386 changes: 386 additions & 0 deletions packages/cli/src/serve/routes/workspace-git-diff.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,386 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import express from 'express';
import request from 'supertest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import {
fetchGitDiff,
fetchGitDiffHunksForFile,
} from '@qwen-code/qwen-code-core';
import type { AcpSessionBridge } from '../acp-session-bridge.js';
import { sendBridgeError } from '../server/error-response.js';
import {
createWorkspaceRegistry,
type WorkspaceRegistry,
type WorkspaceRuntime,
} from '../workspace-registry.js';
import {
registerWorkspaceGitDiffRoutes,
registerWorkspaceQualifiedGitDiffRoutes,
} from './workspace-git-diff.js';

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

const fetchGitDiffMock = vi.mocked(fetchGitDiff);
const fetchGitDiffHunksForFileMock = vi.mocked(fetchGitDiffHunksForFile);

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 createWorkspaceRegistry(runtimes);
}

describe('workspace Git diff routes', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('returns the diff file list for the bound workspace', async () => {
fetchGitDiffMock.mockResolvedValue({
stats: { filesCount: 2, linesAdded: 5, linesRemoved: 1 },
perFileStats: new Map([
['src/a.ts', { added: 4, removed: 1, isBinary: false }],
[
'new.txt',
{ added: 1, removed: 0, isBinary: false, isUntracked: true },
],
]),
});
const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get('/workspace/git/diff');

expect(response.status).toBe(200);
expect(response.headers['cache-control']).toBe('no-store');
expect(response.body).toEqual({
v: 1,
workspaceCwd: '/work/main',
available: true,
filesCount: 2,
linesAdded: 5,
linesRemoved: 1,
files: [
{
path: 'src/a.ts',
added: 4,
removed: 1,
isBinary: false,
isUntracked: false,
isDeleted: false,
truncated: false,
},
{
path: 'new.txt',
added: 1,
removed: 0,
isBinary: false,
isUntracked: true,
isDeleted: false,
truncated: false,
},
],
hiddenCount: 0,
});
expect(fetchGitDiffMock).toHaveBeenCalledWith('/work/main');
});

it('carries the pre-rename oldPath through the file list', async () => {
fetchGitDiffMock.mockResolvedValue({
stats: { filesCount: 1, linesAdded: 2, linesRemoved: 1 },
perFileStats: new Map([
[
'src/new.ts',
{ added: 2, removed: 1, isBinary: false, oldPath: 'src/old.ts' },
],
]),
});
const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get('/workspace/git/diff');

expect(response.status).toBe(200);
// The rename must survive serialization keyed by the new path with the old
// path carried alongside, so both the Web Shell dialog and CLI can render
// `old → new`.
expect(response.body.files).toEqual([
{
path: 'src/new.ts',
oldPath: 'src/old.ts',
added: 2,
removed: 1,
isBinary: false,
isUntracked: false,
isDeleted: false,
truncated: false,
},
]);
});

it('reports available=false when the bound workspace is not a repo', async () => {
fetchGitDiffMock.mockResolvedValue(null);
const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get('/workspace/git/diff');

expect(response.status).toBe(200);
expect(response.body).toMatchObject({ available: false, files: [] });
});

it('returns single-file hunks for the bound workspace', async () => {
fetchGitDiffHunksForFileMock.mockResolvedValue({
hunks: [
{
oldStart: 1,
oldLines: 2,
newStart: 1,
newLines: 2,
lines: ['-one', '+ONE', ' two'],
},
],
truncated: false,
});
const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get(
'/workspace/git/diff/file?path=src/a.ts',
);

expect(response.status).toBe(200);
// `truncated` is intentionally ABSENT (not false) on an untruncated diff.
expect(response.body).toEqual({
v: 1,
workspaceCwd: '/work/main',
path: 'src/a.ts',
available: true,
hunks: [
{
oldStart: 1,
oldLines: 2,
newStart: 1,
newLines: 2,
lines: ['-one', '+ONE', ' two'],
},
],
});
expect(fetchGitDiffHunksForFileMock).toHaveBeenCalledWith(
'/work/main',
'src/a.ts',
undefined,
);
});

it('forwards the oldPath query to fetchGitDiffHunksForFile', async () => {
fetchGitDiffHunksForFileMock.mockResolvedValue({
hunks: [],
truncated: false,
});
Comment on lines +207 to +211

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] No route-level test verifies that a traversal-style oldPath query parameter is rejected or handled safely. The core layer's toRepoRelativePath does reject traversal (defense in depth), but the integration contract — route rejects unsafe input before calling core — is never verified. A future core refactor that loosens toRepoRelativePath could silently open a traversal on this route with no test to catch it.

Suggested change
it('forwards the oldPath query to fetchGitDiffHunksForFile', async () => {
fetchGitDiffHunksForFileMock.mockResolvedValue({
hunks: [],
truncated: false,
});
it('forwards the oldPath query to fetchGitDiffHunksForFile', async () => {
fetchGitDiffHunksForFileMock.mockResolvedValue({
hunks: [],
truncated: false,
});
it('rejects a traversal oldPath query safely', async () => {
fetchGitDiffHunksForFileMock.mockResolvedValue(null);
const response = await request(app).get('/workspace/git/diff/file?path=ok.ts&oldPath=../../etc/passwd');
expect(response.status).toBe(200);
expect(fetchGitDiffHunksForFileMock).toHaveBeenCalledWith('/work/main', 'ok.ts', '../../etc/passwd');
});

— qwen3.7-max via Qwen Code /review

const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get(
'/workspace/git/diff/file?path=src/new.ts&oldPath=src/old.ts',
);

expect(response.status).toBe(200);
// The route must parse ?oldPath= and forward it so the core diff is
// computed old→new (rename detection) instead of new-path-as-added.
expect(fetchGitDiffHunksForFileMock).toHaveBeenCalledWith(
'/work/main',
'src/new.ts',
'src/old.ts',
);
});

it('surfaces a traversal oldPath as unavailable via core normalization', async () => {
// The route forwards oldPath verbatim; fetchGitDiffHunksForFile rejects `..`
// traversal (returns null) and the route surfaces that as available:false
// rather than erroring or escaping the workspace.
fetchGitDiffHunksForFileMock.mockResolvedValue(null);
const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get(
'/workspace/git/diff/file?path=ok.ts&oldPath=../../etc/passwd',
);

expect(response.status).toBe(200);
expect(fetchGitDiffHunksForFileMock).toHaveBeenCalledWith(
'/work/main',
'ok.ts',
'../../etc/passwd',
);
expect(response.body.available).toBe(false);
expect(response.body.hunks).toEqual([]);
});

it('surfaces the truncated flag when the diff was capped', async () => {
fetchGitDiffHunksForFileMock.mockResolvedValue({
hunks: [
{
oldStart: 0,
oldLines: 0,
newStart: 1,
newLines: 1,
lines: ['+head'],
},
],
truncated: true,
});
const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get(
'/workspace/git/diff/file?path=big.txt',
);

expect(response.status).toBe(200);
expect(response.body).toMatchObject({ available: true, truncated: true });
});

it('reports available=false when the file has no diff', async () => {
fetchGitDiffHunksForFileMock.mockResolvedValue(null);
const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get(
'/workspace/git/diff/file?path=src/a.ts',
);

expect(response.status).toBe(200);
expect(response.body).toMatchObject({ available: false, hunks: [] });
});

it('rejects a missing path query with 400', async () => {
const app = express();
registerWorkspaceGitDiffRoutes(app, {
boundWorkspace: '/work/main',
sendBridgeError,
});

const response = await request(app).get('/workspace/git/diff/file');

expect(response.status).toBe(400);
expect(response.body).toMatchObject({ errorKind: 'parse_error' });
expect(fetchGitDiffHunksForFileMock).not.toHaveBeenCalled();
});

it('uses the selected trusted workspace runtime for the file route', async () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] This proves selected-runtime ownership only for the file route. A list-handler regression to the bound/primary cwd would stay green. Add the analogous qualified list request and assert fetchGitDiff('/work/secondary').

— Codex GPT-5 via Qwen Code /review

fetchGitDiffHunksForFileMock.mockResolvedValue({
hunks: [],
truncated: false,
});
const app = express();
const primary = runtime('primary', '/work/main', true);
const secondary = runtime('secondary', '/work/secondary', true);
registerWorkspaceQualifiedGitDiffRoutes(app, {
workspaceRegistry: registry([primary, secondary]),
sendBridgeError,
});

const response = await request(app).get(
'/workspaces/secondary/git/diff/file?path=b.ts',
);

expect(response.status).toBe(200);
expect(fetchGitDiffHunksForFileMock).toHaveBeenCalledWith(
'/work/secondary',
'b.ts',
undefined,
);
});

it('rejects an untrusted workspace before diffing', async () => {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Suggestion] The trust regression covers only /diff; the separately registered /diff/file route is not exercised. Add an untrusted file request and assert fetchGitDiffHunksForFile is untouched.

— Codex GPT-5 via Qwen Code /review

const app = express();
const primary = runtime('primary', '/work/main', true);
const untrusted = runtime('untrusted', '/work/untrusted', false);
registerWorkspaceQualifiedGitDiffRoutes(app, {
workspaceRegistry: registry([primary, untrusted]),
sendBridgeError,
});

const response = await request(app).get('/workspaces/untrusted/git/diff');
Comment thread
wenshao marked this conversation as resolved.

expect(response.status).toBe(403);
expect(response.body.code).toBe('untrusted_workspace');
expect(fetchGitDiffMock).not.toHaveBeenCalled();
});

it('rejects an untrusted workspace on the single-file endpoint too', async () => {
const app = express();
const primary = runtime('primary', '/work/main', true);
const untrusted = runtime('untrusted', '/work/untrusted', false);
registerWorkspaceQualifiedGitDiffRoutes(app, {
workspaceRegistry: registry([primary, untrusted]),
sendBridgeError,
});

const response = await request(app).get(
'/workspaces/untrusted/git/diff/file?path=a.ts',
);

expect(response.status).toBe(403);
expect(response.body.code).toBe('untrusted_workspace');
expect(fetchGitDiffHunksForFileMock).not.toHaveBeenCalled();
});

it('rejects an unknown workspace', async () => {
const app = express();
const primary = runtime('primary', '/work/main', true);
registerWorkspaceQualifiedGitDiffRoutes(app, {
workspaceRegistry: registry([primary]),
sendBridgeError,
});

const response = await request(app).get('/workspaces/missing/git/diff');

expect(response.status).toBe(400);
expect(response.body).toMatchObject({ code: 'workspace_mismatch' });
});
});
Loading
Loading