feat: add git workflow backend operations

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-31 16:43:13 +01:00
co-authored by Copilot
parent 44d0ab07db
commit 906433f408
16 changed files with 2434 additions and 9 deletions
+243 -3
View File
@@ -62,7 +62,14 @@ import {
type PendingApprovalMessageRecord,
type PendingApprovalRecord,
} from '@shared/domain/approval';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import {
isScratchpadProject,
type ProjectGitCommitMessageSuggestion,
type ProjectGitDetails,
type ProjectGitDiffPreview,
type ProjectGitFileReference,
type ProjectRecord,
} from '@shared/domain/project';
import {
branchSessionRecord,
duplicateSessionRecord,
@@ -93,6 +100,7 @@ import {
completeSessionRunRecord,
createSessionRunRecord,
failSessionRunRecord,
setSessionRunGitSummary,
upsertRunApprovalEvent,
upsertRunMessageEvent,
upsertSessionRunRecord,
@@ -130,6 +138,7 @@ import {
SidecarClient,
} from '@main/sidecar/sidecarProcess';
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
import { buildProjectGitCommitMessageSuggestion } from '@main/git/gitCommitMessageSuggestion';
import { GitService } from '@main/git/gitService';
import {
buildRunTurnToolingConfig as buildSessionToolingConfig,
@@ -1357,8 +1366,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
): Promise<void> {
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
const runWorkingDirectory = session.cwd ?? project.path;
const preRunGitSnapshot = workspaceKind === 'project'
? await this.gitService.captureWorkingTreeSnapshot(session.cwd ?? project.path, occurredAt)
? await this.gitService.captureWorkingTreeSnapshot(runWorkingDirectory, occurredAt)
: undefined;
const preRunGitBaselineFiles = workspaceKind === 'project' && preRunGitSnapshot
? await this.gitService.captureWorkingTreeBaseline(runWorkingDirectory, preRunGitSnapshot)
: undefined;
if (workspaceKind === 'project' && project.git?.status === 'ready' && !preRunGitSnapshot) {
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
@@ -1374,11 +1387,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
createSessionRunRecord({
requestId,
project,
workingDirectory: runWorkingDirectory,
workspaceKind,
pattern: effectivePattern,
triggerMessageId,
startedAt: occurredAt,
preRunGitSnapshot,
preRunGitBaselineFiles,
}),
...session.runs,
];
@@ -1397,7 +1412,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
type: 'run-turn',
requestId,
sessionId: session.id,
projectPath: session.cwd ?? project.path,
projectPath: runWorkingDirectory,
workspaceKind,
mode: session.interactionMode ?? 'interactive',
messageMode,
@@ -1437,6 +1452,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
if (workspaceKind === 'project') {
const completedRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
if (completedRun) {
this.emitRunUpdated(session.id, nowIso(), completedRun);
}
}
await this.persistAndBroadcast(workspace);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
@@ -1444,6 +1465,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
} catch (error) {
if (error instanceof TurnCancelledError) {
this.finalizeCancelledTurn(workspace, session, requestId);
if (workspaceKind === 'project') {
const cancelledRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
if (cancelledRun) {
this.emitRunUpdated(session.id, nowIso(), cancelledRun);
}
}
await this.persistAndBroadcast(workspace);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
@@ -1469,6 +1496,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
this.emitRunUpdated(session.id, failedAt, failedRun);
}
if (workspaceKind === 'project') {
const summarizedRun = await this.refreshSessionRunGitSummary(session, project, requestId, failedAt);
if (summarizedRun) {
this.emitRunUpdated(session.id, failedAt, summarizedRun);
}
}
await this.persistAndBroadcast(workspace);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
@@ -1560,6 +1594,157 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.refreshProjectGitContexts(projectId ? [projectId] : undefined);
}
async getProjectGitDetails(projectId: string, commitLimit = 20): Promise<ProjectGitDetails> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
return this.gitService.describeProjectGitDetails(project.path, nowIso(), commitLimit);
}
async getProjectGitFilePreview(
projectId: string,
file: ProjectGitFileReference,
): Promise<ProjectGitDiffPreview | undefined> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
return this.gitService.getWorkingTreeFilePreview(project.path, file);
}
async discardSessionRunGitChanges(
sessionId: string,
runId: string,
files?: ProjectGitFileReference[],
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId);
const run = this.requireSessionRun(session, runId);
if (run.workspaceKind !== 'project') {
throw new Error('Run change review is only available for project-backed sessions.');
}
if (!run.postRunGitSummary) {
throw new Error('This run does not have any tracked git changes to discard.');
}
await this.gitService.discardRunChanges(
this.resolveRunWorkingDirectory(session, project, run),
{
summary: run.postRunGitSummary,
preRunBaselineFiles: run.preRunGitBaselineFiles,
files,
},
);
await this.refreshProjectGitContexts([project.id]);
const refreshedWorkspace = await this.loadWorkspace();
const refreshedSession = this.requireSession(refreshedWorkspace, sessionId);
const refreshedProject = this.requireProject(refreshedWorkspace, refreshedSession.projectId);
const nextRun = await this.refreshSessionRunGitSummary(
refreshedSession,
refreshedProject,
run.requestId,
nowIso(),
);
if (nextRun) {
this.emitRunUpdated(refreshedSession.id, nowIso(), nextRun);
}
return this.persistAndBroadcast(refreshedWorkspace);
}
async stageProjectGitFiles(projectId: string, files: ProjectGitFileReference[]): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
await this.gitService.stageFiles(project.path, files);
});
}
async unstageProjectGitFiles(projectId: string, files: ProjectGitFileReference[]): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
await this.gitService.unstageFiles(project.path, files);
});
}
async suggestProjectGitCommitMessage(
sessionId: string,
runId?: string,
conventionalType?: ProjectGitCommitMessageSuggestion['type'],
): Promise<ProjectGitCommitMessageSuggestion> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const run = runId
? this.requireSessionRun(session, runId)
: session.runs[0];
if (!run) {
throw new Error('This session does not have a run to summarize into a commit message.');
}
return buildProjectGitCommitMessageSuggestion({
session,
run,
summary: run.postRunGitSummary,
conventionalType,
});
}
async commitProjectGitChanges(
projectId: string,
message: string,
files?: ProjectGitFileReference[],
push = false,
): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
if (files && files.length > 0) {
await this.gitService.stageFiles(project.path, files);
}
await this.gitService.commit(project.path, message);
if (push) {
await this.gitService.push(project.path);
}
});
}
async pushProjectGit(projectId: string): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
await this.gitService.push(project.path);
});
}
async fetchProjectGit(projectId: string): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
await this.gitService.fetch(project.path);
});
}
async pullProjectGit(projectId: string, rebase = false): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
await this.gitService.pull(project.path, rebase);
});
}
async createProjectGitBranch(
projectId: string,
name: string,
startPoint?: string,
checkout = true,
): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
await this.gitService.createBranch(project.path, name, startPoint, checkout);
});
}
async switchProjectGitBranch(projectId: string, name: string): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
await this.gitService.switchBranch(project.path, name);
});
}
async deleteProjectGitBranch(projectId: string, name: string, force = false): Promise<WorkspaceState> {
return this.runProjectGitMutation(projectId, async (project) => {
await this.gitService.deleteBranch(project.path, name, force);
});
}
private async refreshProjectGitContexts(projectIds?: readonly string[]): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const projects = projectIds?.length
@@ -1645,6 +1830,61 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return project;
}
private requireSessionRun(session: SessionRecord, runId: string): SessionRunRecord {
const run = session.runs.find((candidate) => candidate.id === runId);
if (!run) {
throw new Error(`Run "${runId}" was not found for session "${session.id}".`);
}
return run;
}
private resolveRunWorkingDirectory(
session: SessionRecord,
project: ProjectRecord,
run: SessionRunRecord,
): string {
return run.workingDirectory ?? session.cwd ?? run.projectPath ?? project.path;
}
private async refreshSessionRunGitSummary(
session: SessionRecord,
project: ProjectRecord,
requestId: string,
occurredAt: string,
): Promise<SessionRunRecord | undefined> {
const run = session.runs.find((candidate) => candidate.requestId === requestId);
if (!run || run.workspaceKind !== 'project' || !run.preRunGitSnapshot) {
return undefined;
}
const summary = await this.gitService.computeRunChangeSummary(
this.resolveRunWorkingDirectory(session, project, run),
{
generatedAt: occurredAt,
preRunSnapshot: run.preRunGitSnapshot,
preRunBaselineFiles: run.preRunGitBaselineFiles,
},
);
return this.updateSessionRun(session, requestId, (currentRun) =>
setSessionRunGitSummary(currentRun, summary));
}
private async runProjectGitMutation(
projectId: string,
mutation: (project: ProjectRecord) => Promise<void>,
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
if (isScratchpadProject(project)) {
throw new Error('Git operations are not available for the Scratchpad project.');
}
await mutation(project);
return this.refreshProjectGitContexts([project.id]);
}
private resolveTerminalWorkingDirectory(workspace: WorkspaceState): string {
const selectedSession = workspace.selectedSessionId
? workspace.sessions.find((session) => session.id === workspace.selectedSessionId)
+141
View File
@@ -0,0 +1,141 @@
import { basename } from 'node:path';
import type {
ProjectGitCommitMessageSuggestion,
ProjectGitConventionalCommitType,
ProjectGitRunChangeSummary,
} from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
interface BuildCommitMessageSuggestionInput {
session: Pick<SessionRecord, 'messages' | 'title'>;
run: Pick<SessionRunRecord, 'triggerMessageId'>;
summary?: ProjectGitRunChangeSummary;
conventionalType?: ProjectGitConventionalCommitType;
}
const COMMIT_TYPES: readonly ProjectGitConventionalCommitType[] = [
'feat',
'fix',
'refactor',
'docs',
'test',
'chore',
];
function normalizeWhitespace(value: string): string {
return value.replace(/\s+/g, ' ').trim();
}
function isCommitType(value: string | undefined): value is ProjectGitConventionalCommitType {
return value !== undefined && COMMIT_TYPES.includes(value as ProjectGitConventionalCommitType);
}
function findTriggerMessageContent(
session: Pick<SessionRecord, 'messages'>,
triggerMessageId: string,
): string | undefined {
return session.messages.find((message) => message.id === triggerMessageId)?.content;
}
function inferCommitTypeFromSummary(
prompt: string | undefined,
summary: ProjectGitRunChangeSummary | undefined,
): ProjectGitConventionalCommitType {
const promptText = normalizeWhitespace(prompt?.toLowerCase() ?? '');
const files = summary?.files ?? [];
const filePaths = files.map((file) => file.path.toLowerCase());
if (filePaths.length > 0 && filePaths.every((path) => path.endsWith('.md') || path.includes('readme'))) {
return 'docs';
}
if (filePaths.length > 0 && filePaths.every((path) => path.includes('test') || path.endsWith('.snap'))) {
return 'test';
}
if (/\b(fix|bug|error|issue|regression|broken|failure)\b/.test(promptText)) {
return 'fix';
}
if (/\b(refactor|cleanup|restructure|rename|simplify)\b/.test(promptText)) {
return 'refactor';
}
if (/\b(doc|readme|documentation)\b/.test(promptText)) {
return 'docs';
}
if (/\b(test|coverage|assertion)\b/.test(promptText)) {
return 'test';
}
if (/\b(chore|config|build|deps|dependency|tooling)\b/.test(promptText)) {
return 'chore';
}
return 'feat';
}
function stripPromptLead(text: string): string {
return text
.replace(/^[`"'“”‘’]+|[`"'“”‘’]+$/g, '')
.replace(/^(please\s+)?(can|could|would)\s+you\s+/i, '')
.replace(/^(implement|add|create|build|make|update|improve|refactor|fix|support|handle)\s+/i, '')
.replace(/[.?!:;]+$/g, '')
.trim();
}
function summarizeFiles(summary: ProjectGitRunChangeSummary | undefined): string | undefined {
const firstFile = summary?.files[0];
if (!summary || summary.files.length === 0 || !firstFile) {
return undefined;
}
if (summary.files.length === 1) {
return basename(firstFile.path).replace(/\.[^.]+$/, '');
}
return `${summary.fileCount} files`;
}
function buildSubject(
prompt: string | undefined,
summary: ProjectGitRunChangeSummary | undefined,
): string {
const normalizedPrompt = normalizeWhitespace(prompt ?? '');
if (normalizedPrompt) {
const firstSentence = normalizedPrompt.split(/[\r\n.?!]/, 1)[0] ?? normalizedPrompt;
const stripped = stripPromptLead(firstSentence);
if (stripped) {
return stripped
.replace(/\b(the|a|an)\s+/gi, '')
.replace(/\s+/g, ' ')
.trim()
.toLowerCase();
}
}
const fallback = summarizeFiles(summary);
if (fallback) {
return `update ${fallback}`.toLowerCase();
}
return 'update project changes';
}
export function buildProjectGitCommitMessageSuggestion(
input: BuildCommitMessageSuggestionInput,
): ProjectGitCommitMessageSuggestion {
const prompt = findTriggerMessageContent(input.session, input.run.triggerMessageId);
const type = isCommitType(input.conventionalType)
? input.conventionalType
: inferCommitTypeFromSummary(prompt, input.summary);
const subject = buildSubject(prompt, input.summary);
return {
type,
subject,
message: `${type}: ${subject}`,
};
}
+327
View File
@@ -0,0 +1,327 @@
import { Buffer } from 'node:buffer';
import { isUtf8 } from 'node:buffer';
import type {
ProjectGitBaselineFile,
ProjectGitDiffPreview,
ProjectGitRunChangeCounts,
ProjectGitRunChangeKind,
ProjectGitRunChangeSummary,
ProjectGitRunChangedFile,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeSnapshot,
} from '@shared/domain/project';
interface DiffStats {
additions: number;
deletions: number;
}
interface BuildProjectGitRunChangeSummaryInput {
generatedAt: string;
preRunSnapshot?: ProjectGitWorkingTreeSnapshot;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
postRunSnapshot?: ProjectGitWorkingTreeSnapshot;
postRunBaselineFiles?: readonly ProjectGitBaselineFile[];
}
function parseDiffStats(diff: string | undefined): DiffStats {
if (!diff) {
return { additions: 0, deletions: 0 };
}
let additions = 0;
let deletions = 0;
for (const line of diff.split('\n')) {
if (line.startsWith('+') && !line.startsWith('+++')) {
additions += 1;
} else if (line.startsWith('-') && !line.startsWith('---')) {
deletions += 1;
}
}
return { additions, deletions };
}
function isBinaryDiff(diff: string | undefined): boolean {
if (!diff) {
return false;
}
return diff.includes('GIT binary patch') || diff.includes('Binary files ');
}
function decodeUtf8FromBase64(value: string | undefined): string | undefined {
if (value === undefined) {
return undefined;
}
const buffer = Buffer.from(value, 'base64');
return isUtf8(buffer) ? buffer.toString('utf8') : undefined;
}
function canRestoreBaseline(
baseline: ProjectGitBaselineFile | undefined,
): baseline is ProjectGitBaselineFile {
return baseline !== undefined
&& (baseline.untrackedContentBase64 !== undefined || baseline.combinedDiff !== undefined);
}
function previewFromBaselineFile(
file: Pick<ProjectGitWorkingTreeFile, 'path'>,
baseline: ProjectGitBaselineFile | undefined,
): ProjectGitDiffPreview | undefined {
if (!baseline) {
return undefined;
}
if (baseline.untrackedContentBase64 !== undefined) {
return {
path: file.path,
previousPath: baseline.previousPath,
newFileContents: decodeUtf8FromBase64(baseline.untrackedContentBase64),
...(baseline.isBinary ? { isBinary: true } : {}),
};
}
if (baseline.combinedDiff === undefined && baseline.isBinary !== true) {
return undefined;
}
return {
path: file.path,
previousPath: baseline.previousPath,
...(baseline.combinedDiff && !isBinaryDiff(baseline.combinedDiff)
? { diff: baseline.combinedDiff }
: {}),
...(baseline.isBinary || isBinaryDiff(baseline.combinedDiff) ? { isBinary: true } : {}),
};
}
function sameWorkingTreeFile(
left: ProjectGitWorkingTreeFile,
right: ProjectGitWorkingTreeFile,
): boolean {
return (
left.path === right.path
&& left.previousPath === right.previousPath
&& left.stagedStatus === right.stagedStatus
&& left.unstagedStatus === right.unstagedStatus
&& left.isConflicted === right.isConflicted
);
}
function sameBaselineFile(
left: ProjectGitBaselineFile | undefined,
right: ProjectGitBaselineFile | undefined,
): boolean {
if (!left && !right) {
return true;
}
if (!left || !right) {
return false;
}
return (
left.path === right.path
&& left.previousPath === right.previousPath
&& left.combinedDiff === right.combinedDiff
&& left.untrackedContentBase64 === right.untrackedContentBase64
&& left.isBinary === right.isBinary
);
}
function createRunChangeCounts(): ProjectGitRunChangeCounts {
return {
added: 0,
modified: 0,
deleted: 0,
renamed: 0,
copied: 0,
typeChanged: 0,
unmerged: 0,
untracked: 0,
cleaned: 0,
};
}
function incrementRunChangeCount(
counts: ProjectGitRunChangeCounts,
kind: ProjectGitRunChangeKind,
): void {
switch (kind) {
case 'added':
counts.added += 1;
break;
case 'modified':
counts.modified += 1;
break;
case 'deleted':
counts.deleted += 1;
break;
case 'renamed':
counts.renamed += 1;
break;
case 'copied':
counts.copied += 1;
break;
case 'type-changed':
counts.typeChanged += 1;
break;
case 'unmerged':
counts.unmerged += 1;
break;
case 'untracked':
counts.untracked += 1;
break;
case 'cleaned':
counts.cleaned += 1;
break;
}
}
function resolveRunChangeKind(file: ProjectGitWorkingTreeFile): ProjectGitRunChangeKind {
if (file.isConflicted) {
return 'unmerged';
}
return file.unstagedStatus ?? file.stagedStatus ?? 'modified';
}
function sortRunChangedFiles(
left: ProjectGitRunChangedFile,
right: ProjectGitRunChangedFile,
): number {
if (left.origin !== right.origin) {
return left.origin === 'run-created' ? -1 : 1;
}
return left.path.localeCompare(right.path);
}
export function buildProjectGitRunChangeSummary(
input: BuildProjectGitRunChangeSummaryInput,
): ProjectGitRunChangeSummary | undefined {
const {
generatedAt,
preRunSnapshot,
preRunBaselineFiles,
postRunSnapshot,
postRunBaselineFiles,
} = input;
if (!preRunSnapshot || !postRunSnapshot) {
return undefined;
}
const preRunFilesByPath = new Map(
preRunSnapshot.files.map((file) => [file.path, file] satisfies [string, ProjectGitWorkingTreeFile]),
);
const preRunBaselineByPath = new Map(
(preRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
const postRunBaselineByPath = new Map(
(postRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
const matchedPreRunPaths = new Set<string>();
const files: ProjectGitRunChangedFile[] = [];
for (const postRunFile of postRunSnapshot.files) {
const matchedPreRunFile = preRunFilesByPath.get(postRunFile.path)
?? (postRunFile.previousPath ? preRunFilesByPath.get(postRunFile.previousPath) : undefined);
const matchedPreRunPath = matchedPreRunFile?.path;
const postRunBaseline = postRunBaselineByPath.get(postRunFile.path);
if (!matchedPreRunFile || !matchedPreRunPath) {
const preview = previewFromBaselineFile(postRunFile, postRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: postRunFile.path,
previousPath: postRunFile.previousPath,
kind: resolveRunChangeKind(postRunFile),
origin: 'run-created',
stagedStatus: postRunFile.stagedStatus,
unstagedStatus: postRunFile.unstagedStatus,
...(postRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: true,
...(preview ? { preview } : {}),
});
continue;
}
matchedPreRunPaths.add(matchedPreRunPath);
const preRunBaseline = preRunBaselineByPath.get(matchedPreRunPath);
if (sameWorkingTreeFile(matchedPreRunFile, postRunFile) && sameBaselineFile(preRunBaseline, postRunBaseline)) {
continue;
}
const preview = previewFromBaselineFile(postRunFile, postRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: postRunFile.path,
previousPath: postRunFile.previousPath,
kind: resolveRunChangeKind(postRunFile),
origin: 'pre-existing',
stagedStatus: postRunFile.stagedStatus,
unstagedStatus: postRunFile.unstagedStatus,
...(postRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: canRestoreBaseline(preRunBaseline),
...(preview ? { preview } : {}),
});
}
for (const preRunFile of preRunSnapshot.files) {
if (matchedPreRunPaths.has(preRunFile.path)) {
continue;
}
const preRunBaseline = preRunBaselineByPath.get(preRunFile.path);
const preview = previewFromBaselineFile(preRunFile, preRunBaseline);
const stats = parseDiffStats(preview?.diff);
files.push({
path: preRunFile.path,
previousPath: preRunFile.previousPath,
kind: 'cleaned',
origin: 'pre-existing',
stagedStatus: preRunFile.stagedStatus,
unstagedStatus: preRunFile.unstagedStatus,
...(preRunFile.isConflicted ? { isConflicted: true } : {}),
additions: stats.additions,
deletions: stats.deletions,
canRevert: canRestoreBaseline(preRunBaseline),
...(preview ? { preview } : {}),
});
}
const branchChanged = preRunSnapshot.branch !== postRunSnapshot.branch;
if (files.length === 0 && !branchChanged) {
return undefined;
}
files.sort(sortRunChangedFiles);
const counts = createRunChangeCounts();
let additions = 0;
let deletions = 0;
for (const file of files) {
incrementRunChangeCount(counts, file.kind);
additions += file.additions;
deletions += file.deletions;
}
return {
generatedAt,
branchAtStart: preRunSnapshot.branch,
branchAtEnd: postRunSnapshot.branch,
...(branchChanged ? { branchChanged: true } : {}),
fileCount: files.length,
additions,
deletions,
counts,
files,
};
}
+503 -1
View File
@@ -1,16 +1,30 @@
import { isUtf8 } from 'node:buffer';
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { createRequire } from 'node:module';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { promisify } from 'node:util';
import type {
ProjectGitBaselineFile,
ProjectGitBranchSummary,
ProjectGitChangeSummary,
ProjectGitCommitLogEntry,
ProjectGitCommitSummary,
ProjectGitContext,
ProjectGitDetails,
ProjectGitDiffPreview,
ProjectGitFileReference,
ProjectGitRunChangeSummary,
ProjectGitRunChangedFile,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeFileStatus,
ProjectGitWorkingTreeSnapshot,
} from '@shared/domain/project';
import { nowIso } from '@shared/utils/ids';
import { buildProjectGitRunChangeSummary } from '@main/git/gitRunChangeSummary';
type ExecFileException = import('node:child_process').ExecFileException;
const require = createRequire(import.meta.url);
@@ -80,6 +94,11 @@ function isNotRepository(error: GitCommandFailure): boolean {
return detail.includes('not a git repository');
}
function isUnknownPathspec(error: GitCommandFailure): boolean {
const detail = `${error.message}\n${error.stderr ?? ''}`.toLowerCase();
return detail.includes('did not match any file') || detail.includes('pathspec');
}
function summarizeGitFailure(error: GitCommandFailure): string {
return error.stderr?.trim() || error.message;
}
@@ -241,6 +260,122 @@ function parseHead(stdout: string): ProjectGitCommitSummary | undefined {
};
}
function parseBranchList(stdout: string): ProjectGitBranchSummary[] {
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.flatMap((line) => {
const [name, currentMarker, upstream] = line.split('\0');
const trimmedName = name?.trim();
if (!trimmedName) {
return [];
}
return [{
name: trimmedName,
isCurrent: currentMarker?.trim() === '*',
upstream: upstream?.trim() || undefined,
}];
});
}
function parseCommitLog(stdout: string): ProjectGitCommitLogEntry[] {
return stdout
.split('\x1e')
.map((record) => record.trim())
.filter(Boolean)
.flatMap((record) => {
const [hash, shortHash, authorName, subject, committedAt, refNames] = record.split('\0');
if (!hash || !shortHash || !authorName || !subject || !committedAt) {
return [];
}
return [{
hash: hash.trim(),
shortHash: shortHash.trim(),
authorName: authorName.trim(),
subject: subject.trim(),
committedAt: committedAt.trim(),
refNames: refNames?.trim() || undefined,
}];
});
}
function isPureUntrackedFile(file: ProjectGitWorkingTreeFile): boolean {
return file.stagedStatus === undefined && file.unstagedStatus === 'untracked';
}
function buildGitPaths(file: ProjectGitFileReference): string[] {
const paths = new Set<string>();
if (file.previousPath?.trim()) {
paths.add(file.previousPath.trim());
}
if (file.path.trim()) {
paths.add(file.path.trim());
}
return [...paths];
}
function uniqueGitPaths(files: readonly ProjectGitFileReference[]): string[] {
const paths = new Set<string>();
for (const file of files) {
for (const path of buildGitPaths(file)) {
paths.add(path);
}
}
return [...paths];
}
function isBinaryDiff(diff: string | undefined): boolean {
if (!diff) {
return false;
}
return diff.includes('GIT binary patch') || diff.includes('Binary files ');
}
function canRestoreBaseline(
baseline: ProjectGitBaselineFile | undefined,
): baseline is ProjectGitBaselineFile {
return baseline !== undefined
&& (baseline.untrackedContentBase64 !== undefined || baseline.combinedDiff !== undefined);
}
function baselineToPreview(
file: Pick<ProjectGitFileReference, 'path' | 'previousPath'>,
baseline: ProjectGitBaselineFile | undefined,
): ProjectGitDiffPreview | undefined {
if (!baseline) {
return undefined;
}
if (baseline.untrackedContentBase64 !== undefined) {
const contentBuffer = Buffer.from(baseline.untrackedContentBase64, 'base64');
return {
path: file.path,
previousPath: file.previousPath,
...(isUtf8(contentBuffer) ? { newFileContents: contentBuffer.toString('utf8') } : {}),
...(baseline.isBinary ? { isBinary: true } : {}),
};
}
if (baseline.combinedDiff === undefined && baseline.isBinary !== true) {
return undefined;
}
return {
path: file.path,
previousPath: file.previousPath,
...(baseline.combinedDiff && !isBinaryDiff(baseline.combinedDiff)
? { diff: baseline.combinedDiff }
: {}),
...(baseline.isBinary || isBinaryDiff(baseline.combinedDiff) ? { isBinary: true } : {}),
};
}
export class GitService {
constructor(private readonly runGitCommand: GitCommandRunner = defaultGitCommandRunner) {}
@@ -306,6 +441,36 @@ export class GitService {
};
}
async describeProjectGitDetails(
projectPath: string,
scannedAt = nowIso(),
commitLimit = 20,
): Promise<ProjectGitDetails> {
const context = await this.describeProject(projectPath, scannedAt);
if (context.status !== 'ready') {
return {
scannedAt,
context,
branches: [],
recentCommits: [],
};
}
const [workingTree, branches, recentCommits] = await Promise.all([
this.captureWorkingTreeSnapshot(projectPath, scannedAt),
this.listBranches(projectPath),
this.listRecentCommits(projectPath, commitLimit),
]);
return {
scannedAt,
context,
workingTree: workingTree ?? undefined,
branches,
recentCommits,
};
}
async captureWorkingTreeSnapshot(
projectPath: string,
scannedAt = nowIso(),
@@ -333,11 +498,348 @@ export class GitService {
};
}
async captureWorkingTreeBaseline(
projectPath: string,
snapshot?: ProjectGitWorkingTreeSnapshot,
): Promise<ProjectGitBaselineFile[]> {
const effectiveSnapshot = snapshot ?? await this.captureWorkingTreeSnapshot(projectPath);
if (!effectiveSnapshot || effectiveSnapshot.files.length === 0) {
return [];
}
const baselineFiles = await Promise.all(
effectiveSnapshot.files.map((file) => this.captureBaselineFile(projectPath, file)),
);
return baselineFiles.flatMap((file) => (file ? [file] : []));
}
async computeRunChangeSummary(
projectPath: string,
options: {
generatedAt?: string;
preRunSnapshot?: ProjectGitWorkingTreeSnapshot;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
},
): Promise<ProjectGitRunChangeSummary | undefined> {
const generatedAt = options.generatedAt ?? nowIso();
const postRunSnapshot = await this.captureWorkingTreeSnapshot(projectPath, generatedAt);
if (!options.preRunSnapshot || !postRunSnapshot) {
return undefined;
}
const postRunBaselineFiles = await this.captureWorkingTreeBaseline(projectPath, postRunSnapshot);
return buildProjectGitRunChangeSummary({
generatedAt,
preRunSnapshot: options.preRunSnapshot,
preRunBaselineFiles: options.preRunBaselineFiles,
postRunSnapshot,
postRunBaselineFiles,
});
}
async getWorkingTreeFilePreview(
projectPath: string,
file: ProjectGitFileReference,
): Promise<ProjectGitDiffPreview | undefined> {
const snapshot = await this.captureWorkingTreeSnapshot(projectPath);
if (!snapshot) {
return undefined;
}
const matchedFile = snapshot.files.find((candidate) =>
candidate.path === file.path
|| candidate.previousPath === file.path
|| (file.previousPath !== undefined && candidate.path === file.previousPath)
|| (file.previousPath !== undefined && candidate.previousPath === file.previousPath));
if (!matchedFile) {
return undefined;
}
const baseline = await this.captureBaselineFile(projectPath, matchedFile);
return baselineToPreview(matchedFile, baseline);
}
async stageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise<void> {
const paths = uniqueGitPaths(files);
if (paths.length === 0) {
return;
}
await this.run(projectPath, ['add', '--', ...paths]);
}
async unstageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise<void> {
const paths = uniqueGitPaths(files);
if (paths.length === 0) {
return;
}
await this.run(projectPath, ['restore', '--staged', '--', ...paths]);
}
async commit(projectPath: string, message: string): Promise<ProjectGitCommitSummary> {
await this.run(projectPath, ['commit', '-m', message]);
const head = await this.getHeadCommit(projectPath);
if (!head) {
throw new Error('Git commit completed, but the new HEAD commit could not be resolved.');
}
return head;
}
async push(projectPath: string): Promise<void> {
await this.run(projectPath, ['push']);
}
async fetch(projectPath: string): Promise<void> {
await this.run(projectPath, ['fetch', '--all', '--prune']);
}
async pull(projectPath: string, rebase = false): Promise<void> {
await this.run(projectPath, rebase ? ['pull', '--rebase'] : ['pull']);
}
async createBranch(
projectPath: string,
name: string,
startPoint?: string,
checkout = true,
): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
if (checkout) {
await this.run(
projectPath,
['switch', '-c', trimmedName, ...(startPoint?.trim() ? [startPoint.trim()] : [])],
);
return;
}
await this.run(
projectPath,
['branch', trimmedName, ...(startPoint?.trim() ? [startPoint.trim()] : [])],
);
}
async switchBranch(projectPath: string, name: string): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
await this.run(projectPath, ['switch', trimmedName]);
}
async deleteBranch(projectPath: string, name: string, force = false): Promise<void> {
const trimmedName = name.trim();
if (!trimmedName) {
throw new Error('A branch name is required.');
}
await this.run(projectPath, ['branch', force ? '-D' : '-d', trimmedName]);
}
async listBranches(projectPath: string): Promise<ProjectGitBranchSummary[]> {
const result = await this.tryRun(projectPath, [
'for-each-ref',
'--format=%(refname:short)%00%(HEAD)%00%(upstream:short)',
'refs/heads',
]);
return result.ok ? parseBranchList(result.stdout) : [];
}
async listRecentCommits(projectPath: string, limit = 20): Promise<ProjectGitCommitLogEntry[]> {
const result = await this.tryRun(projectPath, [
'log',
`-n${Math.max(1, Math.round(limit))}`,
'--format=%H%x00%h%x00%an%x00%s%x00%cI%x00%D%x1e',
]);
return result.ok ? parseCommitLog(result.stdout) : [];
}
async discardRunChanges(
projectPath: string,
options: {
summary: ProjectGitRunChangeSummary;
preRunBaselineFiles?: readonly ProjectGitBaselineFile[];
files?: readonly ProjectGitFileReference[];
},
): Promise<void> {
const selectedFiles = options.files && options.files.length > 0
? options.summary.files.filter((candidate) =>
options.files?.some((selected) =>
selected.path === candidate.path
|| selected.path === candidate.previousPath
|| (selected.previousPath !== undefined && selected.previousPath === candidate.previousPath)))
: options.summary.files;
if (selectedFiles.length === 0) {
return;
}
const baselinesByPath = new Map(
(options.preRunBaselineFiles ?? []).map((file) => [file.path, file] satisfies [string, ProjectGitBaselineFile]),
);
for (const file of selectedFiles) {
if (file.origin === 'pre-existing') {
if (!file.canRevert) {
throw new Error(`Cannot restore "${file.path}" to its pre-run state because no restorable baseline was captured.`);
}
const baseline = baselinesByPath.get(file.previousPath ?? file.path) ?? baselinesByPath.get(file.path);
if (!canRestoreBaseline(baseline)) {
throw new Error(`Cannot restore "${file.path}" to its pre-run state because no restorable baseline was captured.`);
}
await this.restorePreExistingChange(projectPath, file, baseline);
continue;
}
await this.restoreRunCreatedChange(projectPath, file);
}
}
private async captureBaselineFile(
projectPath: string,
file: ProjectGitWorkingTreeFile,
): Promise<ProjectGitBaselineFile | undefined> {
if (!file.path.trim()) {
return undefined;
}
if (isPureUntrackedFile(file)) {
try {
const contents = await readFile(join(projectPath, file.path));
return {
path: file.path,
previousPath: file.previousPath,
untrackedContentBase64: contents.toString('base64'),
...(isUtf8(contents) ? {} : { isBinary: true }),
};
} catch {
return {
path: file.path,
previousPath: file.previousPath,
};
}
}
const diffPaths = buildGitPaths(file);
const diffResult = await this.tryRun(projectPath, [
'diff',
'--binary',
'--no-ext-diff',
'--no-renames',
'HEAD',
'--',
...diffPaths,
]);
if (!diffResult.ok) {
return {
path: file.path,
previousPath: file.previousPath,
};
}
return {
path: file.path,
previousPath: file.previousPath,
combinedDiff: diffResult.stdout.trim() ? diffResult.stdout : undefined,
...(isBinaryDiff(diffResult.stdout) ? { isBinary: true } : {}),
};
}
private async getHeadCommit(projectPath: string): Promise<ProjectGitCommitSummary | undefined> {
const result = await this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']);
return result.ok ? parseHead(result.stdout) : undefined;
}
private async restoreRunCreatedChange(
projectPath: string,
file: ProjectGitRunChangedFile,
): Promise<void> {
if (file.kind === 'renamed' && file.previousPath) {
await this.restorePathFromHead(projectPath, file.previousPath);
await this.removePath(projectPath, file.path);
return;
}
if (file.kind === 'added' || file.kind === 'untracked' || file.kind === 'copied') {
await this.removePath(projectPath, file.path);
return;
}
await this.restorePathFromHead(projectPath, file.path);
}
private async restorePreExistingChange(
projectPath: string,
file: ProjectGitRunChangedFile,
baseline: ProjectGitBaselineFile,
): Promise<void> {
await this.restorePathFromHead(projectPath, baseline.path);
if (file.path !== baseline.path) {
await this.removePath(projectPath, file.path);
}
if (baseline.untrackedContentBase64 !== undefined) {
const contents = Buffer.from(baseline.untrackedContentBase64, 'base64');
await mkdir(dirname(join(projectPath, baseline.path)), { recursive: true });
await writeFile(join(projectPath, baseline.path), contents);
return;
}
if (baseline.combinedDiff) {
await this.applyPatch(projectPath, baseline.combinedDiff);
}
}
private async restorePathFromHead(projectPath: string, path: string): Promise<void> {
const result = await this.tryRun(projectPath, ['restore', '--source=HEAD', '--staged', '--worktree', '--', path]);
if (!result.ok && !isUnknownPathspec(result.error)) {
throw result.error;
}
}
private async removePath(projectPath: string, path: string): Promise<void> {
const unstageResult = await this.tryRun(projectPath, ['rm', '--cached', '--force', '--ignore-unmatch', '--', path]);
if (!unstageResult.ok && !isUnknownPathspec(unstageResult.error)) {
throw unstageResult.error;
}
await rm(join(projectPath, path), { force: true });
}
private async applyPatch(projectPath: string, diff: string): Promise<void> {
const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-patch-'));
const patchPath = join(tempDirectory, 'restore.diff');
try {
await writeFile(patchPath, diff, 'utf8');
await this.run(projectPath, ['apply', '--whitespace=nowarn', '--recount', patchPath]);
} finally {
await rm(tempDirectory, { force: true, recursive: true });
}
}
private async run(projectPath: string, args: string[]): Promise<string> {
try {
return await this.runGitCommand(projectPath, args);
} catch (error) {
throw createGitCommandFailure(projectPath, args, error);
}
}
private async tryRun(projectPath: string, args: string[]): Promise<GitCommandResult> {
try {
return {
ok: true,
stdout: await this.runGitCommand(projectPath, args),
stdout: await this.run(projectPath, args),
};
} catch (error) {
return {
+64
View File
@@ -6,12 +6,23 @@ import type {
BranchSessionInput,
CancelSessionTurnInput,
CreateSessionInput,
CreateProjectGitBranchInput,
DismissSessionMcpAuthInput,
DismissSessionPlanReviewInput,
DeleteProjectGitBranchInput,
DeleteSessionInput,
DiscardSessionRunGitChangesInput,
EditAndResendSessionMessageInput,
CommitProjectGitChangesInput,
ProjectGitDetailsInput,
ProjectGitFilePreviewInput,
ProjectGitFileSelectionInput,
ProjectGitInput,
PullProjectGitInput,
RegenerateSessionMessageInput,
StartSessionMcpAuthInput,
SuggestProjectGitCommitMessageInput,
SwitchProjectGitBranchInput,
DuplicateSessionInput,
RenameSessionInput,
RescanProjectConfigsInput,
@@ -71,6 +82,12 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.refreshProjectGitContext, (_event, projectId?: string) =>
service.refreshProjectGitContext(projectId),
);
ipcMain.handle(ipcChannels.getProjectGitDetails, (_event, input: ProjectGitDetailsInput) =>
service.getProjectGitDetails(input.projectId, input.commitLimit),
);
ipcMain.handle(ipcChannels.getProjectGitFilePreview, (_event, input: ProjectGitFilePreviewInput) =>
service.getProjectGitFilePreview(input.projectId, input.file),
);
ipcMain.handle(ipcChannels.rescanProjectConfigs, (_event, input: RescanProjectConfigsInput) =>
service.rescanProjectConfigs(input.projectId),
);
@@ -207,11 +224,58 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.startSessionMcpAuth, (_event, input: StartSessionMcpAuthInput) =>
service.startSessionMcpAuth(input.sessionId),
);
ipcMain.handle(
ipcChannels.discardSessionRunGitChanges,
(_event, input: DiscardSessionRunGitChangesInput) =>
service.discardSessionRunGitChanges(input.sessionId, input.runId, input.files),
);
ipcMain.handle(
ipcChannels.suggestProjectGitCommitMessage,
(_event, input: SuggestProjectGitCommitMessageInput) =>
service.suggestProjectGitCommitMessage(input.sessionId, input.runId, input.conventionalType),
);
ipcMain.handle(
ipcChannels.updateSessionModelConfig,
(_event, input: UpdateSessionModelConfigInput) =>
service.updateSessionModelConfig(input.sessionId, input.model, input.reasoningEffort),
);
ipcMain.handle(
ipcChannels.stageProjectGitFiles,
(_event, input: ProjectGitFileSelectionInput) => service.stageProjectGitFiles(input.projectId, input.files),
);
ipcMain.handle(
ipcChannels.unstageProjectGitFiles,
(_event, input: ProjectGitFileSelectionInput) => service.unstageProjectGitFiles(input.projectId, input.files),
);
ipcMain.handle(
ipcChannels.commitProjectGitChanges,
(_event, input: CommitProjectGitChangesInput) =>
service.commitProjectGitChanges(input.projectId, input.message, input.files, input.push),
);
ipcMain.handle(ipcChannels.pushProjectGit, (_event, input: ProjectGitInput) =>
service.pushProjectGit(input.projectId),
);
ipcMain.handle(ipcChannels.fetchProjectGit, (_event, input: ProjectGitInput) =>
service.fetchProjectGit(input.projectId),
);
ipcMain.handle(ipcChannels.pullProjectGit, (_event, input: PullProjectGitInput) =>
service.pullProjectGit(input.projectId, input.rebase),
);
ipcMain.handle(
ipcChannels.createProjectGitBranch,
(_event, input: CreateProjectGitBranchInput) =>
service.createProjectGitBranch(input.projectId, input.name, input.startPoint, input.checkout),
);
ipcMain.handle(
ipcChannels.switchProjectGitBranch,
(_event, input: SwitchProjectGitBranchInput) =>
service.switchProjectGitBranch(input.projectId, input.name),
);
ipcMain.handle(
ipcChannels.deleteProjectGitBranch,
(_event, input: DeleteProjectGitBranchInput) =>
service.deleteProjectGitBranch(input.projectId, input.name, input.force),
);
ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input));
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
+13
View File
@@ -14,6 +14,8 @@ const api: ElectronApi = {
resolveWorkspaceDiscoveredTooling: (input) =>
ipcRenderer.invoke(ipcChannels.resolveWorkspaceDiscoveredTooling, input),
refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId),
getProjectGitDetails: (input) => ipcRenderer.invoke(ipcChannels.getProjectGitDetails, input),
getProjectGitFilePreview: (input) => ipcRenderer.invoke(ipcChannels.getProjectGitFilePreview, input),
rescanProjectConfigs: (input) => ipcRenderer.invoke(ipcChannels.rescanProjectConfigs, input),
rescanProjectCustomization: (input) =>
ipcRenderer.invoke(ipcChannels.rescanProjectCustomization, input),
@@ -66,9 +68,20 @@ const api: ElectronApi = {
dismissSessionPlanReview: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionPlanReview, input),
dismissSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionMcpAuth, input),
startSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.startSessionMcpAuth, input),
discardSessionRunGitChanges: (input) => ipcRenderer.invoke(ipcChannels.discardSessionRunGitChanges, input),
suggestProjectGitCommitMessage: (input) => ipcRenderer.invoke(ipcChannels.suggestProjectGitCommitMessage, input),
updateSessionModelConfig: (input) =>
ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input),
querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input),
stageProjectGitFiles: (input) => ipcRenderer.invoke(ipcChannels.stageProjectGitFiles, input),
unstageProjectGitFiles: (input) => ipcRenderer.invoke(ipcChannels.unstageProjectGitFiles, input),
commitProjectGitChanges: (input) => ipcRenderer.invoke(ipcChannels.commitProjectGitChanges, input),
pushProjectGit: (input) => ipcRenderer.invoke(ipcChannels.pushProjectGit, input),
fetchProjectGit: (input) => ipcRenderer.invoke(ipcChannels.fetchProjectGit, input),
pullProjectGit: (input) => ipcRenderer.invoke(ipcChannels.pullProjectGit, input),
createProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.createProjectGitBranch, input),
switchProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.switchProjectGitBranch, input),
deleteProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.deleteProjectGitBranch, input),
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
+13
View File
@@ -6,6 +6,8 @@ export const ipcChannels = {
removeProject: 'workspace:remove-project',
resolveWorkspaceDiscoveredTooling: 'workspace:resolve-discovered-tooling',
refreshProjectGitContext: 'projects:refresh-git-context',
getProjectGitDetails: 'projects:get-git-details',
getProjectGitFilePreview: 'projects:get-git-file-preview',
rescanProjectConfigs: 'project:rescan-configs',
rescanProjectCustomization: 'project:rescan-customization',
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
@@ -50,8 +52,19 @@ export const ipcChannels = {
dismissSessionPlanReview: 'sessions:dismiss-plan-review',
dismissSessionMcpAuth: 'sessions:dismiss-mcp-auth',
startSessionMcpAuth: 'sessions:start-mcp-auth',
discardSessionRunGitChanges: 'sessions:discard-run-git-changes',
suggestProjectGitCommitMessage: 'sessions:suggest-git-commit-message',
querySessions: 'sessions:query',
updateSessionModelConfig: 'sessions:update-model-config',
stageProjectGitFiles: 'git:stage-files',
unstageProjectGitFiles: 'git:unstage-files',
commitProjectGitChanges: 'git:commit',
pushProjectGit: 'git:push',
fetchProjectGit: 'git:fetch',
pullProjectGit: 'git:pull',
createProjectGitBranch: 'git:create-branch',
switchProjectGitBranch: 'git:switch-branch',
deleteProjectGitBranch: 'git:delete-branch',
selectProject: 'selection:project',
selectPattern: 'selection:pattern',
selectSession: 'selection:session',
+74 -1
View File
@@ -1,7 +1,14 @@
import type { ApprovalDecision } from '@shared/domain/approval';
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type {
ProjectGitBranchSummary,
ProjectGitCommitMessageSuggestion,
ProjectGitDetails,
ProjectGitDiffPreview,
ProjectGitFileReference,
ProjectRecord,
} from '@shared/domain/project';
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
import type { SessionEventRecord } from '@shared/domain/event';
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
@@ -175,6 +182,59 @@ export interface SetTerminalHeightInput {
height?: number;
}
export interface ProjectGitInput {
projectId: string;
}
export interface ProjectGitDetailsInput extends ProjectGitInput {
commitLimit?: number;
}
export interface ProjectGitFilePreviewInput extends ProjectGitInput {
file: ProjectGitFileReference;
}
export interface ProjectGitFileSelectionInput extends ProjectGitInput {
files: ProjectGitFileReference[];
}
export interface DiscardSessionRunGitChangesInput {
sessionId: string;
runId: string;
files?: ProjectGitFileReference[];
}
export interface SuggestProjectGitCommitMessageInput {
sessionId: string;
runId?: string;
conventionalType?: ProjectGitCommitMessageSuggestion['type'];
}
export interface CommitProjectGitChangesInput extends ProjectGitInput {
message: string;
files?: ProjectGitFileReference[];
push?: boolean;
}
export interface PullProjectGitInput extends ProjectGitInput {
rebase?: boolean;
}
export interface CreateProjectGitBranchInput extends ProjectGitInput {
name: string;
startPoint?: string;
checkout?: boolean;
}
export interface SwitchProjectGitBranchInput extends ProjectGitInput {
name: string;
}
export interface DeleteProjectGitBranchInput extends ProjectGitInput {
name: string;
force?: boolean;
}
export type UpdateStatusState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'downloaded' | 'error';
export interface UpdateDownloadProgress {
@@ -253,6 +313,19 @@ export interface ElectronApi {
openAppDataFolder(): Promise<void>;
resetLocalWorkspace(): Promise<WorkspaceState>;
getQuota(): Promise<Record<string, QuotaSnapshot>>;
getProjectGitDetails(input: ProjectGitDetailsInput): Promise<ProjectGitDetails>;
getProjectGitFilePreview(input: ProjectGitFilePreviewInput): Promise<ProjectGitDiffPreview | undefined>;
discardSessionRunGitChanges(input: DiscardSessionRunGitChangesInput): Promise<WorkspaceState>;
stageProjectGitFiles(input: ProjectGitFileSelectionInput): Promise<WorkspaceState>;
unstageProjectGitFiles(input: ProjectGitFileSelectionInput): Promise<WorkspaceState>;
suggestProjectGitCommitMessage(input: SuggestProjectGitCommitMessageInput): Promise<ProjectGitCommitMessageSuggestion>;
commitProjectGitChanges(input: CommitProjectGitChangesInput): Promise<WorkspaceState>;
pushProjectGit(input: ProjectGitInput): Promise<WorkspaceState>;
fetchProjectGit(input: ProjectGitInput): Promise<WorkspaceState>;
pullProjectGit(input: PullProjectGitInput): Promise<WorkspaceState>;
createProjectGitBranch(input: CreateProjectGitBranchInput): Promise<WorkspaceState>;
switchProjectGitBranch(input: SwitchProjectGitBranchInput): Promise<WorkspaceState>;
deleteProjectGitBranch(input: DeleteProjectGitBranchInput): Promise<WorkspaceState>;
onTerminalData(listener: (data: string) => void): () => void;
onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void;
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
+89
View File
@@ -18,6 +18,11 @@ export interface ProjectGitCommitSummary {
committedAt: string;
}
export interface ProjectGitCommitLogEntry extends ProjectGitCommitSummary {
authorName: string;
refNames?: string;
}
export type ProjectGitWorkingTreeFileStatus =
| 'added'
| 'modified'
@@ -36,6 +41,23 @@ export interface ProjectGitWorkingTreeFile {
isConflicted?: boolean;
}
export interface ProjectGitFileReference {
path: string;
previousPath?: string;
}
export interface ProjectGitDiffPreview extends ProjectGitFileReference {
diff?: string;
newFileContents?: string;
isBinary?: boolean;
}
export interface ProjectGitBaselineFile extends ProjectGitFileReference {
combinedDiff?: string;
untrackedContentBase64?: string;
isBinary?: boolean;
}
export interface ProjectGitWorkingTreeSnapshot {
scannedAt: string;
repoRoot: string;
@@ -45,6 +67,73 @@ export interface ProjectGitWorkingTreeSnapshot {
files: ProjectGitWorkingTreeFile[];
}
export type ProjectGitRunChangeOrigin = 'run-created' | 'pre-existing';
export type ProjectGitRunChangeKind = ProjectGitWorkingTreeFileStatus | 'cleaned';
export interface ProjectGitRunChangeCounts {
added: number;
modified: number;
deleted: number;
renamed: number;
copied: number;
typeChanged: number;
unmerged: number;
untracked: number;
cleaned: number;
}
export interface ProjectGitRunChangedFile extends ProjectGitFileReference {
kind: ProjectGitRunChangeKind;
origin: ProjectGitRunChangeOrigin;
stagedStatus?: ProjectGitWorkingTreeFileStatus;
unstagedStatus?: ProjectGitWorkingTreeFileStatus;
isConflicted?: boolean;
additions: number;
deletions: number;
canRevert: boolean;
preview?: ProjectGitDiffPreview;
}
export interface ProjectGitRunChangeSummary {
generatedAt: string;
branchAtStart?: string;
branchAtEnd?: string;
branchChanged?: boolean;
fileCount: number;
additions: number;
deletions: number;
counts: ProjectGitRunChangeCounts;
files: ProjectGitRunChangedFile[];
}
export interface ProjectGitBranchSummary {
name: string;
isCurrent: boolean;
upstream?: string;
}
export interface ProjectGitDetails {
scannedAt: string;
context: ProjectGitContext;
workingTree?: ProjectGitWorkingTreeSnapshot;
branches: ProjectGitBranchSummary[];
recentCommits: ProjectGitCommitLogEntry[];
}
export type ProjectGitConventionalCommitType =
| 'feat'
| 'fix'
| 'refactor'
| 'docs'
| 'test'
| 'chore';
export interface ProjectGitCommitMessageSuggestion {
type: ProjectGitConventionalCommitType;
subject: string;
message: string;
}
export interface ProjectGitContext {
status: ProjectGitContextStatus;
scannedAt: string;
+179
View File
@@ -6,7 +6,12 @@ import type {
import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type {
ProjectGitBaselineFile,
ProjectGitChangeSummary,
ProjectGitDiffPreview,
ProjectGitRunChangeCounts,
ProjectGitRunChangedFile,
ProjectGitRunChangeSummary,
ProjectGitWorkingTreeFile,
ProjectGitWorkingTreeFileStatus,
ProjectGitWorkingTreeSnapshot,
@@ -66,6 +71,7 @@ export interface SessionRunRecord {
requestId: string;
projectId: string;
projectPath: string;
workingDirectory?: string;
workspaceKind: SessionRunWorkspaceKind;
patternId: string;
patternName: string;
@@ -77,16 +83,20 @@ export interface SessionRunRecord {
agents: RunTimelineAgentRecord[];
events: RunTimelineEventRecord[];
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
preRunGitBaselineFiles?: ProjectGitBaselineFile[];
postRunGitSummary?: ProjectGitRunChangeSummary;
}
export interface CreateSessionRunRecordInput {
requestId: string;
project: Pick<ProjectRecord, 'id' | 'path'>;
workingDirectory?: string;
workspaceKind: SessionRunWorkspaceKind;
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
triggerMessageId: string;
startedAt: string;
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
preRunGitBaselineFiles?: ProjectGitBaselineFile[];
}
export interface AppendRunActivityEventInput {
@@ -213,6 +223,145 @@ function normalizeWorkingTreeSnapshot(
};
}
function normalizeGitDiffPreview(
preview: ProjectGitDiffPreview,
): ProjectGitDiffPreview | undefined {
const path = normalizeOptionalString(preview.path);
if (!path) {
return undefined;
}
return {
path,
previousPath: normalizeOptionalString(preview.previousPath),
diff: normalizeOptionalPreviewText(preview.diff),
newFileContents: normalizeOptionalPreviewText(preview.newFileContents),
...(preview.isBinary ? { isBinary: true } : {}),
};
}
function normalizeGitBaselineFile(
file: ProjectGitBaselineFile,
): ProjectGitBaselineFile | undefined {
const path = normalizeOptionalString(file.path);
if (!path) {
return undefined;
}
return {
path,
previousPath: normalizeOptionalString(file.previousPath),
combinedDiff: normalizeOptionalPreviewText(file.combinedDiff),
untrackedContentBase64: normalizeOptionalString(file.untrackedContentBase64),
...(file.isBinary ? { isBinary: true } : {}),
};
}
function normalizeGitBaselineFiles(
files: readonly ProjectGitBaselineFile[] | undefined,
): ProjectGitBaselineFile[] | undefined {
if (!files || files.length === 0) {
return undefined;
}
const normalized = files.flatMap((file) => {
const nextFile = normalizeGitBaselineFile(file);
return nextFile ? [nextFile] : [];
});
return normalized.length > 0 ? normalized : undefined;
}
function normalizeGitRunChangeKind(
value: ProjectGitRunChangedFile['kind'] | undefined,
): ProjectGitRunChangedFile['kind'] | undefined {
switch (value) {
case 'cleaned':
return value;
case 'added':
case 'modified':
case 'deleted':
case 'renamed':
case 'copied':
case 'type-changed':
case 'unmerged':
case 'untracked':
return value;
default:
return undefined;
}
}
function normalizeGitRunChangeCounts(
counts: Partial<ProjectGitRunChangeCounts> | undefined,
): ProjectGitRunChangeCounts {
return {
added: normalizeNonNegativeInteger(counts?.added),
modified: normalizeNonNegativeInteger(counts?.modified),
deleted: normalizeNonNegativeInteger(counts?.deleted),
renamed: normalizeNonNegativeInteger(counts?.renamed),
copied: normalizeNonNegativeInteger(counts?.copied),
typeChanged: normalizeNonNegativeInteger(counts?.typeChanged),
unmerged: normalizeNonNegativeInteger(counts?.unmerged),
untracked: normalizeNonNegativeInteger(counts?.untracked),
cleaned: normalizeNonNegativeInteger(counts?.cleaned),
};
}
function normalizeGitRunChangedFile(
file: ProjectGitRunChangedFile,
): ProjectGitRunChangedFile | undefined {
const path = normalizeOptionalString(file.path);
const kind = normalizeGitRunChangeKind(file.kind);
if (!path || !kind) {
return undefined;
}
return {
path,
previousPath: normalizeOptionalString(file.previousPath),
kind,
origin: file.origin === 'pre-existing' ? 'pre-existing' : 'run-created',
stagedStatus: normalizeWorkingTreeFileStatus(file.stagedStatus),
unstagedStatus: normalizeWorkingTreeFileStatus(file.unstagedStatus),
...(file.isConflicted ? { isConflicted: true } : {}),
additions: normalizeNonNegativeInteger(file.additions),
deletions: normalizeNonNegativeInteger(file.deletions),
canRevert: file.canRevert === true,
preview: file.preview ? normalizeGitDiffPreview(file.preview) : undefined,
};
}
function normalizeGitRunChangeSummary(
summary: ProjectGitRunChangeSummary | undefined,
): ProjectGitRunChangeSummary | undefined {
if (!summary) {
return undefined;
}
const generatedAt = normalizeOptionalString(summary.generatedAt);
if (!generatedAt) {
return undefined;
}
const files = (summary.files ?? []).flatMap((file) => {
const normalized = normalizeGitRunChangedFile(file);
return normalized ? [normalized] : [];
});
return {
generatedAt,
branchAtStart: normalizeOptionalString(summary.branchAtStart),
branchAtEnd: normalizeOptionalString(summary.branchAtEnd),
...(summary.branchChanged ? { branchChanged: true } : {}),
fileCount: normalizeNonNegativeInteger(summary.fileCount),
additions: normalizeNonNegativeInteger(summary.additions),
deletions: normalizeNonNegativeInteger(summary.deletions),
counts: normalizeGitRunChangeCounts(summary.counts),
files,
};
}
function normalizeToolCallFileChange(
change: ToolCallFileChangePreview,
): ToolCallFileChangePreview | undefined {
@@ -456,6 +605,7 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
requestId: input.requestId,
projectId: input.project.id,
projectPath: input.project.path,
workingDirectory: normalizeOptionalString(input.workingDirectory),
workspaceKind: input.workspaceKind,
patternId: input.pattern.id,
patternName: input.pattern.name,
@@ -465,6 +615,8 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
status: 'running',
completedAt: undefined,
preRunGitSnapshot: normalizeWorkingTreeSnapshot(input.preRunGitSnapshot),
preRunGitBaselineFiles: normalizeGitBaselineFiles(input.preRunGitBaselineFiles),
postRunGitSummary: undefined,
agents: input.pattern.agents
.map((agent): RunTimelineAgentRecord => ({
agentId: agent.id,
@@ -500,6 +652,7 @@ export function normalizeSessionRunRecords(
const requestId = normalizeOptionalString(run.requestId);
const projectId = normalizeOptionalString(run.projectId);
const projectPath = normalizeOptionalString(run.projectPath);
const workingDirectory = normalizeOptionalString(run.workingDirectory);
const patternId = normalizeOptionalString(run.patternId);
const patternName = normalizeOptionalString(run.patternName);
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
@@ -514,6 +667,7 @@ export function normalizeSessionRunRecords(
requestId,
projectId,
projectPath,
workingDirectory,
workspaceKind: run.workspaceKind === 'scratchpad' ? 'scratchpad' : 'project',
patternId,
patternName,
@@ -523,6 +677,8 @@ export function normalizeSessionRunRecords(
completedAt: normalizeOptionalString(run.completedAt),
status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : run.status === 'cancelled' ? 'cancelled' : 'completed',
preRunGitSnapshot: normalizeWorkingTreeSnapshot(run.preRunGitSnapshot),
preRunGitBaselineFiles: normalizeGitBaselineFiles(run.preRunGitBaselineFiles),
postRunGitSummary: normalizeGitRunChangeSummary(run.postRunGitSummary),
agents: run.agents.flatMap((agent) => {
const normalized = normalizeRunTimelineAgent(agent);
return normalized ? [normalized] : [];
@@ -770,3 +926,26 @@ export function failSessionRunRecord(
error,
});
}
export function setSessionRunGitSummary(
run: SessionRunRecord,
summary: ProjectGitRunChangeSummary | undefined,
): SessionRunRecord {
const normalizedSummary = normalizeGitRunChangeSummary(summary);
if (normalizedSummary === undefined && run.postRunGitSummary === undefined) {
return run;
}
if (
normalizedSummary !== undefined
&& run.postRunGitSummary !== undefined
&& JSON.stringify(normalizedSummary) === JSON.stringify(run.postRunGitSummary)
) {
return run;
}
return {
...run,
postRunGitSummary: normalizedSummary,
};
}