diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b441b6a..7c6ce82 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -55,7 +55,7 @@ flowchart LR | --- | --- | --- | --- | | Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events | | Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` | -| Main process | Workspace mutation, persistence, git inspection, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes | +| Main process | Workspace mutation, persistence, git inspection and refresh orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes | | Sidecar | Capability discovery, pattern validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio | | External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar | @@ -89,7 +89,7 @@ sequenceDiagram R->>P: Invoke typed API P->>M: IPC request M->>M: Append user message - M->>M: Create run record and mark session running + M->>M: Capture pre-run git snapshot, create run record, and mark session running M->>S: run-turn command S->>C: Execute workflow C-->>S: Partial output / tool activity / handoffs / input requests @@ -97,7 +97,7 @@ sequenceDiagram M-->>R: Push session events and workspace updates C-->>S: Final messages or turn boundary S-->>M: Completion or error - M->>M: Finalize run and persist state + M->>M: Finalize run, refresh project git state, and persist state M-->>R: Final workspace snapshot ``` @@ -126,6 +126,8 @@ The scratchpad is modeled inside the same workspace system instead of as a separ Project-backed entries also persist scanned Copilot customization metadata discovered from repository files such as `.github/copilot-instructions.md`, `AGENTS.md`, `.github/agents/*.agent.md`, and `.github/prompts/*.prompt.md`. The main process owns that scan step and stores the normalized results on the project record so repo instructions and enabled custom agent profiles can participate in later run execution without turning the renderer into a filesystem crawler. +For git-backed projects, the main process also owns background git refreshes and captures a structured pre-run working-tree snapshot on each run record. That keeps git CLI access inside the privileged process while giving later renderer features a typed baseline for attributing post-run file changes to a specific turn. + ### Patterns Patterns describe how agents collaborate. The architecture supports: diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index c170d84..d2c1b9a 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -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 { private readonly workspaceRepository = new WorkspaceRepository(); @@ -201,7 +203,13 @@ export class AryxAppService extends EventEmitter { private sidecarCapabilities?: SidecarCapabilities; private sidecarCapabilitiesPromise?: Promise; private didScheduleInitialProjectGitRefresh = false; + private didStartPeriodicProjectGitRefresh = false; private mcpProbeUpdateQueue = Promise.resolve(); + private pendingProjectGitRefreshIds = new Set(); + private pendingRefreshAllProjects = false; + private projectGitRefreshTimer?: ReturnType; + private periodicProjectGitRefreshTimer?: ReturnType; + private runningProjectGitRefresh?: Promise; constructor() { super(); @@ -252,6 +260,9 @@ export class AryxAppService extends EventEmitter { 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 { } async dispose(): Promise { + 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 { const appDataPath = dirname(this.workspaceRepository.filePath); await shell.openPath(appDataPath); @@ -527,6 +569,19 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async setGitAutoRefreshEnabled(enabled: boolean): Promise { + const workspace = await this.loadWorkspace(); + workspace.settings.gitAutoRefreshEnabled = enabled; + + if (enabled) { + this.startPeriodicProjectGitRefresh(); + } else { + this.stopPeriodicProjectGitRefresh(); + } + + return this.persistAndBroadcast(workspace); + } + async describeTerminal(): Promise { return this.ptyManager.getSnapshot(); } @@ -1302,6 +1357,12 @@ export class AryxAppService extends EventEmitter { ): Promise { 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 { pattern: effectivePattern, triggerMessageId, startedAt: occurredAt, + preRunGitSnapshot, }), ...session.runs, ]; @@ -1376,10 +1438,16 @@ export class AryxAppService extends EventEmitter { 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 { } await this.persistAndBroadcast(workspace); + if (workspaceKind === 'project') { + this.scheduleProjectGitRefresh(project.id); + } } } @@ -1486,9 +1557,13 @@ export class AryxAppService extends EventEmitter { } async refreshProjectGitContext(projectId?: string): Promise { + return this.refreshProjectGitContexts(projectId ? [projectId] : undefined); + } + + private async refreshProjectGitContexts(projectIds?: readonly string[]): Promise { 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 { 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 { + 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) { diff --git a/src/main/git/gitService.ts b/src/main/git/gitService.ts index 95f86d2..8cf6664 100644 --- a/src/main/git/gitService.ts +++ b/src/main/git/gitService.ts @@ -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 { + 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 { + 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 { try { return { diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 4491831..f35a2c3 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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(); diff --git a/src/preload/index.ts b/src/preload/index.ts index 1f8e1cb..fc5416d 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,6 +28,7 @@ const api: ElectronApi = { setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input), setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled), setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled), + setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled), checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates), installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate), saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 4d87e3f..2ec201b 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -692,6 +692,8 @@ export default function App() { onSetNotificationsEnabled={(enabled) => void api.setNotificationsEnabled(enabled)} minimizeToTray={workspace.settings.minimizeToTray === true} onSetMinimizeToTray={(enabled) => void api.setMinimizeToTray(enabled)} + gitAutoRefreshEnabled={workspace.settings.gitAutoRefreshEnabled !== false} + onSetGitAutoRefreshEnabled={(enabled) => void api.setGitAutoRefreshEnabled(enabled)} onOpenAppDataFolder={() => void api.openAppDataFolder()} onResetLocalWorkspace={async () => { const fresh = await api.resetLocalWorkspace(); diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 8ab2b68..867dfb0 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -298,17 +298,34 @@ export function ChatPane({ {isScratchpad ? `Scratchpad · ${pattern.name}` : `${project.name} · ${pattern.name} · ${pattern.mode}`} - {!isScratchpad && project.git?.status === 'ready' && ( - - - {project.git.branch ?? project.git.head?.shortHash ?? 'HEAD'} - {project.git.isDirty && ( - - )} - {(project.git.ahead ?? 0) > 0 && ↑{project.git.ahead}} - {(project.git.behind ?? 0) > 0 && ↓{project.git.behind}} - - )} + {!isScratchpad && project.git?.status === 'ready' && (() => { + const git = project.git; + const tipLines: string[] = [git.branch ?? git.head?.shortHash ?? 'HEAD']; + if (git.changes) { + const bd: string[] = []; + if (git.changes.staged > 0) bd.push(`${git.changes.staged} staged`); + if (git.changes.unstaged > 0) bd.push(`${git.changes.unstaged} modified`); + if (git.changes.untracked > 0) bd.push(`${git.changes.untracked} untracked`); + if (bd.length > 0) tipLines.push(bd.join(', ')); + } + if (git.ahead || git.behind) { + const sync: string[] = []; + if (git.ahead) sync.push(`${git.ahead} ahead`); + if (git.behind) sync.push(`${git.behind} behind`); + tipLines.push(sync.join(', ')); + } + return ( + + + {git.branch ?? git.head?.shortHash ?? 'HEAD'} + {git.isDirty && ( + + )} + {(git.ahead ?? 0) > 0 && ↑{git.ahead}} + {(git.behind ?? 0) > 0 && ↓{git.behind}} + + ); + })()}

diff --git a/src/renderer/components/RunTimeline.tsx b/src/renderer/components/RunTimeline.tsx index ede1c65..f084591 100644 --- a/src/renderer/components/RunTimeline.tsx +++ b/src/renderer/components/RunTimeline.tsx @@ -8,6 +8,7 @@ import { ChevronDown, ChevronRight, CircleDot, + GitBranch, MessageSquare, Play, Wrench, @@ -25,6 +26,7 @@ import { type CollapsedTimelineEvent, } from '@renderer/lib/runTimelineFormatting'; import type { OrchestrationMode } from '@shared/domain/pattern'; +import type { ProjectGitWorkingTreeSnapshot } from '@shared/domain/project'; import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline'; import { FileChangePreview } from '@renderer/components/chat/FileChangePreview'; @@ -229,6 +231,39 @@ function CollapsedEventRow({ return ; } +/* ── Git baseline snapshot ──────────────────────────────────── */ + +function RunGitBaseline({ snapshot }: { snapshot: ProjectGitWorkingTreeSnapshot }) { + const { branch, changes, changedFileCount } = snapshot; + + const parts: string[] = []; + if (changes.staged > 0) parts.push(`${changes.staged} staged`); + if (changes.unstaged > 0) parts.push(`${changes.unstaged} modified`); + if (changes.untracked > 0) parts.push(`${changes.untracked} untracked`); + if (changes.conflicted > 0) parts.push(`${changes.conflicted} conflicted`); + + return ( +
+ + {branch && {branch}} + {changedFileCount > 0 ? ( + <> + · + {changedFileCount} changed + {parts.length > 0 && ( + ({parts.join(', ')}) + )} + + ) : ( + <> + · + clean + + )} +
+ ); +} + /* ── Run card ──────────────────────────────────────────────── */ function RunCard({ @@ -294,6 +329,11 @@ function RunCard({
)} + {/* Git baseline */} + {run.preRunGitSnapshot && ( + + )} + {/* Timeline events */}
{collapsedEvents.map((item, index) => ( diff --git a/src/renderer/components/SettingsPanel.tsx b/src/renderer/components/SettingsPanel.tsx index 01d66de..ab9b862 100644 --- a/src/renderer/components/SettingsPanel.tsx +++ b/src/renderer/components/SettingsPanel.tsx @@ -46,6 +46,8 @@ interface SettingsPanelProps { onSetNotificationsEnabled: (enabled: boolean) => void; minimizeToTray: boolean; onSetMinimizeToTray: (enabled: boolean) => void; + gitAutoRefreshEnabled: boolean; + onSetGitAutoRefreshEnabled: (enabled: boolean) => void; onOpenAppDataFolder: () => void; onResetLocalWorkspace: () => Promise; onResolveUserDiscoveredTooling?: (serverIds: string[], resolution: 'accept' | 'dismiss') => void; @@ -129,6 +131,8 @@ export function SettingsPanel({ onSetNotificationsEnabled, minimizeToTray, onSetMinimizeToTray, + gitAutoRefreshEnabled, + onSetGitAutoRefreshEnabled, onOpenAppDataFolder, onResetLocalWorkspace, onResolveUserDiscoveredTooling, @@ -274,6 +278,8 @@ export function SettingsPanel({ onSetNotificationsEnabled={onSetNotificationsEnabled} minimizeToTray={minimizeToTray} onSetMinimizeToTray={onSetMinimizeToTray} + gitAutoRefreshEnabled={gitAutoRefreshEnabled} + onSetGitAutoRefreshEnabled={onSetGitAutoRefreshEnabled} /> )} {activeSection === 'connection' && ( @@ -338,6 +344,8 @@ function AppearanceSection({ onSetNotificationsEnabled, minimizeToTray, onSetMinimizeToTray, + gitAutoRefreshEnabled, + onSetGitAutoRefreshEnabled, }: { theme: AppearanceTheme; onSetTheme: (theme: AppearanceTheme) => void; @@ -345,6 +353,8 @@ function AppearanceSection({ onSetNotificationsEnabled: (enabled: boolean) => void; minimizeToTray: boolean; onSetMinimizeToTray: (enabled: boolean) => void; + gitAutoRefreshEnabled: boolean; + onSetGitAutoRefreshEnabled: (enabled: boolean) => void; }){ return (
@@ -434,6 +444,30 @@ function AppearanceSection({
+ + {/* Git */} +
+

Git

+

+ Control how Aryx monitors your repositories +

+
+ +
); } diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 8354e13..4678612 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -122,8 +122,25 @@ function GitContextBadge({ git }: { git: ProjectGitContext }) { if (git.ahead) parts.push(`↑${git.ahead}`); if (git.behind) parts.push(`↓${git.behind}`); + const tooltipLines: string[] = [branchLabel]; + if (git.changes) { + const breakdown: string[] = []; + if (git.changes.staged > 0) breakdown.push(`${git.changes.staged} staged`); + if (git.changes.unstaged > 0) breakdown.push(`${git.changes.unstaged} modified`); + if (git.changes.untracked > 0) breakdown.push(`${git.changes.untracked} untracked`); + if (git.changes.conflicted > 0) breakdown.push(`${git.changes.conflicted} conflicted`); + if (breakdown.length > 0) tooltipLines.push(breakdown.join(', ')); + } + if (git.ahead || git.behind) { + const sync: string[] = []; + if (git.ahead) sync.push(`${git.ahead} ahead`); + if (git.behind) sync.push(`${git.behind} behind`); + tooltipLines.push(sync.join(', ')); + } + if (git.upstream) tooltipLines.push(`→ ${git.upstream}`); + return ( - + {branchLabel} {git.isDirty && } diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 51ca33a..8177ce5 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -17,6 +17,7 @@ export const ipcChannels = { setTerminalHeight: 'settings:set-terminal-height', setNotificationsEnabled: 'settings:set-notifications-enabled', setMinimizeToTray: 'settings:set-minimize-to-tray', + setGitAutoRefreshEnabled: 'settings:set-git-auto-refresh-enabled', checkForUpdates: 'app:check-for-updates', installUpdate: 'app:install-update', saveMcpServer: 'tooling:mcp:save', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index c30d47e..faf884d 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -241,6 +241,7 @@ export interface ElectronApi { setTerminalHeight(input: SetTerminalHeightInput): Promise; setNotificationsEnabled(enabled: boolean): Promise; setMinimizeToTray(enabled: boolean): Promise; + setGitAutoRefreshEnabled(enabled: boolean): Promise; checkForUpdates(): Promise; installUpdate(): Promise; describeTerminal(): Promise; diff --git a/src/shared/domain/project.ts b/src/shared/domain/project.ts index 608253a..38d7dcb 100644 --- a/src/shared/domain/project.ts +++ b/src/shared/domain/project.ts @@ -18,6 +18,33 @@ export interface ProjectGitCommitSummary { committedAt: string; } +export type ProjectGitWorkingTreeFileStatus = + | 'added' + | 'modified' + | 'deleted' + | 'renamed' + | 'copied' + | 'type-changed' + | 'unmerged' + | 'untracked'; + +export interface ProjectGitWorkingTreeFile { + path: string; + previousPath?: string; + stagedStatus?: ProjectGitWorkingTreeFileStatus; + unstagedStatus?: ProjectGitWorkingTreeFileStatus; + isConflicted?: boolean; +} + +export interface ProjectGitWorkingTreeSnapshot { + scannedAt: string; + repoRoot: string; + branch?: string; + changedFileCount: number; + changes: ProjectGitChangeSummary; + files: ProjectGitWorkingTreeFile[]; +} + export interface ProjectGitContext { status: ProjectGitContextStatus; scannedAt: string; diff --git a/src/shared/domain/runTimeline.ts b/src/shared/domain/runTimeline.ts index 4abbd35..7cf1f6a 100644 --- a/src/shared/domain/runTimeline.ts +++ b/src/shared/domain/runTimeline.ts @@ -5,7 +5,13 @@ import type { } from '@shared/domain/approval'; import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar'; import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; -import type { ProjectRecord } from '@shared/domain/project'; +import type { + ProjectGitChangeSummary, + ProjectGitWorkingTreeFile, + ProjectGitWorkingTreeFileStatus, + ProjectGitWorkingTreeSnapshot, + ProjectRecord, +} from '@shared/domain/project'; import { createId } from '@shared/utils/ids'; export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error'; @@ -70,6 +76,7 @@ export interface SessionRunRecord { status: SessionRunStatus; agents: RunTimelineAgentRecord[]; events: RunTimelineEventRecord[]; + preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot; } export interface CreateSessionRunRecordInput { @@ -79,6 +86,7 @@ export interface CreateSessionRunRecordInput { pattern: Pick; triggerMessageId: string; startedAt: string; + preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot; } export interface AppendRunActivityEventInput { @@ -122,6 +130,89 @@ function normalizeOptionalPreviewText(value: string | undefined): string | undef return value?.trim() ? value : undefined; } +function normalizeNonNegativeInteger(value: number | undefined): number { + if (typeof value !== 'number' || !Number.isFinite(value)) { + return 0; + } + + const normalized = Math.round(value); + return normalized >= 0 ? normalized : 0; +} + +function normalizeWorkingTreeFileStatus( + value: ProjectGitWorkingTreeFileStatus | undefined, +): ProjectGitWorkingTreeFileStatus | undefined { + switch (value) { + case 'added': + case 'modified': + case 'deleted': + case 'renamed': + case 'copied': + case 'type-changed': + case 'unmerged': + case 'untracked': + return value; + default: + return undefined; + } +} + +function normalizeWorkingTreeChangeSummary( + summary: Partial | undefined, +): ProjectGitChangeSummary { + return { + staged: normalizeNonNegativeInteger(summary?.staged), + unstaged: normalizeNonNegativeInteger(summary?.unstaged), + untracked: normalizeNonNegativeInteger(summary?.untracked), + conflicted: normalizeNonNegativeInteger(summary?.conflicted), + }; +} + +function normalizeWorkingTreeFile( + file: ProjectGitWorkingTreeFile, +): ProjectGitWorkingTreeFile | undefined { + const path = normalizeOptionalString(file.path); + if (!path) { + return undefined; + } + + return { + path, + previousPath: normalizeOptionalString(file.previousPath), + stagedStatus: normalizeWorkingTreeFileStatus(file.stagedStatus), + unstagedStatus: normalizeWorkingTreeFileStatus(file.unstagedStatus), + ...(file.isConflicted ? { isConflicted: true } : {}), + }; +} + +function normalizeWorkingTreeSnapshot( + snapshot: ProjectGitWorkingTreeSnapshot | undefined, +): ProjectGitWorkingTreeSnapshot | undefined { + if (!snapshot) { + return undefined; + } + + const scannedAt = normalizeOptionalString(snapshot.scannedAt); + const repoRoot = normalizeOptionalString(snapshot.repoRoot); + if (!scannedAt || !repoRoot) { + return undefined; + } + + const files = (snapshot.files ?? []).flatMap((file) => { + const normalized = normalizeWorkingTreeFile(file); + return normalized ? [normalized] : []; + }); + + return { + scannedAt, + repoRoot, + branch: normalizeOptionalString(snapshot.branch), + changedFileCount: normalizeNonNegativeInteger(snapshot.changedFileCount), + changes: normalizeWorkingTreeChangeSummary(snapshot.changes), + files, + }; +} + function normalizeToolCallFileChange( change: ToolCallFileChangePreview, ): ToolCallFileChangePreview | undefined { @@ -373,6 +464,7 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess startedAt: input.startedAt, status: 'running', completedAt: undefined, + preRunGitSnapshot: normalizeWorkingTreeSnapshot(input.preRunGitSnapshot), agents: input.pattern.agents .map((agent): RunTimelineAgentRecord => ({ agentId: agent.id, @@ -430,6 +522,7 @@ export function normalizeSessionRunRecords( startedAt, completedAt: normalizeOptionalString(run.completedAt), status: run.status === 'error' ? 'error' : run.status === 'running' ? 'running' : run.status === 'cancelled' ? 'cancelled' : 'completed', + preRunGitSnapshot: normalizeWorkingTreeSnapshot(run.preRunGitSnapshot), agents: run.agents.flatMap((agent) => { const normalized = normalizeRunTimelineAgent(agent); return normalized ? [normalized] : []; diff --git a/src/shared/domain/tooling.ts b/src/shared/domain/tooling.ts index 91f006a..5672cf9 100644 --- a/src/shared/domain/tooling.ts +++ b/src/shared/domain/tooling.ts @@ -66,6 +66,7 @@ export interface WorkspaceSettings { terminalHeight?: number; notificationsEnabled?: boolean; minimizeToTray?: boolean; + gitAutoRefreshEnabled?: boolean; } export interface SessionToolingSelection { @@ -209,6 +210,7 @@ export function normalizeWorkspaceSettings(settings?: Partial ...(terminalHeight !== undefined ? { terminalHeight } : {}), ...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}), ...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}), + ...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}), }; } diff --git a/tests/main/appServiceGitRefresh.test.ts b/tests/main/appServiceGitRefresh.test.ts new file mode 100644 index 0000000..46e465a --- /dev/null +++ b/tests/main/appServiceGitRefresh.test.ts @@ -0,0 +1,302 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import type { RunTurnCommand } from '@shared/contracts/sidecar'; +import type { PatternDefinition } from '@shared/domain/pattern'; +import type { ProjectGitWorkingTreeSnapshot, ProjectRecord } from '@shared/domain/project'; +import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project'; +import type { SessionRecord } from '@shared/domain/session'; +import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; + +const TIMESTAMP = '2026-03-31T00:00:00.000Z'; + +mock.module('electron', () => { + const electronMock = { + app: { + isPackaged: false, + getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx', + getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures', + }, + dialog: { + showOpenDialog: async () => ({ canceled: true, filePaths: [] }), + }, + shell: { + openPath: async () => '', + }, + }; + + return { + ...electronMock, + default: electronMock, + }; +}); + +mock.module('keytar', () => ({ + default: { + getPassword: async () => null, + setPassword: async () => undefined, + deletePassword: async () => false, + }, +})); + +const { AryxAppService } = await import('@main/AryxAppService'); + +function createProject(overrides?: Partial): ProjectRecord { + return { + id: 'project-alpha', + name: 'alpha', + path: 'C:\\workspace\\alpha', + addedAt: TIMESTAMP, + git: { + status: 'ready', + scannedAt: TIMESTAMP, + repoRoot: 'C:\\workspace\\alpha', + branch: 'main', + isDirty: false, + changedFileCount: 0, + changes: { + staged: 0, + unstaged: 0, + untracked: 0, + conflicted: 0, + }, + }, + ...overrides, + }; +} + +function createSession(projectId: string, patternId: string, overrides?: Partial): SessionRecord { + return { + id: 'session-alpha', + projectId, + patternId, + title: 'Alpha session', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + status: 'idle', + messages: [], + runs: [], + ...overrides, + }; +} + +function createFixture(overrides?: { + project?: Partial; + session?: Partial; +}): { + workspace: WorkspaceState; + pattern: PatternDefinition; + project: ProjectRecord; + session: SessionRecord; +} { + const workspace = createWorkspaceSeed(); + const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single'); + if (!pattern) { + throw new Error('Expected the workspace seed to include a single-agent pattern.'); + } + + const project = createProject(overrides?.project); + const session = createSession(project.id, pattern.id, overrides?.session); + + workspace.projects = [project]; + workspace.sessions = [session]; + workspace.selectedProjectId = project.id; + workspace.selectedPatternId = pattern.id; + workspace.selectedSessionId = session.id; + + return { workspace, pattern, project, session }; +} + +function createSnapshot(): ProjectGitWorkingTreeSnapshot { + return { + scannedAt: TIMESTAMP, + repoRoot: 'C:\\workspace\\alpha', + branch: 'main', + changedFileCount: 2, + changes: { + staged: 1, + unstaged: 1, + untracked: 0, + conflicted: 0, + }, + files: [ + { + path: 'src\\auth.ts', + stagedStatus: 'modified', + }, + { + path: 'tests\\auth.test.ts', + unstagedStatus: 'modified', + }, + ], + }; +} + +function createService( + workspace: WorkspaceState, + pattern: PatternDefinition, + options?: { + snapshot?: ProjectGitWorkingTreeSnapshot; + onCaptureSnapshot?: (projectPath: string, scannedAt: string) => void; + onScheduleRefresh?: (projectId?: string) => void; + runTurn?: (command: RunTurnCommand) => Promise<[]>; + }, +): InstanceType { + const service = new AryxAppService(); + const internals = service as unknown as Record; + internals.loadWorkspace = async () => { + internals.workspace = workspace; + return workspace; + }; + internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace; + internals.buildEffectivePattern = async () => pattern; + internals.awaitFinalResponseApproval = async () => undefined; + internals.finalizeTurn = () => undefined; + internals.emitSessionEvent = () => undefined; + internals.pruneUnavailableApprovalTools = async () => false; + internals.pruneUnavailableSessionToolingSelections = () => false; + internals.scheduleProjectGitRefresh = (projectId?: string) => { + options?.onScheduleRefresh?.(projectId); + }; + + ( + service as unknown as { + sidecar: { + runTurn: (command: RunTurnCommand) => Promise<[]>; + resolveApproval: () => Promise; + resolveUserInput: () => Promise; + }; + gitService: { + captureWorkingTreeSnapshot: ( + projectPath: string, + scannedAt: string, + ) => Promise; + }; + } + ).sidecar = { + runTurn: async (command) => options?.runTurn ? options.runTurn(command) : [], + resolveApproval: async () => undefined, + resolveUserInput: async () => undefined, + }; + ( + service as unknown as { + gitService: { + captureWorkingTreeSnapshot: ( + projectPath: string, + scannedAt: string, + ) => Promise; + }; + } + ).gitService = { + captureWorkingTreeSnapshot: async (projectPath, scannedAt) => { + options?.onCaptureSnapshot?.(projectPath, scannedAt); + return options?.snapshot; + }, + }; + + return service; +} + +describe('AryxAppService git refresh integration', () => { + test('sendSessionMessage stores a pre-run git snapshot on the created run', async () => { + const { workspace, pattern, session } = createFixture(); + const snapshot = createSnapshot(); + const capturedProjectPaths: string[] = []; + const service = createService(workspace, pattern, { + snapshot, + onCaptureSnapshot: (projectPath) => { + capturedProjectPaths.push(projectPath); + }, + }); + + await service.sendSessionMessage(session.id, 'Implement auth hardening.'); + + expect(capturedProjectPaths).toEqual(['C:\\workspace\\alpha']); + expect(workspace.sessions[0]?.runs[0]?.preRunGitSnapshot).toEqual(snapshot); + }); + + test('sendSessionMessage schedules a git refresh after a successful project turn', async () => { + const { workspace, pattern, project, session } = createFixture(); + const scheduledProjectIds: Array = []; + const service = createService(workspace, pattern, { + snapshot: createSnapshot(), + onScheduleRefresh: (projectId) => { + scheduledProjectIds.push(projectId); + }, + }); + + await service.sendSessionMessage(session.id, 'Implement auth hardening.'); + + expect(scheduledProjectIds).toEqual([project.id]); + }); + + test('sendSessionMessage schedules a git refresh after a failed project turn', async () => { + const { workspace, pattern, project, session } = createFixture(); + const scheduledProjectIds: Array = []; + const service = createService(workspace, pattern, { + snapshot: createSnapshot(), + onScheduleRefresh: (projectId) => { + scheduledProjectIds.push(projectId); + }, + runTurn: async () => { + throw new Error('boom'); + }, + }); + + await service.sendSessionMessage(session.id, 'Implement auth hardening.'); + + expect(scheduledProjectIds).toEqual([project.id]); + expect(session.status).toBe('error'); + expect(session.lastError).toBe('boom'); + expect(session.runs[0]?.status).toBe('error'); + }); + + test('scratchpad turns skip git snapshot capture and refresh scheduling', async () => { + const { workspace, pattern, session } = createFixture({ + project: { + id: SCRATCHPAD_PROJECT_ID, + name: 'Scratchpad', + path: 'C:\\workspace\\scratchpad', + git: undefined, + }, + session: { + projectId: SCRATCHPAD_PROJECT_ID, + }, + }); + let didCaptureSnapshot = false; + const scheduledProjectIds: Array = []; + const service = createService(workspace, pattern, { + snapshot: createSnapshot(), + onCaptureSnapshot: () => { + didCaptureSnapshot = true; + }, + onScheduleRefresh: (projectId) => { + scheduledProjectIds.push(projectId); + }, + }); + + await service.sendSessionMessage(session.id, 'Draft a quick note.'); + + expect(didCaptureSnapshot).toBe(false); + expect(scheduledProjectIds).toEqual([]); + expect(workspace.sessions[0]?.runs[0]?.preRunGitSnapshot).toBeUndefined(); + }); + + test('setGitAutoRefreshEnabled persists the setting and returns updated workspace', async () => { + const { workspace, pattern } = createFixture(); + const service = createService(workspace, pattern); + + expect(service.isGitAutoRefreshEnabled()).toBe(true); + + const result = await service.setGitAutoRefreshEnabled(false); + expect(result.settings.gitAutoRefreshEnabled).toBe(false); + expect(service.isGitAutoRefreshEnabled()).toBe(false); + }); + + test('isGitAutoRefreshEnabled defaults to true when setting is undefined', async () => { + const { workspace, pattern } = createFixture(); + const service = createService(workspace, pattern); + + expect(workspace.settings.gitAutoRefreshEnabled).toBeUndefined(); + expect(service.isGitAutoRefreshEnabled()).toBe(true); + }); +}); diff --git a/tests/main/gitService.test.ts b/tests/main/gitService.test.ts index 320cedf..8d5d9f3 100644 --- a/tests/main/gitService.test.ts +++ b/tests/main/gitService.test.ts @@ -121,4 +121,58 @@ describe('GitService', () => { head: undefined, }); }); + + test('captures a structured pre-run working tree snapshot', async () => { + const service = createService({ + 'rev-parse --show-toplevel': 'C:\\workspace\\repo\n', + 'status --porcelain=1 --untracked-files=all': 'R src\\old.ts -> src\\new.ts\n M src\\app.ts\n?? notes.txt\nUU conflict.txt\n', + 'branch --show-current': 'feature/git-context\n', + }); + + await expect(service.captureWorkingTreeSnapshot('C:\\workspace\\repo', '2026-03-23T19:00:00.000Z')).resolves.toEqual({ + scannedAt: '2026-03-23T19:00:00.000Z', + repoRoot: 'C:\\workspace\\repo', + branch: 'feature/git-context', + changedFileCount: 4, + changes: { + staged: 1, + unstaged: 1, + untracked: 1, + conflicted: 1, + }, + files: [ + { + path: 'src\\new.ts', + previousPath: 'src\\old.ts', + stagedStatus: 'renamed', + unstagedStatus: undefined, + }, + { + path: 'src\\app.ts', + stagedStatus: undefined, + unstagedStatus: 'modified', + }, + { + path: 'notes.txt', + unstagedStatus: 'untracked', + }, + { + path: 'conflict.txt', + stagedStatus: 'unmerged', + unstagedStatus: 'unmerged', + isConflicted: true, + }, + ], + }); + }); + + test('returns no working tree snapshot outside a git repository', async () => { + const service = createService({ + 'rev-parse --show-toplevel': createGitError('fatal: not a git repository', { + stderr: 'fatal: not a git repository (or any of the parent directories): .git', + }), + }); + + await expect(service.captureWorkingTreeSnapshot('C:\\workspace\\not-a-repo', '2026-03-23T19:00:00.000Z')).resolves.toBeUndefined(); + }); });