mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 20:28:46 +02:00
feat: add git integration enhancements (phase 1)
Real-time git awareness: - Auto-refresh git context on window focus and after run completion - Periodic background polling (60s interval, configurable via settings) - Pre-run working tree snapshot capture for project-backed runs - Debounced refresh scheduling to coalesce rapid triggers Backend (main process): - GitService.captureWorkingTreeSnapshot() with per-file metadata - Enriched parseWorkingTree() producing ProjectGitWorkingTreeFile entries - AryxAppService: scheduleProjectGitRefresh(), periodic timer, focus hook - preRunGitSnapshot persisted on SessionRunRecord with normalization Frontend (renderer): - Settings toggle for auto-refresh (General > Git section) - RunTimeline: git baseline indicator showing branch and change summary - Enhanced GitContextBadge tooltip with change breakdown details - ChatPane: enriched git tooltip with staged/modified/untracked counts - New IPC channel: setGitAutoRefreshEnabled Tests: 8 new tests covering snapshot capture, refresh scheduling, scratchpad skip behavior, and auto-refresh setting persistence. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+125
-2
@@ -185,6 +185,8 @@ const INTERRUPTED_RUN_ERROR =
|
||||
'This session was interrupted because Aryx restarted while a run was in progress.';
|
||||
const INTERRUPTED_APPROVAL_ERROR =
|
||||
'Pending approval was interrupted because Aryx restarted before a decision was recorded.';
|
||||
const GIT_REFRESH_DEBOUNCE_MS = 750;
|
||||
const GIT_REFRESH_INTERVAL_MS = 60_000;
|
||||
|
||||
export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly workspaceRepository = new WorkspaceRepository();
|
||||
@@ -201,7 +203,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private sidecarCapabilities?: SidecarCapabilities;
|
||||
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
|
||||
private didScheduleInitialProjectGitRefresh = false;
|
||||
private didStartPeriodicProjectGitRefresh = false;
|
||||
private mcpProbeUpdateQueue = Promise.resolve();
|
||||
private pendingProjectGitRefreshIds = new Set<string>();
|
||||
private pendingRefreshAllProjects = false;
|
||||
private projectGitRefreshTimer?: ReturnType<typeof setTimeout>;
|
||||
private periodicProjectGitRefreshTimer?: ReturnType<typeof setInterval>;
|
||||
private runningProjectGitRefresh?: Promise<void>;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
@@ -252,6 +260,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
|
||||
if (!this.didScheduleInitialProjectGitRefresh) {
|
||||
this.didScheduleInitialProjectGitRefresh = true;
|
||||
if (this.workspace.settings.gitAutoRefreshEnabled !== false) {
|
||||
this.startPeriodicProjectGitRefresh();
|
||||
}
|
||||
void this.refreshProjectGitContext().catch((error) => {
|
||||
console.error('[aryx git]', error);
|
||||
});
|
||||
@@ -270,11 +281,42 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
if (this.projectGitRefreshTimer) {
|
||||
clearTimeout(this.projectGitRefreshTimer);
|
||||
this.projectGitRefreshTimer = undefined;
|
||||
}
|
||||
if (this.periodicProjectGitRefreshTimer) {
|
||||
clearInterval(this.periodicProjectGitRefreshTimer);
|
||||
this.periodicProjectGitRefreshTimer = undefined;
|
||||
}
|
||||
this.ptyManager.dispose();
|
||||
await this.sidecar.dispose();
|
||||
void this.secretStore;
|
||||
}
|
||||
|
||||
isGitAutoRefreshEnabled(): boolean {
|
||||
return this.workspace?.settings.gitAutoRefreshEnabled !== false;
|
||||
}
|
||||
|
||||
scheduleProjectGitRefresh(projectId?: string): void {
|
||||
if (projectId) {
|
||||
this.pendingProjectGitRefreshIds.add(projectId);
|
||||
} else {
|
||||
this.pendingRefreshAllProjects = true;
|
||||
this.pendingProjectGitRefreshIds.clear();
|
||||
}
|
||||
|
||||
if (this.projectGitRefreshTimer) {
|
||||
clearTimeout(this.projectGitRefreshTimer);
|
||||
}
|
||||
|
||||
this.projectGitRefreshTimer = setTimeout(() => {
|
||||
this.projectGitRefreshTimer = undefined;
|
||||
void this.flushScheduledProjectGitRefresh();
|
||||
}, GIT_REFRESH_DEBOUNCE_MS);
|
||||
this.projectGitRefreshTimer.unref?.();
|
||||
}
|
||||
|
||||
async openAppDataFolder(): Promise<void> {
|
||||
const appDataPath = dirname(this.workspaceRepository.filePath);
|
||||
await shell.openPath(appDataPath);
|
||||
@@ -527,6 +569,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async setGitAutoRefreshEnabled(enabled: boolean): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
workspace.settings.gitAutoRefreshEnabled = enabled;
|
||||
|
||||
if (enabled) {
|
||||
this.startPeriodicProjectGitRefresh();
|
||||
} else {
|
||||
this.stopPeriodicProjectGitRefresh();
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async describeTerminal(): Promise<TerminalSnapshot | undefined> {
|
||||
return this.ptyManager.getSnapshot();
|
||||
}
|
||||
@@ -1302,6 +1357,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
): Promise<void> {
|
||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
||||
const preRunGitSnapshot = workspaceKind === 'project'
|
||||
? await this.gitService.captureWorkingTreeSnapshot(session.cwd ?? project.path, occurredAt)
|
||||
: undefined;
|
||||
if (workspaceKind === 'project' && project.git?.status === 'ready' && !preRunGitSnapshot) {
|
||||
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
|
||||
}
|
||||
|
||||
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
||||
session.status = 'running';
|
||||
@@ -1317,6 +1378,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
pattern: effectivePattern,
|
||||
triggerMessageId,
|
||||
startedAt: occurredAt,
|
||||
preRunGitSnapshot,
|
||||
}),
|
||||
...session.runs,
|
||||
];
|
||||
@@ -1376,10 +1438,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
||||
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof TurnCancelledError) {
|
||||
this.finalizeCancelledTurn(workspace, session, requestId);
|
||||
await this.persistAndBroadcast(workspace);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1402,6 +1470,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
if (workspaceKind === 'project') {
|
||||
this.scheduleProjectGitRefresh(project.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1486,9 +1557,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
|
||||
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
|
||||
return this.refreshProjectGitContexts(projectId ? [projectId] : undefined);
|
||||
}
|
||||
|
||||
private async refreshProjectGitContexts(projectIds?: readonly string[]): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const projects = projectId
|
||||
? [this.requireProject(workspace, projectId)]
|
||||
const projects = projectIds?.length
|
||||
? projectIds.map((currentProjectId) => this.requireProject(workspace, currentProjectId))
|
||||
: workspace.projects;
|
||||
|
||||
let didRefreshGit = false;
|
||||
@@ -1603,6 +1678,54 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return true;
|
||||
}
|
||||
|
||||
private startPeriodicProjectGitRefresh(): void {
|
||||
if (this.didStartPeriodicProjectGitRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.didStartPeriodicProjectGitRefresh = true;
|
||||
this.periodicProjectGitRefreshTimer = setInterval(() => {
|
||||
this.scheduleProjectGitRefresh();
|
||||
}, GIT_REFRESH_INTERVAL_MS);
|
||||
this.periodicProjectGitRefreshTimer.unref?.();
|
||||
}
|
||||
|
||||
private stopPeriodicProjectGitRefresh(): void {
|
||||
if (this.periodicProjectGitRefreshTimer) {
|
||||
clearInterval(this.periodicProjectGitRefreshTimer);
|
||||
this.periodicProjectGitRefreshTimer = undefined;
|
||||
}
|
||||
this.didStartPeriodicProjectGitRefresh = false;
|
||||
}
|
||||
|
||||
private async flushScheduledProjectGitRefresh(): Promise<void> {
|
||||
if (this.runningProjectGitRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectIds = this.pendingRefreshAllProjects
|
||||
? undefined
|
||||
: [...this.pendingProjectGitRefreshIds];
|
||||
this.pendingRefreshAllProjects = false;
|
||||
this.pendingProjectGitRefreshIds.clear();
|
||||
|
||||
this.runningProjectGitRefresh = this.refreshProjectGitContexts(projectIds).then(
|
||||
() => undefined,
|
||||
(error) => {
|
||||
console.error('[aryx git]', error);
|
||||
},
|
||||
);
|
||||
|
||||
try {
|
||||
await this.runningProjectGitRefresh;
|
||||
} finally {
|
||||
this.runningProjectGitRefresh = undefined;
|
||||
if (this.pendingRefreshAllProjects || this.pendingProjectGitRefreshIds.size > 0) {
|
||||
this.scheduleProjectGitRefresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private requirePattern(workspace: WorkspaceState, patternId: string): PatternDefinition {
|
||||
const pattern = workspace.patterns.find((current) => current.id === patternId);
|
||||
if (!pattern) {
|
||||
|
||||
+101
-6
@@ -1,7 +1,14 @@
|
||||
import { createRequire } from 'node:module';
|
||||
import { promisify } from 'node:util';
|
||||
|
||||
import type { ProjectGitChangeSummary, ProjectGitCommitSummary, ProjectGitContext } from '@shared/domain/project';
|
||||
import type {
|
||||
ProjectGitChangeSummary,
|
||||
ProjectGitCommitSummary,
|
||||
ProjectGitContext,
|
||||
ProjectGitWorkingTreeFile,
|
||||
ProjectGitWorkingTreeFileStatus,
|
||||
ProjectGitWorkingTreeSnapshot,
|
||||
} from '@shared/domain/project';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
type ExecFileException = import('node:child_process').ExecFileException;
|
||||
@@ -102,9 +109,46 @@ function isConflictedStatus(x: string, y: string): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function parseChangeSummary(stdout: string): {
|
||||
function parseWorkingTreeFileStatus(value: string): ProjectGitWorkingTreeFileStatus | undefined {
|
||||
switch (value) {
|
||||
case 'A':
|
||||
return 'added';
|
||||
case 'M':
|
||||
return 'modified';
|
||||
case 'D':
|
||||
return 'deleted';
|
||||
case 'R':
|
||||
return 'renamed';
|
||||
case 'C':
|
||||
return 'copied';
|
||||
case 'T':
|
||||
return 'type-changed';
|
||||
case 'U':
|
||||
return 'unmerged';
|
||||
case '?':
|
||||
return 'untracked';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function parseWorkingTreePath(rawPath: string): Pick<ProjectGitWorkingTreeFile, 'path' | 'previousPath'> {
|
||||
const separator = ' -> ';
|
||||
const separatorIndex = rawPath.indexOf(separator);
|
||||
if (separatorIndex < 0) {
|
||||
return { path: rawPath };
|
||||
}
|
||||
|
||||
return {
|
||||
previousPath: rawPath.slice(0, separatorIndex).trim(),
|
||||
path: rawPath.slice(separatorIndex + separator.length).trim(),
|
||||
};
|
||||
}
|
||||
|
||||
function parseWorkingTree(stdout: string): {
|
||||
changedFileCount: number;
|
||||
changes: ProjectGitChangeSummary;
|
||||
files: ProjectGitWorkingTreeFile[];
|
||||
} {
|
||||
const summary: ProjectGitChangeSummary = {
|
||||
staged: 0,
|
||||
@@ -112,6 +156,7 @@ function parseChangeSummary(stdout: string): {
|
||||
untracked: 0,
|
||||
conflicted: 0,
|
||||
};
|
||||
const files: ProjectGitWorkingTreeFile[] = [];
|
||||
|
||||
const lines = stdout
|
||||
.split(/\r?\n/)
|
||||
@@ -122,20 +167,42 @@ function parseChangeSummary(stdout: string): {
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('??')) {
|
||||
const path = line.slice(3).trim();
|
||||
if (!path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
summary.untracked += 1;
|
||||
changedFileCount += 1;
|
||||
files.push({
|
||||
path,
|
||||
unstagedStatus: 'untracked',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.length < 2) {
|
||||
if (line.length < 3) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const x = line[0];
|
||||
const y = line[1];
|
||||
changedFileCount += 1;
|
||||
const rawPath = line.slice(3).trim();
|
||||
if (!rawPath) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isConflictedStatus(x, y)) {
|
||||
changedFileCount += 1;
|
||||
const isConflicted = isConflictedStatus(x, y);
|
||||
const pathInfo = parseWorkingTreePath(rawPath);
|
||||
files.push({
|
||||
...pathInfo,
|
||||
stagedStatus: parseWorkingTreeFileStatus(x),
|
||||
unstagedStatus: parseWorkingTreeFileStatus(y),
|
||||
...(isConflicted ? { isConflicted: true } : {}),
|
||||
});
|
||||
|
||||
if (isConflicted) {
|
||||
summary.conflicted += 1;
|
||||
continue;
|
||||
}
|
||||
@@ -152,6 +219,7 @@ function parseChangeSummary(stdout: string): {
|
||||
return {
|
||||
changedFileCount,
|
||||
changes: summary,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -219,7 +287,7 @@ export class GitService {
|
||||
this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']),
|
||||
]);
|
||||
|
||||
const { changedFileCount, changes } = parseChangeSummary(statusResult.stdout);
|
||||
const { changedFileCount, changes } = parseWorkingTree(statusResult.stdout);
|
||||
const upstream = upstreamResult.ok ? upstreamResult.stdout.trim() || undefined : undefined;
|
||||
const aheadBehind = countsResult.ok ? parseAheadBehind(countsResult.stdout) : {};
|
||||
|
||||
@@ -238,6 +306,33 @@ export class GitService {
|
||||
};
|
||||
}
|
||||
|
||||
async captureWorkingTreeSnapshot(
|
||||
projectPath: string,
|
||||
scannedAt = nowIso(),
|
||||
): Promise<ProjectGitWorkingTreeSnapshot | undefined> {
|
||||
const repoRootResult = await this.tryRun(projectPath, ['rev-parse', '--show-toplevel']);
|
||||
if (!repoRootResult.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const statusResult = await this.tryRun(projectPath, ['status', '--porcelain=1', '--untracked-files=all']);
|
||||
if (!statusResult.ok) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const branchResult = await this.tryRun(projectPath, ['branch', '--show-current']);
|
||||
const { changedFileCount, changes, files } = parseWorkingTree(statusResult.stdout);
|
||||
|
||||
return {
|
||||
scannedAt,
|
||||
repoRoot: repoRootResult.stdout.trim(),
|
||||
branch: branchResult.ok ? parseBranch(branchResult.stdout) : undefined,
|
||||
changedFileCount,
|
||||
changes,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
private async tryRun(projectPath: string, args: string[]): Promise<GitCommandResult> {
|
||||
try {
|
||||
return {
|
||||
|
||||
@@ -52,6 +52,12 @@ export function registerIpcHandlers(
|
||||
service: AryxAppService,
|
||||
autoUpdateService: AutoUpdateService,
|
||||
): void {
|
||||
window.on('focus', () => {
|
||||
if (service.isGitAutoRefreshEnabled()) {
|
||||
service.scheduleProjectGitRefresh();
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle(ipcChannels.describeSidecarCapabilities, () => service.describeSidecarCapabilities());
|
||||
ipcMain.handle(ipcChannels.refreshSidecarCapabilities, () => service.refreshSidecarCapabilities());
|
||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||
@@ -105,6 +111,10 @@ export function registerIpcHandlers(
|
||||
ipcChannels.setMinimizeToTray,
|
||||
(_event, enabled: boolean) => service.setMinimizeToTray(enabled),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.setGitAutoRefreshEnabled,
|
||||
(_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
|
||||
ipcMain.handle(ipcChannels.installUpdate, () => {
|
||||
autoUpdateService.installUpdate();
|
||||
|
||||
Reference in New Issue
Block a user