mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 05:08:44 +02:00
feat: add git workflow backend operations
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+243
-3
@@ -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)
|
||||
|
||||
@@ -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}`,
|
||||
};
|
||||
}
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type {
|
||||
ProjectGitBranchSummary,
|
||||
ProjectGitCommitMessageSuggestion,
|
||||
ProjectGitDetails,
|
||||
ProjectGitDiffPreview,
|
||||
ProjectGitFileReference,
|
||||
ProjectRecord,
|
||||
} from '@shared/domain/project';
|
||||
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
|
||||
import type { SessionEventRecord } from '@shared/domain/event';
|
||||
import type { TerminalExitInfo, TerminalSnapshot } from '@shared/domain/terminal';
|
||||
@@ -175,6 +182,59 @@ export interface SetTerminalHeightInput {
|
||||
height?: number;
|
||||
}
|
||||
|
||||
export interface ProjectGitInput {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface ProjectGitDetailsInput extends ProjectGitInput {
|
||||
commitLimit?: number;
|
||||
}
|
||||
|
||||
export interface ProjectGitFilePreviewInput extends ProjectGitInput {
|
||||
file: ProjectGitFileReference;
|
||||
}
|
||||
|
||||
export interface ProjectGitFileSelectionInput extends ProjectGitInput {
|
||||
files: ProjectGitFileReference[];
|
||||
}
|
||||
|
||||
export interface DiscardSessionRunGitChangesInput {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
files?: ProjectGitFileReference[];
|
||||
}
|
||||
|
||||
export interface SuggestProjectGitCommitMessageInput {
|
||||
sessionId: string;
|
||||
runId?: string;
|
||||
conventionalType?: ProjectGitCommitMessageSuggestion['type'];
|
||||
}
|
||||
|
||||
export interface CommitProjectGitChangesInput extends ProjectGitInput {
|
||||
message: string;
|
||||
files?: ProjectGitFileReference[];
|
||||
push?: boolean;
|
||||
}
|
||||
|
||||
export interface PullProjectGitInput extends ProjectGitInput {
|
||||
rebase?: boolean;
|
||||
}
|
||||
|
||||
export interface CreateProjectGitBranchInput extends ProjectGitInput {
|
||||
name: string;
|
||||
startPoint?: string;
|
||||
checkout?: boolean;
|
||||
}
|
||||
|
||||
export interface SwitchProjectGitBranchInput extends ProjectGitInput {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface DeleteProjectGitBranchInput extends ProjectGitInput {
|
||||
name: string;
|
||||
force?: boolean;
|
||||
}
|
||||
|
||||
export type UpdateStatusState = 'idle' | 'checking' | 'up-to-date' | 'available' | 'downloading' | 'downloaded' | 'error';
|
||||
|
||||
export interface UpdateDownloadProgress {
|
||||
@@ -253,6 +313,19 @@ export interface ElectronApi {
|
||||
openAppDataFolder(): Promise<void>;
|
||||
resetLocalWorkspace(): Promise<WorkspaceState>;
|
||||
getQuota(): Promise<Record<string, QuotaSnapshot>>;
|
||||
getProjectGitDetails(input: ProjectGitDetailsInput): Promise<ProjectGitDetails>;
|
||||
getProjectGitFilePreview(input: ProjectGitFilePreviewInput): Promise<ProjectGitDiffPreview | undefined>;
|
||||
discardSessionRunGitChanges(input: DiscardSessionRunGitChangesInput): Promise<WorkspaceState>;
|
||||
stageProjectGitFiles(input: ProjectGitFileSelectionInput): Promise<WorkspaceState>;
|
||||
unstageProjectGitFiles(input: ProjectGitFileSelectionInput): Promise<WorkspaceState>;
|
||||
suggestProjectGitCommitMessage(input: SuggestProjectGitCommitMessageInput): Promise<ProjectGitCommitMessageSuggestion>;
|
||||
commitProjectGitChanges(input: CommitProjectGitChangesInput): Promise<WorkspaceState>;
|
||||
pushProjectGit(input: ProjectGitInput): Promise<WorkspaceState>;
|
||||
fetchProjectGit(input: ProjectGitInput): Promise<WorkspaceState>;
|
||||
pullProjectGit(input: PullProjectGitInput): Promise<WorkspaceState>;
|
||||
createProjectGitBranch(input: CreateProjectGitBranchInput): Promise<WorkspaceState>;
|
||||
switchProjectGitBranch(input: SwitchProjectGitBranchInput): Promise<WorkspaceState>;
|
||||
deleteProjectGitBranch(input: DeleteProjectGitBranchInput): Promise<WorkspaceState>;
|
||||
onTerminalData(listener: (data: string) => void): () => void;
|
||||
onTerminalExit(listener: (info: TerminalExitInfo) => void): () => void;
|
||||
onWorkspaceUpdated(listener: (workspace: WorkspaceState) => void): () => void;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,7 +6,12 @@ import type {
|
||||
import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type {
|
||||
ProjectGitBaselineFile,
|
||||
ProjectGitChangeSummary,
|
||||
ProjectGitDiffPreview,
|
||||
ProjectGitRunChangeCounts,
|
||||
ProjectGitRunChangedFile,
|
||||
ProjectGitRunChangeSummary,
|
||||
ProjectGitWorkingTreeFile,
|
||||
ProjectGitWorkingTreeFileStatus,
|
||||
ProjectGitWorkingTreeSnapshot,
|
||||
@@ -66,6 +71,7 @@ export interface SessionRunRecord {
|
||||
requestId: string;
|
||||
projectId: string;
|
||||
projectPath: string;
|
||||
workingDirectory?: string;
|
||||
workspaceKind: SessionRunWorkspaceKind;
|
||||
patternId: string;
|
||||
patternName: string;
|
||||
@@ -77,16 +83,20 @@ export interface SessionRunRecord {
|
||||
agents: RunTimelineAgentRecord[];
|
||||
events: RunTimelineEventRecord[];
|
||||
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
preRunGitBaselineFiles?: ProjectGitBaselineFile[];
|
||||
postRunGitSummary?: ProjectGitRunChangeSummary;
|
||||
}
|
||||
|
||||
export interface CreateSessionRunRecordInput {
|
||||
requestId: string;
|
||||
project: Pick<ProjectRecord, 'id' | 'path'>;
|
||||
workingDirectory?: string;
|
||||
workspaceKind: SessionRunWorkspaceKind;
|
||||
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
|
||||
triggerMessageId: string;
|
||||
startedAt: string;
|
||||
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||
preRunGitBaselineFiles?: ProjectGitBaselineFile[];
|
||||
}
|
||||
|
||||
export interface AppendRunActivityEventInput {
|
||||
@@ -213,6 +223,145 @@ function normalizeWorkingTreeSnapshot(
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitDiffPreview(
|
||||
preview: ProjectGitDiffPreview,
|
||||
): ProjectGitDiffPreview | undefined {
|
||||
const path = normalizeOptionalString(preview.path);
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
previousPath: normalizeOptionalString(preview.previousPath),
|
||||
diff: normalizeOptionalPreviewText(preview.diff),
|
||||
newFileContents: normalizeOptionalPreviewText(preview.newFileContents),
|
||||
...(preview.isBinary ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitBaselineFile(
|
||||
file: ProjectGitBaselineFile,
|
||||
): ProjectGitBaselineFile | undefined {
|
||||
const path = normalizeOptionalString(file.path);
|
||||
if (!path) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
previousPath: normalizeOptionalString(file.previousPath),
|
||||
combinedDiff: normalizeOptionalPreviewText(file.combinedDiff),
|
||||
untrackedContentBase64: normalizeOptionalString(file.untrackedContentBase64),
|
||||
...(file.isBinary ? { isBinary: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitBaselineFiles(
|
||||
files: readonly ProjectGitBaselineFile[] | undefined,
|
||||
): ProjectGitBaselineFile[] | undefined {
|
||||
if (!files || files.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalized = files.flatMap((file) => {
|
||||
const nextFile = normalizeGitBaselineFile(file);
|
||||
return nextFile ? [nextFile] : [];
|
||||
});
|
||||
|
||||
return normalized.length > 0 ? normalized : undefined;
|
||||
}
|
||||
|
||||
function normalizeGitRunChangeKind(
|
||||
value: ProjectGitRunChangedFile['kind'] | undefined,
|
||||
): ProjectGitRunChangedFile['kind'] | undefined {
|
||||
switch (value) {
|
||||
case 'cleaned':
|
||||
return value;
|
||||
case 'added':
|
||||
case 'modified':
|
||||
case 'deleted':
|
||||
case 'renamed':
|
||||
case 'copied':
|
||||
case 'type-changed':
|
||||
case 'unmerged':
|
||||
case 'untracked':
|
||||
return value;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeGitRunChangeCounts(
|
||||
counts: Partial<ProjectGitRunChangeCounts> | undefined,
|
||||
): ProjectGitRunChangeCounts {
|
||||
return {
|
||||
added: normalizeNonNegativeInteger(counts?.added),
|
||||
modified: normalizeNonNegativeInteger(counts?.modified),
|
||||
deleted: normalizeNonNegativeInteger(counts?.deleted),
|
||||
renamed: normalizeNonNegativeInteger(counts?.renamed),
|
||||
copied: normalizeNonNegativeInteger(counts?.copied),
|
||||
typeChanged: normalizeNonNegativeInteger(counts?.typeChanged),
|
||||
unmerged: normalizeNonNegativeInteger(counts?.unmerged),
|
||||
untracked: normalizeNonNegativeInteger(counts?.untracked),
|
||||
cleaned: normalizeNonNegativeInteger(counts?.cleaned),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitRunChangedFile(
|
||||
file: ProjectGitRunChangedFile,
|
||||
): ProjectGitRunChangedFile | undefined {
|
||||
const path = normalizeOptionalString(file.path);
|
||||
const kind = normalizeGitRunChangeKind(file.kind);
|
||||
if (!path || !kind) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
previousPath: normalizeOptionalString(file.previousPath),
|
||||
kind,
|
||||
origin: file.origin === 'pre-existing' ? 'pre-existing' : 'run-created',
|
||||
stagedStatus: normalizeWorkingTreeFileStatus(file.stagedStatus),
|
||||
unstagedStatus: normalizeWorkingTreeFileStatus(file.unstagedStatus),
|
||||
...(file.isConflicted ? { isConflicted: true } : {}),
|
||||
additions: normalizeNonNegativeInteger(file.additions),
|
||||
deletions: normalizeNonNegativeInteger(file.deletions),
|
||||
canRevert: file.canRevert === true,
|
||||
preview: file.preview ? normalizeGitDiffPreview(file.preview) : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeGitRunChangeSummary(
|
||||
summary: ProjectGitRunChangeSummary | undefined,
|
||||
): ProjectGitRunChangeSummary | undefined {
|
||||
if (!summary) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const generatedAt = normalizeOptionalString(summary.generatedAt);
|
||||
if (!generatedAt) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const files = (summary.files ?? []).flatMap((file) => {
|
||||
const normalized = normalizeGitRunChangedFile(file);
|
||||
return normalized ? [normalized] : [];
|
||||
});
|
||||
|
||||
return {
|
||||
generatedAt,
|
||||
branchAtStart: normalizeOptionalString(summary.branchAtStart),
|
||||
branchAtEnd: normalizeOptionalString(summary.branchAtEnd),
|
||||
...(summary.branchChanged ? { branchChanged: true } : {}),
|
||||
fileCount: normalizeNonNegativeInteger(summary.fileCount),
|
||||
additions: normalizeNonNegativeInteger(summary.additions),
|
||||
deletions: normalizeNonNegativeInteger(summary.deletions),
|
||||
counts: normalizeGitRunChangeCounts(summary.counts),
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeToolCallFileChange(
|
||||
change: ToolCallFileChangePreview,
|
||||
): ToolCallFileChangePreview | undefined {
|
||||
@@ -456,6 +605,7 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
|
||||
requestId: input.requestId,
|
||||
projectId: input.project.id,
|
||||
projectPath: input.project.path,
|
||||
workingDirectory: normalizeOptionalString(input.workingDirectory),
|
||||
workspaceKind: input.workspaceKind,
|
||||
patternId: input.pattern.id,
|
||||
patternName: input.pattern.name,
|
||||
@@ -465,6 +615,8 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
|
||||
status: 'running',
|
||||
completedAt: undefined,
|
||||
preRunGitSnapshot: normalizeWorkingTreeSnapshot(input.preRunGitSnapshot),
|
||||
preRunGitBaselineFiles: normalizeGitBaselineFiles(input.preRunGitBaselineFiles),
|
||||
postRunGitSummary: undefined,
|
||||
agents: input.pattern.agents
|
||||
.map((agent): RunTimelineAgentRecord => ({
|
||||
agentId: agent.id,
|
||||
@@ -500,6 +652,7 @@ export function normalizeSessionRunRecords(
|
||||
const requestId = normalizeOptionalString(run.requestId);
|
||||
const projectId = normalizeOptionalString(run.projectId);
|
||||
const projectPath = normalizeOptionalString(run.projectPath);
|
||||
const workingDirectory = normalizeOptionalString(run.workingDirectory);
|
||||
const patternId = normalizeOptionalString(run.patternId);
|
||||
const patternName = normalizeOptionalString(run.patternName);
|
||||
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
|
||||
@@ -514,6 +667,7 @@ export function normalizeSessionRunRecords(
|
||||
requestId,
|
||||
projectId,
|
||||
projectPath,
|
||||
workingDirectory,
|
||||
workspaceKind: run.workspaceKind === 'scratchpad' ? 'scratchpad' : 'project',
|
||||
patternId,
|
||||
patternName,
|
||||
@@ -523,6 +677,8 @@ export function normalizeSessionRunRecords(
|
||||
completedAt: normalizeOptionalString(run.completedAt),
|
||||
status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : run.status === 'cancelled' ? 'cancelled' : 'completed',
|
||||
preRunGitSnapshot: normalizeWorkingTreeSnapshot(run.preRunGitSnapshot),
|
||||
preRunGitBaselineFiles: normalizeGitBaselineFiles(run.preRunGitBaselineFiles),
|
||||
postRunGitSummary: normalizeGitRunChangeSummary(run.postRunGitSummary),
|
||||
agents: run.agents.flatMap((agent) => {
|
||||
const normalized = normalizeRunTimelineAgent(agent);
|
||||
return normalized ? [normalized] : [];
|
||||
@@ -770,3 +926,26 @@ export function failSessionRunRecord(
|
||||
error,
|
||||
});
|
||||
}
|
||||
|
||||
export function setSessionRunGitSummary(
|
||||
run: SessionRunRecord,
|
||||
summary: ProjectGitRunChangeSummary | undefined,
|
||||
): SessionRunRecord {
|
||||
const normalizedSummary = normalizeGitRunChangeSummary(summary);
|
||||
if (normalizedSummary === undefined && run.postRunGitSummary === undefined) {
|
||||
return run;
|
||||
}
|
||||
|
||||
if (
|
||||
normalizedSummary !== undefined
|
||||
&& run.postRunGitSummary !== undefined
|
||||
&& JSON.stringify(normalizedSummary) === JSON.stringify(run.postRunGitSummary)
|
||||
) {
|
||||
return run;
|
||||
}
|
||||
|
||||
return {
|
||||
...run,
|
||||
postRunGitSummary: normalizedSummary,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user