feat(desktop): add diff review commit flow

This commit is contained in:
DragonnZhang 2026-04-25 10:15:33 +08:00
parent 82c6675d09
commit 39edf57e6d
9 changed files with 1144 additions and 8 deletions

View file

@ -0,0 +1,63 @@
# Electron Desktop E2E Record: Diff Review and Commit
Date: 2026-04-25
## Slice
Slice 12 basic diff review and commit.
## User-Visible Scenario
1. Launch the desktop app with a temporary HOME/QWEN_RUNTIME_DIR and fake ACP.
2. Open a temporary Git workspace with an initial commit.
3. Modify a tracked file and create an untracked file.
4. Verify the right Review panel lists changed files and textual diff content.
5. Stage all changes from the Review panel.
6. Enter a commit message and commit.
7. Verify the Review panel returns to a clean state.
## Assertions
- `GET /api/projects/:id/git/diff` returns modified and untracked files.
- The Review panel shows changed file count and diff text.
- `POST /api/projects/:id/git/stage` updates Git status from modified/untracked
to staged.
- `POST /api/projects/:id/git/commit` creates a commit and returns a clean
status.
- `POST /api/projects/:id/git/revert` cleans tracked and untracked changes when
explicitly invoked.
- Commit and Git errors are displayed in the review area.
## Diagnostics on Failure
- Save renderer screenshot.
- Save renderer console errors and failed network requests.
- Save Electron main stdout/stderr.
- Save `git -C <workspace> status --porcelain=v1 --branch`.
- Save `git -C <workspace> diff` and `git -C <workspace> diff --cached`.
- Save DesktopServer responses for diff/stage/revert/commit routes.
## Automated Coverage Added This Iteration
The full Electron E2E harness is still pending. This iteration added
server-level coverage in `packages/desktop/src/server/index.test.ts`:
- opens a registered project and reads `/git/diff`;
- verifies modified and untracked files are returned with diff text;
- stages all changes and verifies status counts;
- commits staged changes and verifies a clean status;
- reverts all changes and verifies the workspace returns to the initial file
content.
## Execution Results
- `npm run test --workspace=packages/desktop` passed: 8 files, 50 tests.
- `npm run typecheck --workspace=packages/desktop` passed.
- `npm run lint --workspace=packages/desktop` passed.
- `npm run build --workspace=packages/desktop` passed.
## Remaining Risk
Hunk-level accept/revert, inline comments, Open in Editor, and real Electron
renderer assertions are not complete yet. They remain required before the MVP
can be marked done.

View file

@ -172,17 +172,36 @@ scope before a DONE marker can be created.
### Slice 12: Diff Review and Commit
- Status: pending
- Status: partial complete in iteration 7
- Goal: add Git diff/status review APIs and UI actions for accept/revert and
commit.
- Files:
- `packages/desktop/src/server/services/gitReviewService.ts`
- `packages/desktop/src/server/index.ts`
- `packages/desktop/src/server/index.test.ts`
- `packages/desktop/src/server/services/projectService.ts`
- `packages/desktop/src/renderer/api/client.ts`
- `packages/desktop/src/renderer/App.tsx`
- `packages/desktop/src/renderer/styles.css`
- Acceptance criteria:
- Right Changes tab shows changed files and unified diff.
- Stage/unstage/revert/commit routes are token protected and scoped to a
registered project.
- Commit errors are visible in the UI.
- Completed:
- Token-protected diff, stage, revert, and commit routes scoped to registered
projects.
- Basic right Review panel changed-file list, textual diff preview, Stage
All, Revert All, commit message input, and Commit action.
- Server tests for diff, stage, commit, revert, invalid project path, and Git
status metadata.
- Remaining:
- Hunk-level accept/revert, inline comments, Open in Editor, richer file tree,
and renderer E2E coverage.
- E2E coverage:
- Temporary Git workspace with a fake file change, accept/stage, commit, and
error diagnostics.
- Record in `.qwen/e2e-tests/electron-desktop/diff-review-commit.md`.
- Later Electron E2E must use a temporary Git workspace with a fake file
change, accept/stage, commit, and error diagnostics.
### Slice 13: Scoped Terminal
@ -231,6 +250,10 @@ scope before a DONE marker can be created.
- 2026-04-25: Keep the CDP switch opt-in through `QWEN_DESKTOP_CDP_PORT` and
always pair it with `remote-debugging-address=127.0.0.1`; production remains
closed unless the environment variable is set.
- 2026-04-25: Implement desktop Git review with `git` via `execFile` and
explicit relative path validation. This avoids broad shell execution and
keeps review operations scoped to projects registered through the desktop
project service.
## Verification Log
@ -253,6 +276,11 @@ scope before a DONE marker can be created.
- `curl --fail --silent http://127.0.0.1:9339/json/list` passed and returned
a `Qwen Code` page at
`file:///Users/dragon/Documents/qwen-code/packages/desktop/dist/renderer/index.html`.
- 2026-04-25 Slice 12 basic diff review:
- `npm run test --workspace=packages/desktop` passed: 8 files, 50 tests.
- `npm run typecheck --workspace=packages/desktop` passed.
- `npm run lint --workspace=packages/desktop` passed.
- `npm run build --workspace=packages/desktop` passed.
## Self Review Notes
@ -269,6 +297,14 @@ scope before a DONE marker can be created.
- The CDP smoke verified endpoint discovery but did not yet drive DOM,
console, network, or screenshot assertions through MCP; that remains in the
E2E harness slice.
- Slice 12 review operations resolve the project path from the registered
project id server-side; renderer cannot submit arbitrary cwd values.
- File-scoped Git operations reject absolute paths and parent-directory
traversal. The current UI exposes all-scope operations only; file/hunk UI is
still pending.
- Revert All uses `git restore` and `git clean -fd`, so it is intentionally
available only as an explicit user review action and remains scoped to the
active registered project.
## Remaining Work

