feat: add git-aware project context

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-23 20:11:46 +01:00
co-authored by Copilot
parent 8d140b6972
commit ab4e9bcea9
10 changed files with 701 additions and 0 deletions
+40
View File
@@ -43,6 +43,7 @@ import { mergeStreamingText } from '@shared/utils/streamingText';
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
import { SecretStore } from '@main/secrets/secretStore';
import { SidecarClient } from '@main/sidecar/sidecarProcess';
import { GitService } from '@main/git/gitService';
type AppServiceEvents = {
'workspace-updated': [WorkspaceState];
@@ -57,8 +58,10 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
private readonly workspaceRepository = new WorkspaceRepository();
private readonly sidecar = new SidecarClient();
private readonly secretStore = new SecretStore();
private readonly gitService = new GitService();
private workspace?: WorkspaceState;
private sidecarCapabilities?: SidecarCapabilities;
private didScheduleInitialProjectGitRefresh = false;
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
return this.loadSidecarCapabilities();
@@ -73,6 +76,13 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
this.workspace = await this.workspaceRepository.load();
}
if (!this.didScheduleInitialProjectGitRefresh) {
this.didScheduleInitialProjectGitRefresh = true;
void this.refreshProjectGitContext().catch((error) => {
console.error('[kopaya git]', error);
});
}
return this.workspace;
}
@@ -104,6 +114,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
name: basename(folderPath),
path: folderPath,
addedAt: nowIso(),
git: await this.gitService.describeProject(folderPath),
};
workspace.projects.push(project);
@@ -360,6 +371,21 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
return queryWorkspaceSessions(workspace, input);
}
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const projects = projectId
? [this.requireProject(workspace, projectId)]
: workspace.projects;
let changed = false;
for (const project of projects) {
const projectChanged = await this.refreshGitContextForProject(project);
changed = projectChanged || changed;
}
return changed ? this.persistAndBroadcast(workspace) : workspace;
}
async selectProject(projectId?: string): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
workspace.selectedProjectId = projectId;
@@ -389,6 +415,20 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
return project;
}
private async refreshGitContextForProject(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.git) {
return false;
}
project.git = undefined;
return true;
}
project.git = await this.gitService.describeProject(project.path);
return true;
}
private requirePattern(workspace: WorkspaceState, patternId: string): PatternDefinition {
const pattern = workspace.patterns.find((current) => current.id === patternId);
if (!pattern) {
+250
View File
@@ -0,0 +1,250 @@
import { execFile, type ExecFileException } from 'node:child_process';
import { promisify } from 'node:util';
import type { ProjectGitChangeSummary, ProjectGitCommitSummary, ProjectGitContext } from '@shared/domain/project';
import { nowIso } from '@shared/utils/ids';
const execFileAsync = promisify(execFile);
const GIT_TIMEOUT_MS = 5_000;
type GitCommandRunner = (projectPath: string, args: string[]) => Promise<string>;
type GitCommandResult =
| { ok: true; stdout: string }
| { ok: false; error: GitCommandFailure };
class GitCommandFailure extends Error {
readonly code?: number | string;
readonly stderr?: string;
constructor(message: string, options?: { code?: number | string; stderr?: string; cause?: unknown }) {
super(message, options?.cause ? { cause: options.cause } : undefined);
this.name = 'GitCommandFailure';
this.code = options?.code;
this.stderr = options?.stderr;
}
}
function createGitCommandFailure(
projectPath: string,
args: string[],
error: unknown,
): GitCommandFailure {
if (error instanceof GitCommandFailure) {
return error;
}
const execError = error as ExecFileException & { stderr?: string };
const command = `git -C "${projectPath}" ${args.join(' ')}`.trim();
const stderr = typeof execError?.stderr === 'string' ? execError.stderr.trim() : undefined;
const message = stderr || execError?.message || `Git command failed: ${command}`;
return new GitCommandFailure(message, {
code: execError?.code ?? undefined,
stderr,
cause: error,
});
}
async function defaultGitCommandRunner(projectPath: string, args: string[]): Promise<string> {
try {
const { stdout } = await execFileAsync('git', ['-C', projectPath, ...args], {
encoding: 'utf8',
timeout: GIT_TIMEOUT_MS,
windowsHide: true,
});
return stdout;
} catch (error) {
throw createGitCommandFailure(projectPath, args, error);
}
}
function isGitMissing(error: GitCommandFailure): boolean {
return error.code === 'ENOENT';
}
function isNotRepository(error: GitCommandFailure): boolean {
const detail = `${error.message}\n${error.stderr ?? ''}`.toLowerCase();
return detail.includes('not a git repository');
}
function summarizeGitFailure(error: GitCommandFailure): string {
return error.stderr?.trim() || error.message;
}
function parseBranch(stdout: string): string | undefined {
const branch = stdout.trim();
return branch || undefined;
}
function parseAheadBehind(stdout: string): Pick<ProjectGitContext, 'ahead' | 'behind'> {
const [behindValue, aheadValue] = stdout.trim().split(/\s+/);
const behind = Number.parseInt(behindValue ?? '', 10);
const ahead = Number.parseInt(aheadValue ?? '', 10);
return {
ahead: Number.isFinite(ahead) ? ahead : undefined,
behind: Number.isFinite(behind) ? behind : undefined,
};
}
function isConflictedStatus(x: string, y: string): boolean {
return (
x === 'U'
|| y === 'U'
|| (x === 'A' && y === 'A')
|| (x === 'D' && y === 'D')
);
}
function parseChangeSummary(stdout: string): {
changedFileCount: number;
changes: ProjectGitChangeSummary;
} {
const summary: ProjectGitChangeSummary = {
staged: 0,
unstaged: 0,
untracked: 0,
conflicted: 0,
};
const lines = stdout
.split(/\r?\n/)
.map((line) => line.trimEnd())
.filter(Boolean);
let changedFileCount = 0;
for (const line of lines) {
if (line.startsWith('??')) {
summary.untracked += 1;
changedFileCount += 1;
continue;
}
if (line.length < 2) {
continue;
}
const x = line[0];
const y = line[1];
changedFileCount += 1;
if (isConflictedStatus(x, y)) {
summary.conflicted += 1;
continue;
}
if (x !== ' ') {
summary.staged += 1;
}
if (y !== ' ') {
summary.unstaged += 1;
}
}
return {
changedFileCount,
changes: summary,
};
}
function parseHead(stdout: string): ProjectGitCommitSummary | undefined {
const [hash, shortHash, subject, committedAt] = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
if (!hash || !shortHash || !subject || !committedAt) {
return undefined;
}
return {
hash,
shortHash,
subject,
committedAt,
};
}
export class GitService {
constructor(private readonly runGitCommand: GitCommandRunner = defaultGitCommandRunner) {}
async describeProject(projectPath: string, scannedAt = nowIso()): Promise<ProjectGitContext> {
const repoRootResult = await this.tryRun(projectPath, ['rev-parse', '--show-toplevel']);
if (!repoRootResult.ok) {
if (isGitMissing(repoRootResult.error)) {
return {
status: 'git-missing',
scannedAt,
errorMessage: 'Git is not installed or is not available on PATH.',
};
}
if (isNotRepository(repoRootResult.error)) {
return {
status: 'not-repository',
scannedAt,
};
}
return {
status: 'error',
scannedAt,
errorMessage: summarizeGitFailure(repoRootResult.error),
};
}
const repoRoot = repoRootResult.stdout.trim();
const statusResult = await this.tryRun(projectPath, ['status', '--porcelain=1', '--untracked-files=all']);
if (!statusResult.ok) {
return {
status: 'error',
scannedAt,
repoRoot,
errorMessage: summarizeGitFailure(statusResult.error),
};
}
const [branchResult, upstreamResult, countsResult, headResult] = await Promise.all([
this.tryRun(projectPath, ['branch', '--show-current']),
this.tryRun(projectPath, ['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']),
this.tryRun(projectPath, ['rev-list', '--left-right', '--count', '@{upstream}...HEAD']),
this.tryRun(projectPath, ['log', '-1', '--format=%H%n%h%n%s%n%cI']),
]);
const { changedFileCount, changes } = parseChangeSummary(statusResult.stdout);
const upstream = upstreamResult.ok ? upstreamResult.stdout.trim() || undefined : undefined;
const aheadBehind = countsResult.ok ? parseAheadBehind(countsResult.stdout) : {};
return {
status: 'ready',
scannedAt,
repoRoot,
branch: branchResult.ok ? parseBranch(branchResult.stdout) : undefined,
upstream,
ahead: upstream ? aheadBehind.ahead : undefined,
behind: upstream ? aheadBehind.behind : undefined,
isDirty: changedFileCount > 0,
changedFileCount,
changes,
head: headResult.ok ? parseHead(headResult.stdout) : undefined,
};
}
private async tryRun(projectPath: string, args: string[]): Promise<GitCommandResult> {
try {
return {
ok: true,
stdout: await this.runGitCommand(projectPath, args),
};
} catch (error) {
return {
ok: false,
error: createGitCommandFailure(projectPath, args, error),
};
}
}
}
+3
View File
@@ -22,6 +22,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppSer
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
ipcMain.handle(ipcChannels.refreshProjectGitContext, (_event, projectId?: string) =>
service.refreshProjectGitContext(projectId),
);
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
+1
View File
@@ -9,6 +9,7 @@ const api: ElectronApi = {
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId),
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
+1
View File
@@ -4,6 +4,7 @@ export const ipcChannels = {
loadWorkspace: 'workspace:load',
addProject: 'workspace:add-project',
removeProject: 'workspace:remove-project',
refreshProjectGitContext: 'projects:refresh-git-context',
savePattern: 'patterns:save',
deletePattern: 'patterns:delete',
setPatternFavorite: 'patterns:set-favorite',
+1
View File
@@ -55,6 +55,7 @@ export interface ElectronApi {
loadWorkspace(): Promise<WorkspaceState>;
addProject(): Promise<WorkspaceState>;
removeProject(projectId: string): Promise<WorkspaceState>;
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
deletePattern(patternId: string): Promise<WorkspaceState>;
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
+32
View File
@@ -1,10 +1,42 @@
import { nowIso } from '@shared/utils/ids';
export type ProjectGitContextStatus = 'ready' | 'not-repository' | 'git-missing' | 'error';
export interface ProjectGitChangeSummary {
staged: number;
unstaged: number;
untracked: number;
conflicted: number;
}
export interface ProjectGitCommitSummary {
hash: string;
shortHash: string;
subject: string;
committedAt: string;
}
export interface ProjectGitContext {
status: ProjectGitContextStatus;
scannedAt: string;
repoRoot?: string;
branch?: string;
upstream?: string;
ahead?: number;
behind?: number;
isDirty?: boolean;
changedFileCount?: number;
changes?: ProjectGitChangeSummary;
head?: ProjectGitCommitSummary;
errorMessage?: string;
}
export interface ProjectRecord {
id: string;
name: string;
path: string;
addedAt: string;
git?: ProjectGitContext;
}
export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad';