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

@ -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(