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
+27
View File
@@ -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;
+94 -1
View File
@@ -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<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
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<ProjectGitChangeSummary> | 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] : [];
+2
View File
@@ -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<WorkspaceSettings>
...(terminalHeight !== undefined ? { terminalHeight } : {}),
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}),
};
}