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