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:
David Kaya
2026-03-31 16:37:02 +01:00
co-authored by Copilot
parent 49933f218b
commit 44d0ab07db
17 changed files with 845 additions and 24 deletions
+125 -2
View File
@@ -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
View File
@@ -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 {
+10
View File
@@ -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();