diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 7c6ce82..fbedd7f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,7 +55,7 @@ flowchart LR | --- | --- | --- | --- | | Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events | | Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` | -| Main process | Workspace mutation, persistence, git inspection and refresh orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes | +| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes | | Sidecar | Capability discovery, pattern validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio | | External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar | @@ -97,7 +97,7 @@ sequenceDiagram M-->>R: Push session events and workspace updates C-->>S: Final messages or turn boundary S-->>M: Completion or error - M->>M: Finalize run, refresh project git state, and persist state + M->>M: Finalize run, compute post-run git summary, refresh project git state, and persist state M-->>R: Final workspace snapshot ``` @@ -126,7 +126,7 @@ The scratchpad is modeled inside the same workspace system instead of as a separ Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `.github/agents/*.agent.md`, and `.github/prompts/*.prompt.md`. The main process owns that scan step and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler. -For git-backed projects, the main process also owns background git refreshes and captures a structured pre-run working-tree snapshot on each run record. That keeps git CLI access inside the privileged process while giving later renderer features a typed baseline for attributing post-run file changes to a specific turn. +For git-backed projects, the main process also owns background git refreshes, captures a structured pre-run working-tree snapshot on each run record, and persists a post-run git change summary after project-backed turns complete. It also owns all git write operations exposed by Aryx — selective discard, staging, commit, push/pull/fetch, and branch lifecycle actions — so the renderer never shells out directly or manipulates repository state on its own. ### Patterns @@ -174,6 +174,7 @@ Each user turn becomes a **run**. A run is more than the final assistant output; - which activity happened during the turn - partial streaming output - success or failure +- optional git baselines and post-run change summaries for project-backed execution That run model is what enables the activity panel and historical timeline instead of forcing the user to infer execution from message text alone. diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index d2c1b9a..1f97d85 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -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 { ): Promise { 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 { createSessionRunRecord({ requestId, project, + workingDirectory: runWorkingDirectory, workspaceKind, pattern: effectivePattern, triggerMessageId, startedAt: occurredAt, preRunGitSnapshot, + preRunGitBaselineFiles, }), ...session.runs, ]; @@ -1397,7 +1412,7 @@ export class AryxAppService extends EventEmitter { 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 { 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 { } 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 { 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 { return this.refreshProjectGitContexts(projectId ? [projectId] : undefined); } + async getProjectGitDetails(projectId: string, commitLimit = 20): Promise { + 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 { + 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 { + 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 { + return this.runProjectGitMutation(projectId, async (project) => { + await this.gitService.stageFiles(project.path, files); + }); + } + + async unstageProjectGitFiles(projectId: string, files: ProjectGitFileReference[]): Promise { + return this.runProjectGitMutation(projectId, async (project) => { + await this.gitService.unstageFiles(project.path, files); + }); + } + + async suggestProjectGitCommitMessage( + sessionId: string, + runId?: string, + conventionalType?: ProjectGitCommitMessageSuggestion['type'], + ): Promise { + 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 { + 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 { + return this.runProjectGitMutation(projectId, async (project) => { + await this.gitService.push(project.path); + }); + } + + async fetchProjectGit(projectId: string): Promise { + return this.runProjectGitMutation(projectId, async (project) => { + await this.gitService.fetch(project.path); + }); + } + + async pullProjectGit(projectId: string, rebase = false): Promise { + return this.runProjectGitMutation(projectId, async (project) => { + await this.gitService.pull(project.path, rebase); + }); + } + + async createProjectGitBranch( + projectId: string, + name: string, + startPoint?: string, + checkout = true, + ): Promise { + return this.runProjectGitMutation(projectId, async (project) => { + await this.gitService.createBranch(project.path, name, startPoint, checkout); + }); + } + + async switchProjectGitBranch(projectId: string, name: string): Promise { + return this.runProjectGitMutation(projectId, async (project) => { + await this.gitService.switchBranch(project.path, name); + }); + } + + async deleteProjectGitBranch(projectId: string, name: string, force = false): Promise { + return this.runProjectGitMutation(projectId, async (project) => { + await this.gitService.deleteBranch(project.path, name, force); + }); + } + private async refreshProjectGitContexts(projectIds?: readonly string[]): Promise { const workspace = await this.loadWorkspace(); const projects = projectIds?.length @@ -1645,6 +1830,61 @@ export class AryxAppService extends EventEmitter { 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 { + 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, + ): Promise { + 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) diff --git a/src/main/git/gitCommitMessageSuggestion.ts b/src/main/git/gitCommitMessageSuggestion.ts new file mode 100644 index 0000000..bd0d303 --- /dev/null +++ b/src/main/git/gitCommitMessageSuggestion.ts @@ -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; + run: Pick; + 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, + 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}`, + }; +} diff --git a/src/main/git/gitRunChangeSummary.ts b/src/main/git/gitRunChangeSummary.ts new file mode 100644 index 0000000..aa113cf --- /dev/null +++ b/src/main/git/gitRunChangeSummary.ts @@ -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, + 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(); + 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, + }; +} diff --git a/src/main/git/gitService.ts b/src/main/git/gitService.ts index 8cf6664..c8e5450 100644 --- a/src/main/git/gitService.ts +++ b/src/main/git/gitService.ts @@ -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(); + 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(); + 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, + 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 { + 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 { + 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 { + 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 { + 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 { + const paths = uniqueGitPaths(files); + if (paths.length === 0) { + return; + } + + await this.run(projectPath, ['add', '--', ...paths]); + } + + async unstageFiles(projectPath: string, files: readonly ProjectGitFileReference[]): Promise { + const paths = uniqueGitPaths(files); + if (paths.length === 0) { + return; + } + + await this.run(projectPath, ['restore', '--staged', '--', ...paths]); + } + + async commit(projectPath: string, message: string): Promise { + 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 { + await this.run(projectPath, ['push']); + } + + async fetch(projectPath: string): Promise { + await this.run(projectPath, ['fetch', '--all', '--prune']); + } + + async pull(projectPath: string, rebase = false): Promise { + await this.run(projectPath, rebase ? ['pull', '--rebase'] : ['pull']); + } + + async createBranch( + projectPath: string, + name: string, + startPoint?: string, + checkout = true, + ): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + try { + return await this.runGitCommand(projectPath, args); + } catch (error) { + throw createGitCommandFailure(projectPath, args, error); + } + } + private async tryRun(projectPath: string, args: string[]): Promise { try { return { ok: true, - stdout: await this.runGitCommand(projectPath, args), + stdout: await this.run(projectPath, args), }; } catch (error) { return { diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index f35a2c3..9e67e79 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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)); diff --git a/src/preload/index.ts b/src/preload/index.ts index fc5416d..23b527f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 8177ce5..3c95e08 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -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', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index faf884d..7056258 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -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; resetLocalWorkspace(): Promise; getQuota(): Promise>; + getProjectGitDetails(input: ProjectGitDetailsInput): Promise; + getProjectGitFilePreview(input: ProjectGitFilePreviewInput): Promise; + discardSessionRunGitChanges(input: DiscardSessionRunGitChangesInput): Promise; + stageProjectGitFiles(input: ProjectGitFileSelectionInput): Promise; + unstageProjectGitFiles(input: ProjectGitFileSelectionInput): Promise; + suggestProjectGitCommitMessage(input: SuggestProjectGitCommitMessageInput): Promise; + commitProjectGitChanges(input: CommitProjectGitChangesInput): Promise; + pushProjectGit(input: ProjectGitInput): Promise; + fetchProjectGit(input: ProjectGitInput): Promise; + pullProjectGit(input: PullProjectGitInput): Promise; + createProjectGitBranch(input: CreateProjectGitBranchInput): Promise; + switchProjectGitBranch(input: SwitchProjectGitBranchInput): Promise; + deleteProjectGitBranch(input: DeleteProjectGitBranchInput): Promise; onTerminalData(listener: (data: string) => void): () => void; onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void; onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void; diff --git a/src/shared/domain/project.ts b/src/shared/domain/project.ts index 38d7dcb..edb77f3 100644 --- a/src/shared/domain/project.ts +++ b/src/shared/domain/project.ts @@ -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; diff --git a/src/shared/domain/runTimeline.ts b/src/shared/domain/runTimeline.ts index 7cf1f6a..406fa89 100644 --- a/src/shared/domain/runTimeline.ts +++ b/src/shared/domain/runTimeline.ts @@ -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; + workingDirectory?: string; workspaceKind: SessionRunWorkspaceKind; pattern: Pick; 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 | 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, + }; +} diff --git a/tests/main/appServiceGitRefresh.test.ts b/tests/main/appServiceGitRefresh.test.ts index 46e465a..2c61b18 100644 --- a/tests/main/appServiceGitRefresh.test.ts +++ b/tests/main/appServiceGitRefresh.test.ts @@ -2,7 +2,11 @@ import { describe, expect, mock, test } from 'bun:test'; import type { RunTurnCommand } from '@shared/contracts/sidecar'; import type { PatternDefinition } from '@shared/domain/pattern'; -import type { ProjectGitWorkingTreeSnapshot, ProjectRecord } from '@shared/domain/project'; +import type { + ProjectGitRunChangeSummary, + ProjectGitWorkingTreeSnapshot, + ProjectRecord, +} from '@shared/domain/project'; import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project'; import type { SessionRecord } from '@shared/domain/session'; import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; @@ -131,12 +135,50 @@ function createSnapshot(): ProjectGitWorkingTreeSnapshot { }; } +function createRunSummary(): ProjectGitRunChangeSummary { + return { + generatedAt: TIMESTAMP, + branchAtStart: 'main', + branchAtEnd: 'main', + fileCount: 1, + additions: 4, + deletions: 1, + counts: { + added: 1, + modified: 0, + deleted: 0, + renamed: 0, + copied: 0, + typeChanged: 0, + unmerged: 0, + untracked: 0, + cleaned: 0, + }, + files: [ + { + path: 'src\\generated.ts', + kind: 'added', + origin: 'run-created', + additions: 4, + deletions: 1, + canRevert: true, + preview: { + path: 'src\\generated.ts', + diff: '@@ -0,0 +1,4 @@\n+export const generated = true;\n', + }, + }, + ], + }; +} + function createService( workspace: WorkspaceState, pattern: PatternDefinition, options?: { snapshot?: ProjectGitWorkingTreeSnapshot; + runSummary?: ProjectGitRunChangeSummary; onCaptureSnapshot?: (projectPath: string, scannedAt: string) => void; + onComputeRunSummary?: (projectPath: string) => void; onScheduleRefresh?: (projectId?: string) => void; runTurn?: (command: RunTurnCommand) => Promise<[]>; }, @@ -170,6 +212,8 @@ function createService( projectPath: string, scannedAt: string, ) => Promise; + captureWorkingTreeBaseline: () => Promise<[]>; + computeRunChangeSummary: (projectPath: string) => Promise; }; } ).sidecar = { @@ -184,6 +228,8 @@ function createService( projectPath: string, scannedAt: string, ) => Promise; + captureWorkingTreeBaseline: () => Promise<[]>; + computeRunChangeSummary: (projectPath: string) => Promise; }; } ).gitService = { @@ -191,6 +237,11 @@ function createService( options?.onCaptureSnapshot?.(projectPath, scannedAt); return options?.snapshot; }, + captureWorkingTreeBaseline: async () => [], + computeRunChangeSummary: async (projectPath) => { + options?.onComputeRunSummary?.(projectPath); + return options?.runSummary; + }, }; return service; @@ -229,6 +280,23 @@ describe('AryxAppService git refresh integration', () => { expect(scheduledProjectIds).toEqual([project.id]); }); + test('sendSessionMessage stores a post-run git summary on the completed run', async () => { + const { workspace, pattern, session, project } = createFixture(); + const computedProjectPaths: string[] = []; + const service = createService(workspace, pattern, { + snapshot: createSnapshot(), + runSummary: createRunSummary(), + onComputeRunSummary: (projectPath) => { + computedProjectPaths.push(projectPath); + }, + }); + + await service.sendSessionMessage(session.id, 'Implement auth hardening.'); + + expect(computedProjectPaths).toEqual([project.path]); + expect(workspace.sessions[0]?.runs[0]?.postRunGitSummary).toEqual(createRunSummary()); + }); + test('sendSessionMessage schedules a git refresh after a failed project turn', async () => { const { workspace, pattern, project, session } = createFixture(); const scheduledProjectIds: Array = []; diff --git a/tests/main/gitCommitMessageSuggestion.test.ts b/tests/main/gitCommitMessageSuggestion.test.ts new file mode 100644 index 0000000..38f9096 --- /dev/null +++ b/tests/main/gitCommitMessageSuggestion.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, test } from 'bun:test'; + +import type { ProjectGitRunChangeSummary } from '@shared/domain/project'; + +import { buildProjectGitCommitMessageSuggestion } from '@main/git/gitCommitMessageSuggestion'; + +const baseSummary: ProjectGitRunChangeSummary = { + generatedAt: '2026-03-31T00:00:00.000Z', + branchAtStart: 'main', + branchAtEnd: 'main', + fileCount: 1, + additions: 5, + deletions: 2, + counts: { + added: 0, + modified: 1, + deleted: 0, + renamed: 0, + copied: 0, + typeChanged: 0, + unmerged: 0, + untracked: 0, + cleaned: 0, + }, + files: [ + { + path: 'src\\auth.ts', + kind: 'modified', + origin: 'run-created', + additions: 5, + deletions: 2, + canRevert: true, + }, + ], +}; + +describe('buildProjectGitCommitMessageSuggestion', () => { + test('infers a fix commit from the triggering user prompt', () => { + const suggestion = buildProjectGitCommitMessageSuggestion({ + session: { + title: 'Auth hardening', + messages: [ + { + id: 'msg-user-1', + role: 'user', + authorName: 'You', + content: 'Fix auth hardening for git integration.', + createdAt: '2026-03-31T00:00:00.000Z', + }, + ], + }, + run: { + triggerMessageId: 'msg-user-1', + }, + summary: baseSummary, + }); + + expect(suggestion).toEqual({ + type: 'fix', + subject: 'auth hardening for git integration', + message: 'fix: auth hardening for git integration', + }); + }); + + test('infers docs commits from documentation-only changes', () => { + const suggestion = buildProjectGitCommitMessageSuggestion({ + session: { + title: 'Docs touch-up', + messages: [ + { + id: 'msg-user-1', + role: 'user', + authorName: 'You', + content: '', + createdAt: '2026-03-31T00:00:00.000Z', + }, + ], + }, + run: { + triggerMessageId: 'msg-user-1', + }, + summary: { + ...baseSummary, + files: [ + { + path: 'README.md', + kind: 'modified', + origin: 'run-created', + additions: 1, + deletions: 0, + canRevert: true, + }, + ], + }, + }); + + expect(suggestion.type).toBe('docs'); + expect(suggestion.message).toBe('docs: update readme'); + }); +}); diff --git a/tests/main/gitRunChangeSummary.test.ts b/tests/main/gitRunChangeSummary.test.ts new file mode 100644 index 0000000..5a93c73 --- /dev/null +++ b/tests/main/gitRunChangeSummary.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, test } from 'bun:test'; + +import type { ProjectGitBaselineFile, ProjectGitWorkingTreeSnapshot } from '@shared/domain/project'; + +import { buildProjectGitRunChangeSummary } from '@main/git/gitRunChangeSummary'; + +const TIMESTAMP = '2026-03-31T00:00:00.000Z'; + +function createPreRunSnapshot(): ProjectGitWorkingTreeSnapshot { + return { + scannedAt: TIMESTAMP, + repoRoot: 'C:\\workspace\\alpha', + branch: 'main', + changedFileCount: 2, + changes: { + staged: 0, + unstaged: 1, + untracked: 1, + conflicted: 0, + }, + files: [ + { + path: 'src\\auth.ts', + unstagedStatus: 'modified', + }, + { + path: 'legacy.tmp', + unstagedStatus: 'untracked', + }, + ], + }; +} + +function createPostRunSnapshot(): ProjectGitWorkingTreeSnapshot { + return { + scannedAt: TIMESTAMP, + repoRoot: 'C:\\workspace\\alpha', + branch: 'main', + changedFileCount: 2, + changes: { + staged: 0, + unstaged: 1, + untracked: 1, + conflicted: 0, + }, + files: [ + { + path: 'src\\auth.ts', + unstagedStatus: 'modified', + }, + { + path: 'notes.txt', + unstagedStatus: 'untracked', + }, + ], + }; +} + +function createPreRunBaselines(): ProjectGitBaselineFile[] { + return [ + { + path: 'src\\auth.ts', + combinedDiff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+before\n', + }, + { + path: 'legacy.tmp', + untrackedContentBase64: Buffer.from('legacy\n', 'utf8').toString('base64'), + }, + ]; +} + +function createPostRunBaselines(): ProjectGitBaselineFile[] { + return [ + { + path: 'src\\auth.ts', + combinedDiff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+after\n', + }, + { + path: 'notes.txt', + untrackedContentBase64: Buffer.from('fresh notes\n', 'utf8').toString('base64'), + }, + ]; +} + +describe('buildProjectGitRunChangeSummary', () => { + test('classifies run-created, pre-existing, and cleaned files', () => { + const summary = buildProjectGitRunChangeSummary({ + generatedAt: TIMESTAMP, + preRunSnapshot: createPreRunSnapshot(), + preRunBaselineFiles: createPreRunBaselines(), + postRunSnapshot: createPostRunSnapshot(), + postRunBaselineFiles: createPostRunBaselines(), + }); + + expect(summary).toMatchObject({ + generatedAt: TIMESTAMP, + branchAtStart: 'main', + branchAtEnd: 'main', + fileCount: 3, + additions: 1, + deletions: 1, + counts: { + added: 0, + modified: 1, + deleted: 0, + renamed: 0, + copied: 0, + typeChanged: 0, + unmerged: 0, + untracked: 1, + cleaned: 1, + }, + }); + + expect(summary?.files).toEqual([ + { + path: 'notes.txt', + previousPath: undefined, + kind: 'untracked', + origin: 'run-created', + stagedStatus: undefined, + unstagedStatus: 'untracked', + additions: 0, + deletions: 0, + canRevert: true, + preview: { + path: 'notes.txt', + previousPath: undefined, + newFileContents: 'fresh notes\n', + }, + }, + { + path: 'legacy.tmp', + previousPath: undefined, + kind: 'cleaned', + origin: 'pre-existing', + stagedStatus: undefined, + unstagedStatus: 'untracked', + additions: 0, + deletions: 0, + canRevert: true, + preview: { + path: 'legacy.tmp', + previousPath: undefined, + newFileContents: 'legacy\n', + }, + }, + { + path: 'src\\auth.ts', + previousPath: undefined, + kind: 'modified', + origin: 'pre-existing', + stagedStatus: undefined, + unstagedStatus: 'modified', + additions: 1, + deletions: 1, + canRevert: true, + preview: { + path: 'src\\auth.ts', + previousPath: undefined, + diff: 'diff --git a/src/auth.ts b/src/auth.ts\n@@ -1 +1 @@\n-old\n+after\n', + }, + }, + ]); + }); + + test('returns undefined when nothing changed across the run', () => { + const snapshot = createPreRunSnapshot(); + const baselines = createPreRunBaselines(); + + expect(buildProjectGitRunChangeSummary({ + generatedAt: TIMESTAMP, + preRunSnapshot: snapshot, + preRunBaselineFiles: baselines, + postRunSnapshot: snapshot, + postRunBaselineFiles: baselines, + })).toBeUndefined(); + }); + + test('marks pre-existing files as non-revertable when the baseline capture has no restore data', () => { + const summary = buildProjectGitRunChangeSummary({ + generatedAt: TIMESTAMP, + preRunSnapshot: createPreRunSnapshot(), + preRunBaselineFiles: [ + { + path: 'src\\auth.ts', + }, + ], + postRunSnapshot: createPostRunSnapshot(), + postRunBaselineFiles: createPostRunBaselines(), + }); + + expect(summary?.files.find((file) => file.path === 'src\\auth.ts')).toMatchObject({ + path: 'src\\auth.ts', + origin: 'pre-existing', + canRevert: false, + }); + + expect(summary?.files.find((file) => file.path === 'legacy.tmp')).toMatchObject({ + path: 'legacy.tmp', + kind: 'cleaned', + canRevert: false, + }); + }); + + test('preserves empty untracked file previews', () => { + const summary = buildProjectGitRunChangeSummary({ + generatedAt: TIMESTAMP, + preRunSnapshot: { + scannedAt: TIMESTAMP, + repoRoot: 'C:\\workspace\\alpha', + branch: 'main', + changedFileCount: 1, + changes: { + staged: 0, + unstaged: 0, + untracked: 1, + conflicted: 0, + }, + files: [ + { + path: 'empty.txt', + unstagedStatus: 'untracked', + }, + ], + }, + preRunBaselineFiles: [ + { + path: 'empty.txt', + untrackedContentBase64: '', + }, + ], + postRunSnapshot: { + scannedAt: TIMESTAMP, + repoRoot: 'C:\\workspace\\alpha', + branch: 'main', + changedFileCount: 0, + changes: { + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + }, + files: [], + }, + postRunBaselineFiles: [], + }); + + expect(summary?.files).toEqual([ + { + path: 'empty.txt', + previousPath: undefined, + kind: 'cleaned', + origin: 'pre-existing', + stagedStatus: undefined, + unstagedStatus: 'untracked', + additions: 0, + deletions: 0, + canRevert: true, + preview: { + path: 'empty.txt', + previousPath: undefined, + newFileContents: '', + }, + }, + ]); + }); +}); diff --git a/tests/main/gitService.test.ts b/tests/main/gitService.test.ts index 8d5d9f3..5f1b6a1 100644 --- a/tests/main/gitService.test.ts +++ b/tests/main/gitService.test.ts @@ -1,3 +1,7 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + import { describe, expect, test } from 'bun:test'; import { GitService } from '@main/git/gitService'; @@ -175,4 +179,332 @@ describe('GitService', () => { await expect(service.captureWorkingTreeSnapshot('C:\\workspace\\not-a-repo', '2026-03-23T19:00:00.000Z')).resolves.toBeUndefined(); }); + + test('captures baseline data for tracked diffs and untracked files', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-service-')); + try { + await writeFile(join(tempDirectory, 'notes.txt'), 'fresh notes\n', 'utf8'); + const service = createService({ + 'diff --binary --no-ext-diff --no-renames HEAD -- src\\app.ts': 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + }); + + await expect(service.captureWorkingTreeBaseline(tempDirectory, { + scannedAt: '2026-03-23T19:00:00.000Z', + repoRoot: tempDirectory, + branch: 'main', + changedFileCount: 2, + changes: { + staged: 0, + unstaged: 1, + untracked: 1, + conflicted: 0, + }, + files: [ + { + path: 'src\\app.ts', + unstagedStatus: 'modified', + }, + { + path: 'notes.txt', + unstagedStatus: 'untracked', + }, + ], + })).resolves.toEqual([ + { + path: 'src\\app.ts', + previousPath: undefined, + combinedDiff: 'diff --git a/src/app.ts b/src/app.ts\n@@ -1 +1 @@\n-old\n+new\n', + }, + { + path: 'notes.txt', + previousPath: undefined, + untrackedContentBase64: Buffer.from('fresh notes\n', 'utf8').toString('base64'), + }, + ]); + } finally { + await rm(tempDirectory, { force: true, recursive: true }); + } + }); + + test('describes git details with branches and recent commits', async () => { + const service = createService({ + 'rev-parse --show-toplevel': 'C:\\workspace\\repo\n', + 'status --porcelain=1 --untracked-files=all': ' M src\\app.ts\n', + 'branch --show-current': 'main\n', + 'rev-parse --abbrev-ref --symbolic-full-name @{upstream}': 'origin/main\n', + 'rev-list --left-right --count @{upstream}...HEAD': '0\t2\n', + 'log -1 --format=%H%n%h%n%s%n%cI': '0123456789abcdef\n0123456\nAdd git detail plumbing\n2026-03-23T18:00:00+01:00\n', + 'for-each-ref --format=%(refname:short)%00%(HEAD)%00%(upstream:short) refs/heads': 'main\0*\0origin/main\nfeature/refactor\0 \0origin/feature/refactor\n', + 'log -n15 --format=%H%x00%h%x00%an%x00%s%x00%cI%x00%D%x1e': '0123456789abcdef\x00123456\x00Alice\x00Add git detail plumbing\x002026-03-23T18:00:00+01:00\x00HEAD -> main, origin/main\x1e', + }); + + await expect(service.describeProjectGitDetails('C:\\workspace\\repo', '2026-03-23T19:00:00.000Z', 15)).resolves.toMatchObject({ + scannedAt: '2026-03-23T19:00:00.000Z', + context: { + status: 'ready', + branch: 'main', + upstream: 'origin/main', + ahead: 2, + behind: 0, + }, + workingTree: { + changedFileCount: 1, + }, + branches: [ + { name: 'main', isCurrent: true, upstream: 'origin/main' }, + { name: 'feature/refactor', isCurrent: false, upstream: 'origin/feature/refactor' }, + ], + recentCommits: [ + { + hash: '0123456789abcdef', + shortHash: '123456', + authorName: 'Alice', + subject: 'Add git detail plumbing', + committedAt: '2026-03-23T18:00:00+01:00', + refNames: 'HEAD -> main, origin/main', + }, + ], + }); + }); + + test('dispatches commit workflow and branch commands to git', async () => { + const executedCommands: string[] = []; + const service = new GitService(async (_projectPath, args) => { + executedCommands.push(args.join(' ')); + if (args.join(' ') === 'commit -m feat: update auth') { + return ''; + } + + if (args.join(' ') === 'log -1 --format=%H%n%h%n%s%n%cI') { + return 'fedcba9876543210\nfedcba9\nfeat: update auth\n2026-03-23T18:00:00+01:00\n'; + } + + return ''; + }); + + await service.stageFiles('C:\\workspace\\repo', [{ path: 'src\\auth.ts' }]); + await service.unstageFiles('C:\\workspace\\repo', [{ path: 'src\\auth.ts' }]); + await expect(service.commit('C:\\workspace\\repo', 'feat: update auth')).resolves.toEqual({ + hash: 'fedcba9876543210', + shortHash: 'fedcba9', + subject: 'feat: update auth', + committedAt: '2026-03-23T18:00:00+01:00', + }); + await service.push('C:\\workspace\\repo'); + await service.fetch('C:\\workspace\\repo'); + await service.pull('C:\\workspace\\repo', true); + await service.createBranch('C:\\workspace\\repo', 'feature/git-panel', undefined, true); + await service.switchBranch('C:\\workspace\\repo', 'main'); + await service.deleteBranch('C:\\workspace\\repo', 'feature/git-panel', true); + + expect(executedCommands).toEqual([ + 'add -- src\\auth.ts', + 'restore --staged -- src\\auth.ts', + 'commit -m feat: update auth', + 'log -1 --format=%H%n%h%n%s%n%cI', + 'push', + 'fetch --all --prune', + 'pull --rebase', + 'switch -c feature/git-panel', + 'switch main', + 'branch -D feature/git-panel', + ]); + }); + + test('discards run-created untracked files from the working tree', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-discard-')); + try { + await writeFile(join(tempDirectory, 'notes.txt'), 'fresh notes\n', 'utf8'); + const executedCommands: string[] = []; + const service = new GitService(async (_projectPath, args) => { + executedCommands.push(args.join(' ')); + return ''; + }); + + await service.discardRunChanges(tempDirectory, { + summary: { + generatedAt: '2026-03-31T00:00:00.000Z', + branchAtStart: 'main', + branchAtEnd: 'main', + fileCount: 1, + additions: 0, + deletions: 0, + counts: { + added: 0, + modified: 0, + deleted: 0, + renamed: 0, + copied: 0, + typeChanged: 0, + unmerged: 0, + untracked: 1, + cleaned: 0, + }, + files: [ + { + path: 'notes.txt', + kind: 'untracked', + origin: 'run-created', + additions: 0, + deletions: 0, + canRevert: true, + }, + ], + }, + }); + + expect(executedCommands).toEqual([ + 'rm --cached --force --ignore-unmatch -- notes.txt', + ]); + await expect(Bun.file(join(tempDirectory, 'notes.txt')).exists()).resolves.toBe(false); + } finally { + await rm(tempDirectory, { force: true, recursive: true }); + } + }); + + test('restores cleaned pre-existing untracked files from the captured baseline', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-restore-')); + try { + const executedCommands: string[] = []; + const service = new GitService(async (_projectPath, args) => { + executedCommands.push(args.join(' ')); + return ''; + }); + + await service.discardRunChanges(tempDirectory, { + summary: { + generatedAt: '2026-03-31T00:00:00.000Z', + branchAtStart: 'main', + branchAtEnd: 'main', + fileCount: 1, + additions: 0, + deletions: 0, + counts: { + added: 0, + modified: 0, + deleted: 0, + renamed: 0, + copied: 0, + typeChanged: 0, + unmerged: 0, + untracked: 0, + cleaned: 1, + }, + files: [ + { + path: 'legacy.txt', + kind: 'cleaned', + origin: 'pre-existing', + additions: 0, + deletions: 0, + canRevert: true, + }, + ], + }, + preRunBaselineFiles: [ + { + path: 'legacy.txt', + untrackedContentBase64: Buffer.from('legacy\n', 'utf8').toString('base64'), + }, + ], + }); + + expect(executedCommands).toEqual([ + 'restore --source=HEAD --staged --worktree -- legacy.txt', + ]); + await expect(Bun.file(join(tempDirectory, 'legacy.txt')).text()).resolves.toBe('legacy\n'); + } finally { + await rm(tempDirectory, { force: true, recursive: true }); + } + }); + + test('restores cleaned pre-existing empty untracked files from the captured baseline', async () => { + const tempDirectory = await mkdtemp(join(tmpdir(), 'aryx-git-restore-empty-')); + try { + const service = new GitService(async () => ''); + + await service.discardRunChanges(tempDirectory, { + summary: { + generatedAt: '2026-03-31T00:00:00.000Z', + branchAtStart: 'main', + branchAtEnd: 'main', + fileCount: 1, + additions: 0, + deletions: 0, + counts: { + added: 0, + modified: 0, + deleted: 0, + renamed: 0, + copied: 0, + typeChanged: 0, + unmerged: 0, + untracked: 0, + cleaned: 1, + }, + files: [ + { + path: 'empty.txt', + kind: 'cleaned', + origin: 'pre-existing', + additions: 0, + deletions: 0, + canRevert: true, + }, + ], + }, + preRunBaselineFiles: [ + { + path: 'empty.txt', + untrackedContentBase64: '', + }, + ], + }); + + await expect(Bun.file(join(tempDirectory, 'empty.txt')).text()).resolves.toBe(''); + } finally { + await rm(tempDirectory, { force: true, recursive: true }); + } + }); + + test('rejects restoring pre-existing files when no restorable baseline was captured', async () => { + const service = new GitService(async () => ''); + + await expect(service.discardRunChanges('C:\\workspace\\repo', { + summary: { + generatedAt: '2026-03-31T00:00:00.000Z', + branchAtStart: 'main', + branchAtEnd: 'main', + fileCount: 1, + additions: 0, + deletions: 0, + counts: { + added: 0, + modified: 1, + deleted: 0, + renamed: 0, + copied: 0, + typeChanged: 0, + unmerged: 0, + untracked: 0, + cleaned: 0, + }, + files: [ + { + path: 'src\\auth.ts', + kind: 'modified', + origin: 'pre-existing', + additions: 0, + deletions: 0, + canRevert: false, + }, + ], + }, + preRunBaselineFiles: [ + { + path: 'src\\auth.ts', + }, + ], + })).rejects.toThrow('no restorable baseline was captured'); + }); }); diff --git a/tests/shared/runTimeline.test.ts b/tests/shared/runTimeline.test.ts index 617840b..93b4864 100644 --- a/tests/shared/runTimeline.test.ts +++ b/tests/shared/runTimeline.test.ts @@ -55,22 +55,37 @@ describe('run timeline helpers', () => { const run = createSessionRunRecord({ requestId: 'turn-1', project: createProject(), + workingDirectory: 'C:\\workspace\\alpha\\packages\\app', workspaceKind: 'project', pattern: createPattern(), triggerMessageId: 'msg-user-1', startedAt: '2026-03-23T00:00:01.000Z', + preRunGitBaselineFiles: [ + { + path: 'src\\alpha.ts', + combinedDiff: '@@ -1 +1 @@\n-old\n+new\n', + }, + ], }); expect(run).toMatchObject({ requestId: 'turn-1', projectId: 'project-1', projectPath: 'C:\\workspace\\alpha', + workingDirectory: 'C:\\workspace\\alpha\\packages\\app', patternId: 'pattern-sequential', patternName: 'Sequential Trio Review', patternMode: 'sequential', triggerMessageId: 'msg-user-1', status: 'running', }); + expect(run.preRunGitBaselineFiles).toEqual([ + { + path: 'src\\alpha.ts', + previousPath: undefined, + combinedDiff: '@@ -1 +1 @@\n-old\n+new\n', + }, + ]); expect(run.agents).toEqual([ { agentId: 'agent-writer',