View file

@ -16,7 +16,9 @@ import {
} from 'react';
import {
authenticateDesktop,
commitDesktopProjectChanges,
createDesktopSession,
getDesktopProjectGitDiff,
getDesktopProjectGitStatus,
getDesktopSessionModeState,
getDesktopSessionModelState,
@ -25,10 +27,13 @@ import {
listDesktopSessions,
loadDesktopStatus,
openDesktopProject,
revertDesktopProjectChanges,
setDesktopSessionMode,
setDesktopSessionModel,
stageDesktopProjectChanges,
updateDesktopUserSettings,
type DesktopConnectionStatus,
type DesktopGitDiff,
type DesktopProject,
type DesktopSessionSummary,
} from './api/client.js';
@ -73,6 +78,9 @@ export function App() {
const [sessions, setSessions] = useState<DesktopSessionSummary[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [sessionError, setSessionError] = useState<string | null>(null);
const [gitDiff, setGitDiff] = useState<DesktopGitDiff | null>(null);
const [reviewError, setReviewError] = useState<string | null>(null);
const [commitMessage, setCommitMessage] = useState('');
const [messageText, setMessageText] = useState('');
const [chatState, dispatchChat] = useReducer(
chatReducer,
@ -341,6 +349,106 @@ export function App() {
}
}, [activeProject, loadState]);
const loadProjectReview = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
setGitDiff(null);
return;
}
try {
const diff = await getDesktopProjectGitDiff(
loadState.status.serverInfo,
activeProject.id,
);
setGitDiff(diff);
setReviewError(null);
} catch (error) {
setGitDiff(null);
setReviewError(getErrorMessage(error));
}
}, [activeProject, loadState]);
useEffect(() => {
void loadProjectReview();
}, [loadProjectReview]);
const applyReviewMutation = useCallback(
(status: DesktopProject['gitStatus'], diff: DesktopGitDiff) => {
if (!activeProject) {
return;
}
setProjects((current) =>
current.map((project) =>
project.id === activeProject.id
? {
...project,
gitBranch: status.branch,
gitStatus: status,
}
: project,
),
);
setGitDiff(diff);
setReviewError(null);
},
[activeProject],
);
const stageAllChanges = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
try {
const result = await stageDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
);
applyReviewMutation(result.status, result.diff);
} catch (error) {
setReviewError(getErrorMessage(error));
}
}, [activeProject, applyReviewMutation, loadState]);
const revertAllChanges = useCallback(async () => {
if (loadState.state !== 'ready' || !activeProject) {
return;
}
try {
const result = await revertDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
);
applyReviewMutation(result.status, result.diff);
} catch (error) {
setReviewError(getErrorMessage(error));
}
}, [activeProject, applyReviewMutation, loadState]);
const commitChanges = useCallback(async () => {
if (
loadState.state !== 'ready' ||
!activeProject ||
commitMessage.trim().length === 0
) {
return;
}
try {
const result = await commitDesktopProjectChanges(
loadState.status.serverInfo,
activeProject.id,
commitMessage,
);
applyReviewMutation(result.status, result.diff);
setCommitMessage('');
} catch (error) {
setReviewError(getErrorMessage(error));
}
}, [activeProject, applyReviewMutation, commitMessage, loadState]);
const saveSettings = useCallback(async () => {
if (loadState.state !== 'ready') {
return;
@ -583,7 +691,16 @@ export function App() {
<div className="panel-header">
<h3>Review</h3>
</div>
<ReviewSummary project={activeProject} />
<ReviewSummary
commitMessage={commitMessage}
gitDiff={gitDiff}
project={activeProject}
reviewError={reviewError}
onCommit={commitChanges}
onCommitMessageChange={setCommitMessage}
onRevertAll={revertAllChanges}
onStageAll={stageAllChanges}
/>
<RuntimeDetails loadState={loadState} />
<SessionDetails
activeSessionId={activeSessionId}
@ -827,7 +944,25 @@ function PermissionPrompts({
);
}
function ReviewSummary({ project }: { project: DesktopProject | null }) {
function ReviewSummary({
commitMessage,
gitDiff,
onCommit,
onCommitMessageChange,
onRevertAll,
onStageAll,
project,
reviewError,
}: {
commitMessage: string;
gitDiff: DesktopGitDiff | null;
onCommit: () => void;
onCommitMessageChange: (message: string) => void;
onRevertAll: () => void;
onStageAll: () => void;
project: DesktopProject | null;
reviewError: string | null;
}) {
if (!project) {
return (
<div className="review-summary">
@ -837,6 +972,7 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) {
}
const status = project.gitStatus;
const changedFiles = gitDiff?.files ?? [];
return (
<div className="review-summary">
<div className="review-tabs" aria-label="Review sections">
@ -862,6 +998,10 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) {
<dt>Untracked</dt>
<dd>{status.untracked}</dd>
</div>
<div>
<dt>Files</dt>
<dd>{changedFiles.length}</dd>
</div>
{status.error ? (
<div>
<dt>Git</dt>
@ -869,6 +1009,56 @@ function ReviewSummary({ project }: { project: DesktopProject | null }) {
</div>
) : null}
</dl>
<div className="review-actions">
<button
className="secondary-button"
disabled={changedFiles.length === 0}
type="button"
onClick={onRevertAll}
>
Revert All
</button>
<button
className="secondary-button"
disabled={changedFiles.length === 0}
type="button"
onClick={onStageAll}
>
Stage All
</button>
</div>
<div className="changed-files">
{changedFiles.length === 0 ? (
<div className="empty-row">No changes</div>
) : (
changedFiles.map((file) => (
<details key={file.path} open={changedFiles.length === 1}>
<summary>
<span>{file.path}</span>
<small>{file.status}</small>
</summary>
<pre>{file.diff || 'No textual diff available.'}</pre>
</details>
))
)}
</div>
<div className="commit-box">
<input
aria-label="Commit message"
placeholder="Commit message"
value={commitMessage}
onChange={(event) => onCommitMessageChange(event.target.value)}
/>
<button
className="primary-button"
disabled={commitMessage.trim().length === 0}
type="button"
onClick={onCommit}
>
Commit
</button>
</div>
{reviewError ? <p className="error-text">{reviewError}</p> : null}
</div>
);
}

View file

@ -50,6 +50,44 @@ export interface DesktopProjectList {
projects: DesktopProject[];
}
export type DesktopGitChangeStatus =
| 'added'
| 'copied'
| 'deleted'
| 'modified'
| 'renamed'
| 'untracked'
| 'unknown';
export interface DesktopGitChangedFile {
path: string;
status: DesktopGitChangeStatus;
staged: boolean;
unstaged: boolean;
untracked: boolean;
diff: string;
}
export interface DesktopGitDiff {
ok: true;
files: DesktopGitChangedFile[];
diff: string;
generatedAt: string;
}
export interface DesktopGitReviewMutation {
ok: true;
status: DesktopGitStatus;
diff: DesktopGitDiff;
}
export interface DesktopGitCommitMutation extends DesktopGitReviewMutation {
commit: {
commit: string;
summary: string;
};
}
export interface DesktopRuntime {
ok: true;
desktop: {
@ -182,6 +220,57 @@ export async function getDesktopProjectGitStatus(
return response.status;
}
export async function getDesktopProjectGitDiff(
serverInfo: DesktopServerInfo,
projectId: string,
): Promise<DesktopGitDiff> {
return getJson(
serverInfo,
`/api/projects/${encodeURIComponent(projectId)}/git/diff`,
isGitDiff,
);
}
export async function stageDesktopProjectChanges(
serverInfo: DesktopServerInfo,
projectId: string,
): Promise<DesktopGitReviewMutation> {
return writeJson(
serverInfo,
`/api/projects/${encodeURIComponent(projectId)}/git/stage`,
'POST',
{ scope: 'all' },
isGitReviewMutation,
);
}
export async function revertDesktopProjectChanges(
serverInfo: DesktopServerInfo,
projectId: string,
): Promise<DesktopGitReviewMutation> {
return writeJson(
serverInfo,
`/api/projects/${encodeURIComponent(projectId)}/git/revert`,
'POST',
{ scope: 'all' },
isGitReviewMutation,
);
}
export async function commitDesktopProjectChanges(
serverInfo: DesktopServerInfo,
projectId: string,
message: string,
): Promise<DesktopGitCommitMutation> {
return writeJson(
serverInfo,
`/api/projects/${encodeURIComponent(projectId)}/git/commit`,
'POST',
{ message },
isGitCommitMutation,
);
}
export async function createDesktopSession(
serverInfo: DesktopServerInfo,
cwd: string,
@ -476,6 +565,52 @@ function isGitStatusResponse(
return candidate.ok === true && isGitStatus(candidate.status);
}
function isGitDiff(value: unknown): value is DesktopGitDiff {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<DesktopGitDiff>;
return (
candidate.ok === true &&
Array.isArray(candidate.files) &&
candidate.files.every(isGitChangedFile) &&
typeof candidate.diff === 'string' &&
typeof candidate.generatedAt === 'string'
);
}
function isGitReviewMutation(
value: unknown,
): value is DesktopGitReviewMutation {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<DesktopGitReviewMutation>;
return (
candidate.ok === true &&
isGitStatus(candidate.status) &&
isGitDiff(candidate.diff)
);
}
function isGitCommitMutation(
value: unknown,
): value is DesktopGitCommitMutation {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<DesktopGitCommitMutation>;
return (
isGitReviewMutation(value) &&
!!candidate.commit &&
typeof candidate.commit.commit === 'string' &&
typeof candidate.commit.summary === 'string'
);
}
function isCreateSessionResponse(
value: unknown,
): value is { ok: true; session: DesktopSessionSummary } {
@ -593,6 +728,34 @@ function isGitStatus(value: unknown): value is DesktopGitStatus {
);
}
function isGitChangedFile(value: unknown): value is DesktopGitChangedFile {
if (!value || typeof value !== 'object') {
return false;
}
const candidate = value as Partial<DesktopGitChangedFile>;
return (
typeof candidate.path === 'string' &&
isGitChangeStatus(candidate.status) &&
typeof candidate.staged === 'boolean' &&
typeof candidate.unstaged === 'boolean' &&
typeof candidate.untracked === 'boolean' &&
typeof candidate.diff === 'string'
);
}
function isGitChangeStatus(value: unknown): value is DesktopGitChangeStatus {
return (
value === 'added' ||
value === 'copied' ||
value === 'deleted' ||
value === 'modified' ||
value === 'renamed' ||
value === 'untracked' ||
value === 'unknown'
);
}
function isModelState(value: unknown): value is DesktopSessionModelState {
if (!value || typeof value !== 'object') {
return false;

View file

@ -569,6 +569,75 @@ input:focus {
border-left: 1px solid rgba(238, 240, 237, 0.1);
}
.review-actions,
.commit-box {
display: flex;
gap: 8px;
margin-top: 12px;
}
.review-actions {
justify-content: flex-end;
}
.commit-box input {
min-width: 0;
flex: 1;
}
.changed-files {
display: grid;
gap: 8px;
margin-top: 12px;
}
.changed-files details {
border: 1px solid rgba(238, 240, 237, 0.1);
background: rgba(238, 240, 237, 0.03);
}
.changed-files summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 34px;
padding: 8px 10px;
cursor: pointer;
}
.changed-files summary span {
min-width: 0;
overflow: hidden;
color: #eef0ed;
font-size: 12px;
font-weight: 700;
text-overflow: ellipsis;
white-space: nowrap;
}
.changed-files summary small {
color: #9ca39b;
font-size: 11px;
font-weight: 800;
text-transform: uppercase;
}
.changed-files pre {
max-height: 260px;
margin: 0;
overflow: auto;
padding: 10px;
border-top: 1px solid rgba(238, 240, 237, 0.08);
color: #d8dcd6;
font-family:
ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono',
monospace;
font-size: 11px;
line-height: 1.45;
white-space: pre-wrap;
}
.muted {
color: #858c84;
}

View file

@ -195,6 +195,113 @@ describe('DesktopServer', () => {
});
});
it('returns project diffs and can stage and commit changes', async () => {
const projectPath = await createCommittedGitProject();
const storePath = join(
await createTempDirectory('qwen-desktop-store-'),
'desktop-projects.json',
);
await writeFile(join(projectPath, 'tracked.txt'), 'changed\n', 'utf8');
await writeFile(join(projectPath, 'new.txt'), 'new file\n', 'utf8');
const server = await createTestServer(undefined, undefined, storePath);
const opened = await postJson(server, '/api/projects/open', {
path: projectPath,
});
const projectId = getProjectId(opened.body);
const diff = await getJson(
server,
`/api/projects/${encodeURIComponent(projectId)}/git/diff`,
{
Authorization: 'Bearer test-token',
},
);
const staged = await postJson(
server,
`/api/projects/${encodeURIComponent(projectId)}/git/stage`,
{ scope: 'all' },
);
const committed = await postJson(
server,
`/api/projects/${encodeURIComponent(projectId)}/git/commit`,
{ message: 'test commit' },
);
expect(diff.status).toBe(200);
expect(diff.body).toMatchObject({
ok: true,
files: expect.arrayContaining([
expect.objectContaining({ path: 'tracked.txt', status: 'modified' }),
expect.objectContaining({ path: 'new.txt', status: 'untracked' }),
]),
});
expect(JSON.stringify(diff.body)).toContain('+changed');
expect(staged.body).toMatchObject({
ok: true,
status: {
staged: 2,
modified: 0,
untracked: 0,
},
});
expect(committed.body).toMatchObject({
ok: true,
commit: {
commit: expect.any(String),
},
status: {
clean: true,
staged: 0,
modified: 0,
untracked: 0,
},
diff: {
files: [],
},
});
await expect(
runGitOutput(projectPath, ['log', '-1', '--pretty=%s']),
).resolves.toBe('test commit');
});
it('can revert all project changes', async () => {
const projectPath = await createCommittedGitProject();
const storePath = join(
await createTempDirectory('qwen-desktop-store-'),
'desktop-projects.json',
);
await writeFile(join(projectPath, 'tracked.txt'), 'changed\n', 'utf8');
await writeFile(join(projectPath, 'new.txt'), 'new file\n', 'utf8');
const server = await createTestServer(undefined, undefined, storePath);
const opened = await postJson(server, '/api/projects/open', {
path: projectPath,
});
const projectId = getProjectId(opened.body);
const reverted = await postJson(
server,
`/api/projects/${encodeURIComponent(projectId)}/git/revert`,
{ scope: 'all' },
);
expect(reverted.status).toBe(200);
expect(reverted.body).toMatchObject({
ok: true,
status: {
clean: true,
staged: 0,
modified: 0,
untracked: 0,
},
diff: {
files: [],
},
});
await expect(
readFile(join(projectPath, 'tracked.txt'), 'utf8'),
).resolves.toBe('initial\n');
});
it('reads and writes user settings without returning API key secrets', async () => {
const settingsPath = await createTempSettingsPath();
const server = await createTestServer(undefined, settingsPath);
@ -773,6 +880,17 @@ async function createTempSettingsPath(): Promise<string> {
return join(dir, '.qwen', 'settings.json');
}
async function createCommittedGitProject(): Promise<string> {
const projectPath = await createTempDirectory('qwen-desktop-git-');
await runGit(projectPath, ['init']);
await runGit(projectPath, ['config', 'user.email', 'desktop@example.com']);
await runGit(projectPath, ['config', 'user.name', 'Desktop Test']);
await writeFile(join(projectPath, 'tracked.txt'), 'initial\n', 'utf8');
await runGit(projectPath, ['add', '.']);
await runGit(projectPath, ['commit', '-m', 'initial']);
return projectPath;
}
async function getJson(
server: DesktopServer,
path: string,
@ -980,14 +1098,18 @@ function getAvailableModes(message: unknown): unknown[] {
}
function runGit(cwd: string, args: string[]): Promise<void> {
return runGitOutput(cwd, args).then(() => undefined);
}
function runGitOutput(cwd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile('git', args, { cwd }, (error, _stdout, stderr) => {
execFile('git', args, { cwd }, (error, stdout, stderr) => {
if (error) {
reject(new Error(stderr.trim() || error.message));
return;
}
resolve();
resolve(stdout.trim());
});
});
}

View file

@ -20,6 +20,10 @@ import {
import { AcpEventRouter } from './acp/AcpEventRouter.js';
import { PermissionBridge } from './acp/permissionBridge.js';
import { isDesktopHttpError, DesktopHttpError } from './http/errors.js';
import {
DesktopGitReviewService,
type DesktopGitTarget,
} from './services/gitReviewService.js';
import { DesktopProjectService } from './services/projectService.js';
import { getRuntimeInfo } from './services/runtimeService.js';
import {
@ -35,6 +39,7 @@ interface HandlerContext {
token: string;
startedAt: number;
now: () => Date;
gitReviewService: DesktopGitReviewService;
projectService: DesktopProjectService;
sessionService: DesktopSessionService;
settingsService: DesktopSettingsService;
@ -51,6 +56,7 @@ export async function startDesktopServer(
storePath: options.projectStorePath,
now,
});
const gitReviewService = new DesktopGitReviewService(now);
const sessionService = new DesktopSessionService(options.acpClient);
const settingsService = new DesktopSettingsService(options.settingsPath);
const socketHubRef: { current: SessionSocketHub | null } = { current: null };
@ -83,6 +89,7 @@ export async function startDesktopServer(
token,
startedAt,
now,
gitReviewService,
projectService,
sessionService,
settingsService,
@ -226,6 +233,66 @@ async function handleRequest(
return;
}
const projectGitDiffMatch = matchSessionRoute(
requestUrl.pathname,
/^\/api\/projects\/([^/]+)\/git\/diff$/u,
);
if (projectGitDiffMatch) {
await handleProjectGitDiffRoute(
request,
response,
origin,
context,
projectGitDiffMatch,
);
return;
}
const projectGitStageMatch = matchSessionRoute(
requestUrl.pathname,
/^\/api\/projects\/([^/]+)\/git\/stage$/u,
);
if (projectGitStageMatch) {
await handleProjectGitStageRoute(
request,
response,
origin,
context,
projectGitStageMatch,
);
return;
}
const projectGitRevertMatch = matchSessionRoute(
requestUrl.pathname,
/^\/api\/projects\/([^/]+)\/git\/revert$/u,
);
if (projectGitRevertMatch) {
await handleProjectGitRevertRoute(
request,
response,
origin,
context,
projectGitRevertMatch,
);
return;
}
const projectGitCommitMatch = matchSessionRoute(
requestUrl.pathname,
/^\/api\/projects\/([^/]+)\/git\/commit$/u,
);
if (projectGitCommitMatch) {
await handleProjectGitCommitRoute(
request,
response,
origin,
context,
projectGitCommitMatch,
);
return;
}
if (requestUrl.pathname === '/api/settings/user') {
await handleUserSettingsRoute(request, response, origin, context);
return;
@ -395,6 +462,97 @@ async function handleProjectGitStatusRoute(
sendMethodNotAllowed(response, origin);
}
async function handleProjectGitDiffRoute(
request: IncomingMessage,
response: ServerResponse,
origin: string | undefined,
context: HandlerContext,
projectId: string,
): Promise<void> {
if (request.method === 'GET') {
const projectPath = await context.projectService.getProjectPath(projectId);
sendJson(
response,
origin,
200,
await context.gitReviewService.getDiff(projectPath),
);
return;
}
sendMethodNotAllowed(response, origin);
}
async function handleProjectGitStageRoute(
request: IncomingMessage,
response: ServerResponse,
origin: string | undefined,
context: HandlerContext,
projectId: string,
): Promise<void> {
if (request.method === 'POST') {
const body = await readObjectBody(request);
const target = parseGitTarget(body);
const projectPath = await context.projectService.getProjectPath(projectId);
await context.gitReviewService.stage(projectPath, target);
sendJson(response, origin, 200, {
ok: true,
status: await context.projectService.getProjectGitStatus(projectId),
diff: await context.gitReviewService.getDiff(projectPath),
});
return;
}
sendMethodNotAllowed(response, origin);
}
async function handleProjectGitRevertRoute(
request: IncomingMessage,
response: ServerResponse,
origin: string | undefined,
context: HandlerContext,
projectId: string,
): Promise<void> {
if (request.method === 'POST') {
const body = await readObjectBody(request);
const target = parseGitTarget(body);
const projectPath = await context.projectService.getProjectPath(projectId);
await context.gitReviewService.revert(projectPath, target);
sendJson(response, origin, 200, {
ok: true,
status: await context.projectService.getProjectGitStatus(projectId),
diff: await context.gitReviewService.getDiff(projectPath),
});
return;
}
sendMethodNotAllowed(response, origin);
}
async function handleProjectGitCommitRoute(
request: IncomingMessage,
response: ServerResponse,
origin: string | undefined,
context: HandlerContext,
projectId: string,
): Promise<void> {
if (request.method === 'POST') {
const body = await readObjectBody(request);
const message = getRequiredString(body, 'message');
const projectPath = await context.projectService.getProjectPath(projectId);
const commit = await context.gitReviewService.commit(projectPath, message);
sendJson(response, origin, 200, {
ok: true,
commit,
status: await context.projectService.getProjectGitStatus(projectId),
diff: await context.gitReviewService.getDiff(projectPath),
});
return;
}
sendMethodNotAllowed(response, origin);
}
async function handleSessionsRoute(
request: IncomingMessage,
response: ServerResponse,
@ -766,6 +924,22 @@ function parseUserSettingsUpdate(
};
}
function parseGitTarget(body: Record<string, unknown>): DesktopGitTarget {
const scope = body['scope'] ?? 'all';
if (scope === 'all') {
return { scope };
}
if (scope === 'file') {
return {
scope,
filePath: getRequiredString(body, 'filePath'),
};
}
throw new DesktopHttpError(400, 'bad_request', 'scope must be all or file.');
}
function getOptionalString(
body: Record<string, unknown>,
key: string,

View file

@ -0,0 +1,309 @@
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { execFile } from 'node:child_process';
import { readFile } from 'node:fs/promises';
import { isAbsolute, normalize, relative, sep } from 'node:path';
import { DesktopHttpError } from '../http/errors.js';
export type DesktopGitChangeStatus =
| 'added'
| 'copied'
| 'deleted'
| 'modified'
| 'renamed'
| 'untracked'
| 'unknown';
export interface DesktopGitChangedFile {
path: string;
status: DesktopGitChangeStatus;
staged: boolean;
unstaged: boolean;
untracked: boolean;
diff: string;
}
export interface DesktopGitDiff {
ok: true;
files: DesktopGitChangedFile[];
diff: string;
generatedAt: string;
}
export interface DesktopGitCommitResult {
commit: string;
summary: string;
}
export interface DesktopGitTarget {
scope: 'all' | 'file';
filePath?: string;
}
export class DesktopGitReviewService {
constructor(private readonly now: () => Date = () => new Date()) {}
async getDiff(projectPath: string): Promise<DesktopGitDiff> {
const statusEntries = await getPorcelainStatus(projectPath);
const files = await Promise.all(
statusEntries.map((entry) => describeChangedFile(projectPath, entry)),
);
const diff = files
.map((file) => file.diff)
.filter((fileDiff) => fileDiff.length > 0)
.join('\n');
return {
ok: true,
files,
diff,
generatedAt: this.now().toISOString(),
};
}
async stage(projectPath: string, target: DesktopGitTarget): Promise<void> {
if (target.scope === 'all') {
await runGit(projectPath, ['add', '-A']);
return;
}
await runGit(projectPath, [
'add',
'--',
getSafeRelativePath(projectPath, target),
]);
}
async revert(projectPath: string, target: DesktopGitTarget): Promise<void> {
if (target.scope === 'all') {
await runGitIgnoringFailure(projectPath, ['restore', '--staged', '.']);
await runGitIgnoringFailure(projectPath, ['restore', '.']);
await runGit(projectPath, ['clean', '-fd']);
return;
}
const filePath = getSafeRelativePath(projectPath, target);
await runGitIgnoringFailure(projectPath, [
'restore',
'--staged',
'--',
filePath,
]);
await runGitIgnoringFailure(projectPath, ['restore', '--', filePath]);
await runGit(projectPath, ['clean', '-fd', '--', filePath]);
}
async commit(
projectPath: string,
message: string,
): Promise<DesktopGitCommitResult> {
const trimmedMessage = message.trim();
if (!trimmedMessage) {
throw new DesktopHttpError(
400,
'bad_request',
'Commit message must be a non-empty string.',
);
}
const summary = await runGit(projectPath, ['commit', '-m', trimmedMessage]);
const commit = (await runGit(projectPath, ['rev-parse', 'HEAD'])).trim();
return { commit, summary };
}
}
interface PorcelainStatusEntry {
path: string;
indexStatus: string;
worktreeStatus: string;
}
async function getPorcelainStatus(
projectPath: string,
): Promise<PorcelainStatusEntry[]> {
const stdout = await runGit(projectPath, ['status', '--porcelain=v1', '-z']);
const records = stdout.split('\0').filter((record) => record.length > 0);
const entries: PorcelainStatusEntry[] = [];
for (let index = 0; index < records.length; index += 1) {
const record = records[index] ?? '';
const indexStatus = record[0] ?? ' ';
const worktreeStatus = record[1] ?? ' ';
const filePath = record.slice(3);
entries.push({
path: filePath,
indexStatus,
worktreeStatus,
});
if (
(indexStatus === 'R' || indexStatus === 'C') &&
records[index + 1] !== undefined
) {
index += 1;
}
}
return entries;
}
async function describeChangedFile(
projectPath: string,
entry: PorcelainStatusEntry,
): Promise<DesktopGitChangedFile> {
const untracked = entry.indexStatus === '?' && entry.worktreeStatus === '?';
const [cachedDiff, worktreeDiff, untrackedDiff] = await Promise.all([
untracked
? Promise.resolve('')
: runGit(projectPath, ['diff', '--cached', '--', entry.path]),
untracked
? Promise.resolve('')
: runGit(projectPath, ['diff', '--', entry.path]),
untracked
? createUntrackedFileDiff(projectPath, entry.path)
: Promise.resolve(''),
]);
const diff = [cachedDiff, worktreeDiff, untrackedDiff]
.filter((part) => part.length > 0)
.join('\n');
return {
path: entry.path,
status: getChangeStatus(entry),
staged: entry.indexStatus !== ' ' && entry.indexStatus !== '?',
unstaged: entry.worktreeStatus !== ' ' && entry.worktreeStatus !== '?',
untracked,
diff,
};
}
function getChangeStatus(entry: PorcelainStatusEntry): DesktopGitChangeStatus {
if (entry.indexStatus === '?' && entry.worktreeStatus === '?') {
return 'untracked';
}
const status =
entry.worktreeStatus !== ' ' ? entry.worktreeStatus : entry.indexStatus;
if (status === 'A') {
return 'added';
}
if (status === 'C') {
return 'copied';
}
if (status === 'D') {
return 'deleted';
}
if (status === 'M' || status === 'T') {
return 'modified';
}
if (status === 'R') {
return 'renamed';
}
return 'unknown';
}
async function createUntrackedFileDiff(
projectPath: string,
relativePath: string,
): Promise<string> {
const safePath = getSafeRelativePath(projectPath, {
scope: 'file',
filePath: relativePath,
});
try {
const raw = await readFile(`${projectPath}${sep}${safePath}`, 'utf8');
const additions = raw
.split(/\r?\n/u)
.map((line) => `+${line}`)
.join('\n');
return [
`diff --git a/${safePath} b/${safePath}`,
'new file mode 100644',
'--- /dev/null',
`+++ b/${safePath}`,
'@@',
additions,
].join('\n');
} catch {
return `diff unavailable for untracked file ${safePath}`;
}
}
function getSafeRelativePath(
projectPath: string,
target: DesktopGitTarget,
): string {
if (target.scope !== 'file' || !target.filePath?.trim()) {
throw new DesktopHttpError(
400,
'bad_request',
'filePath is required for file-scoped Git review operations.',
);
}
const normalized = normalize(target.filePath);
if (
isAbsolute(normalized) ||
normalized === '..' ||
normalized.startsWith(`..${sep}`)
) {
throw new DesktopHttpError(
400,
'bad_request',
'filePath must stay inside the project.',
);
}
const relativePath = relative(
projectPath,
`${projectPath}${sep}${normalized}`,
);
if (relativePath === '..' || relativePath.startsWith(`..${sep}`)) {
throw new DesktopHttpError(
400,
'bad_request',
'filePath must stay inside the project.',
);
}
return normalized;
}
async function runGitIgnoringFailure(
cwd: string,
args: string[],
): Promise<void> {
try {
await runGit(cwd, args);
} catch {
// Reverting untracked or unstaged paths can legitimately fail. The
// following clean/restore step decides the final result.
}
}
function runGit(cwd: string, args: string[]): Promise<string> {
return new Promise((resolve, reject) => {
execFile(
'git',
['-C', cwd, ...args],
{
maxBuffer: 1024 * 1024 * 8,
timeout: 20_000,
},
(error, stdout, stderr) => {
if (error) {
const message = stderr.trim() || error.message;
reject(new DesktopHttpError(400, 'git_error', message));
return;
}
resolve(stdout);
},
);
});
}

View file

@ -93,6 +93,16 @@ export class DesktopProjectService {
}
async getProjectGitStatus(projectId: string): Promise<DesktopGitStatus> {
const project = await this.getStoredProject(projectId);
return readGitStatus(project.path);
}
async getProjectPath(projectId: string): Promise<string> {
const project = await this.getStoredProject(projectId);
return project.path;
}
private async getStoredProject(projectId: string): Promise<StoredProject> {
const store = await this.readStore();
const project = store.projects.find((entry) => entry.id === projectId);
if (!project) {
@ -103,7 +113,7 @@ export class DesktopProjectService {
);
}
return readGitStatus(project.path);
return project;
}
private async describeStoredProject(