mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-08 20:58:45 +02:00
feat: add git-aware project context
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+204
@@ -0,0 +1,204 @@
|
|||||||
|
# Git-aware context UX handover
|
||||||
|
|
||||||
|
This document covers the frontend/UX work that can now be built on top of the newly implemented backend/shared git-context support.
|
||||||
|
|
||||||
|
## What was implemented in backend/shared
|
||||||
|
|
||||||
|
The app now persists and refreshes per-project git context on `ProjectRecord`.
|
||||||
|
|
||||||
|
### New shared project shape
|
||||||
|
|
||||||
|
File: `src/shared/domain/project.ts`
|
||||||
|
|
||||||
|
`ProjectRecord` now has:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
git?: ProjectGitContext;
|
||||||
|
```
|
||||||
|
|
||||||
|
`ProjectGitContext` shape:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
type ProjectGitContextStatus = 'ready' | 'not-repository' | 'git-missing' | 'error';
|
||||||
|
|
||||||
|
interface ProjectGitContext {
|
||||||
|
status: ProjectGitContextStatus;
|
||||||
|
scannedAt: string;
|
||||||
|
repoRoot?: string;
|
||||||
|
branch?: string;
|
||||||
|
upstream?: string;
|
||||||
|
ahead?: number;
|
||||||
|
behind?: number;
|
||||||
|
isDirty?: boolean;
|
||||||
|
changedFileCount?: number;
|
||||||
|
changes?: {
|
||||||
|
staged: number;
|
||||||
|
unstaged: number;
|
||||||
|
untracked: number;
|
||||||
|
conflicted: number;
|
||||||
|
};
|
||||||
|
head?: {
|
||||||
|
hash: string;
|
||||||
|
shortHash: string;
|
||||||
|
subject: string;
|
||||||
|
committedAt: string;
|
||||||
|
};
|
||||||
|
errorMessage?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### New backend behavior
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `src/main/git/gitService.ts`
|
||||||
|
- `src/main/KopayaAppService.ts`
|
||||||
|
|
||||||
|
Behavior:
|
||||||
|
|
||||||
|
- when a project is added, git context is fetched immediately
|
||||||
|
- when the workspace loads, the main process schedules an initial git-context refresh for all projects
|
||||||
|
- scratchpad projects intentionally do not carry git context
|
||||||
|
- non-repo folders are represented explicitly with `status: 'not-repository'`
|
||||||
|
- missing system git is represented explicitly with `status: 'git-missing'`
|
||||||
|
- unexpected git failures are represented with `status: 'error'` plus `errorMessage`
|
||||||
|
|
||||||
|
### New Electron API
|
||||||
|
|
||||||
|
Files:
|
||||||
|
|
||||||
|
- `src/shared/contracts/channels.ts`
|
||||||
|
- `src/shared/contracts/ipc.ts`
|
||||||
|
- `src/main/ipc/registerIpcHandlers.ts`
|
||||||
|
- `src/preload/index.ts`
|
||||||
|
|
||||||
|
New API:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
|
||||||
|
```
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
|
||||||
|
- call with a `projectId` to refresh a single project
|
||||||
|
- call with no argument to refresh all projects
|
||||||
|
- the renderer also receives refreshed data through the existing `onWorkspaceUpdated(...)` subscription
|
||||||
|
|
||||||
|
## Important UX constraints and current backend semantics
|
||||||
|
|
||||||
|
These are important so the frontend does not guess incorrectly:
|
||||||
|
|
||||||
|
- `project.git` is optional overall because scratchpad has no git context and older persisted workspaces may not have been refreshed yet
|
||||||
|
- `status: 'ready'` is the only state where branch/dirty/commit details should be assumed to exist
|
||||||
|
- `branch` may still be missing even when `status === 'ready'` (for example detached HEAD)
|
||||||
|
- `head` may be missing even when `status === 'ready'` (for example a repo with no commits yet)
|
||||||
|
- `upstream`, `ahead`, and `behind` are optional and should only be shown when present
|
||||||
|
- `changedFileCount` is a file-entry count, while `changes` breaks it down into staged / unstaged / untracked / conflicted
|
||||||
|
- this phase does **not** inject git context into sidecar run-turn payloads yet; this is app/backend UI support, not orchestration/runtime support
|
||||||
|
|
||||||
|
## Recommended UX surfaces
|
||||||
|
|
||||||
|
### 1. Sidebar project rows
|
||||||
|
|
||||||
|
Primary file: `src/renderer/components/Sidebar.tsx`
|
||||||
|
|
||||||
|
Current `ProjectGroup` already renders:
|
||||||
|
|
||||||
|
- project icon
|
||||||
|
- project name
|
||||||
|
- running session count
|
||||||
|
- session count
|
||||||
|
|
||||||
|
Recommended additions for non-scratchpad projects:
|
||||||
|
|
||||||
|
- branch badge next to the project name when `project.git?.status === 'ready'`
|
||||||
|
- subtle dirty indicator when `isDirty === true`
|
||||||
|
- changed file count badge when `changedFileCount > 0`
|
||||||
|
- small warning/error badge for:
|
||||||
|
- `not-repository`
|
||||||
|
- `git-missing`
|
||||||
|
- `error`
|
||||||
|
- refresh action for the selected or hovered project that calls `api.refreshProjectGitContext(project.id)`
|
||||||
|
|
||||||
|
Suggested behavior:
|
||||||
|
|
||||||
|
- keep the row compact; do not turn it into a full card
|
||||||
|
- prefer one-line metadata in the collapsed header and more detail in an expanded/hover area if needed
|
||||||
|
- scratchpad should remain visually distinct and should not show git affordances
|
||||||
|
|
||||||
|
### 2. Chat header / selected session context
|
||||||
|
|
||||||
|
Primary file: `src/renderer/components/ChatPane.tsx`
|
||||||
|
|
||||||
|
Current header already shows:
|
||||||
|
|
||||||
|
- project name
|
||||||
|
- pattern name
|
||||||
|
- pattern mode
|
||||||
|
|
||||||
|
Recommended additions for real projects:
|
||||||
|
|
||||||
|
- branch name beside the project title
|
||||||
|
- dirty state chip when the repo has pending changes
|
||||||
|
- optional compact summary like:
|
||||||
|
- `3 changed`
|
||||||
|
- `2 ahead`
|
||||||
|
- `1 behind`
|
||||||
|
- optional recent commit summary tooltip/popover using `project.git.head`
|
||||||
|
|
||||||
|
This is likely the best place to make git context feel relevant to the active conversation without overcrowding the sidebar.
|
||||||
|
|
||||||
|
### 3. Empty/welcome state
|
||||||
|
|
||||||
|
Primary file: `src/renderer/components/WelcomePane.tsx`
|
||||||
|
|
||||||
|
Optional but useful:
|
||||||
|
|
||||||
|
- update copy so project-backed sessions clearly imply branch/diff awareness
|
||||||
|
- if you add a project-management callout, mention that git state appears automatically for repositories
|
||||||
|
|
||||||
|
### 4. App-level wiring
|
||||||
|
|
||||||
|
Primary file: `src/renderer/App.tsx`
|
||||||
|
|
||||||
|
The frontend agent will likely need to thread a refresh callback into whichever component gets the refresh UI, for example:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
() => void api.refreshProjectGitContext(projectId)
|
||||||
|
```
|
||||||
|
|
||||||
|
No extra state container is required because the workspace subscription already updates the renderer.
|
||||||
|
|
||||||
|
## Suggested UX implementation order
|
||||||
|
|
||||||
|
1. Add a small git metadata presentation in `Sidebar.tsx`
|
||||||
|
2. Add selected-project git context in `ChatPane.tsx`
|
||||||
|
3. Wire refresh actions from `App.tsx`
|
||||||
|
4. Add polish for non-ready states (`not-repository`, `git-missing`, `error`)
|
||||||
|
|
||||||
|
## Edge cases the UX should handle
|
||||||
|
|
||||||
|
- scratchpad: no git UI at all
|
||||||
|
- project added outside a git repo: show a neutral non-repo state, not a scary error
|
||||||
|
- git missing on the machine: show a helpful message, not a broken state
|
||||||
|
- detached HEAD: do not assume `branch` exists; fall back to commit short hash if useful
|
||||||
|
- brand-new repo with no commits: `head` may be absent even though the repo is valid
|
||||||
|
- upstream not configured: do not show ahead/behind placeholders
|
||||||
|
- refresh while viewing the project: avoid layout jumps; optimistic spinners are fine
|
||||||
|
|
||||||
|
## Files most likely to change in the UX pass
|
||||||
|
|
||||||
|
- `src/renderer/App.tsx`
|
||||||
|
- `src/renderer/components/Sidebar.tsx`
|
||||||
|
- `src/renderer/components/ChatPane.tsx`
|
||||||
|
- `src/renderer/components/WelcomePane.tsx`
|
||||||
|
|
||||||
|
## Out of scope for this handover
|
||||||
|
|
||||||
|
These are intentionally **not** implemented yet in backend/runtime:
|
||||||
|
|
||||||
|
- passing git context into sidecar agent instructions
|
||||||
|
- git diff viewers or file-by-file change inspection
|
||||||
|
- branch switching or commit actions
|
||||||
|
- git-aware working sets / file pinning
|
||||||
|
- project summary generation beyond git metadata
|
||||||
@@ -43,6 +43,7 @@ import { mergeStreamingText } from '@shared/utils/streamingText';
|
|||||||
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
||||||
import { SecretStore } from '@main/secrets/secretStore';
|
import { SecretStore } from '@main/secrets/secretStore';
|
||||||
import { SidecarClient } from '@main/sidecar/sidecarProcess';
|
import { SidecarClient } from '@main/sidecar/sidecarProcess';
|
||||||
|
import { GitService } from '@main/git/gitService';
|
||||||
|
|
||||||
type AppServiceEvents = {
|
type AppServiceEvents = {
|
||||||
'workspace-updated': [WorkspaceState];
|
'workspace-updated': [WorkspaceState];
|
||||||
@@ -57,8 +58,10 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
private readonly workspaceRepository = new WorkspaceRepository();
|
private readonly workspaceRepository = new WorkspaceRepository();
|
||||||
private readonly sidecar = new SidecarClient();
|
private readonly sidecar = new SidecarClient();
|
||||||
private readonly secretStore = new SecretStore();
|
private readonly secretStore = new SecretStore();
|
||||||
|
private readonly gitService = new GitService();
|
||||||
private workspace?: WorkspaceState;
|
private workspace?: WorkspaceState;
|
||||||
private sidecarCapabilities?: SidecarCapabilities;
|
private sidecarCapabilities?: SidecarCapabilities;
|
||||||
|
private didScheduleInitialProjectGitRefresh = false;
|
||||||
|
|
||||||
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
|
async describeSidecarCapabilities(): Promise<SidecarCapabilities> {
|
||||||
return this.loadSidecarCapabilities();
|
return this.loadSidecarCapabilities();
|
||||||
@@ -73,6 +76,13 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
this.workspace = await this.workspaceRepository.load();
|
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;
|
return this.workspace;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,6 +114,7 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
name: basename(folderPath),
|
name: basename(folderPath),
|
||||||
path: folderPath,
|
path: folderPath,
|
||||||
addedAt: nowIso(),
|
addedAt: nowIso(),
|
||||||
|
git: await this.gitService.describeProject(folderPath),
|
||||||
};
|
};
|
||||||
|
|
||||||
workspace.projects.push(project);
|
workspace.projects.push(project);
|
||||||
@@ -360,6 +371,21 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return queryWorkspaceSessions(workspace, input);
|
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> {
|
async selectProject(projectId?: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
workspace.selectedProjectId = projectId;
|
workspace.selectedProjectId = projectId;
|
||||||
@@ -389,6 +415,20 @@ export class KopayaAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return project;
|
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 {
|
private requirePattern(workspace: WorkspaceState, patternId: string): PatternDefinition {
|
||||||
const pattern = workspace.patterns.find((current) => current.id === patternId);
|
const pattern = workspace.patterns.find((current) => current.id === patternId);
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -22,6 +22,9 @@ export function registerIpcHandlers(window: BrowserWindow, service: KopayaAppSer
|
|||||||
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
ipcMain.handle(ipcChannels.loadWorkspace, () => service.loadWorkspace());
|
||||||
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
ipcMain.handle(ipcChannels.addProject, () => service.addProject());
|
||||||
ipcMain.handle(ipcChannels.removeProject, (_event, projectId: string) => service.removeProject(projectId));
|
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.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
|
||||||
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
||||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const api: ElectronApi = {
|
|||||||
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
|
loadWorkspace: () => ipcRenderer.invoke(ipcChannels.loadWorkspace),
|
||||||
addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
|
addProject: () => ipcRenderer.invoke(ipcChannels.addProject),
|
||||||
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
|
removeProject: (projectId) => ipcRenderer.invoke(ipcChannels.removeProject, projectId),
|
||||||
|
refreshProjectGitContext: (projectId) => ipcRenderer.invoke(ipcChannels.refreshProjectGitContext, projectId),
|
||||||
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
||||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
||||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ export const ipcChannels = {
|
|||||||
loadWorkspace: 'workspace:load',
|
loadWorkspace: 'workspace:load',
|
||||||
addProject: 'workspace:add-project',
|
addProject: 'workspace:add-project',
|
||||||
removeProject: 'workspace:remove-project',
|
removeProject: 'workspace:remove-project',
|
||||||
|
refreshProjectGitContext: 'projects:refresh-git-context',
|
||||||
savePattern: 'patterns:save',
|
savePattern: 'patterns:save',
|
||||||
deletePattern: 'patterns:delete',
|
deletePattern: 'patterns:delete',
|
||||||
setPatternFavorite: 'patterns:set-favorite',
|
setPatternFavorite: 'patterns:set-favorite',
|
||||||
|
|||||||
@@ -55,6 +55,7 @@ export interface ElectronApi {
|
|||||||
loadWorkspace(): Promise<WorkspaceState>;
|
loadWorkspace(): Promise<WorkspaceState>;
|
||||||
addProject(): Promise<WorkspaceState>;
|
addProject(): Promise<WorkspaceState>;
|
||||||
removeProject(projectId: string): Promise<WorkspaceState>;
|
removeProject(projectId: string): Promise<WorkspaceState>;
|
||||||
|
refreshProjectGitContext(projectId?: string): Promise<WorkspaceState>;
|
||||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
||||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
deletePattern(patternId: string): Promise<WorkspaceState>;
|
||||||
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
createSession(input: CreateSessionInput): Promise<WorkspaceState>;
|
||||||
|
|||||||
@@ -1,10 +1,42 @@
|
|||||||
import { nowIso } from '@shared/utils/ids';
|
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 {
|
export interface ProjectRecord {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
path: string;
|
path: string;
|
||||||
addedAt: string;
|
addedAt: string;
|
||||||
|
git?: ProjectGitContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad';
|
export const SCRATCHPAD_PROJECT_ID = 'project-scratchpad';
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
|
import { GitService } from '@main/git/gitService';
|
||||||
|
|
||||||
|
function createGitError(message: string, options?: { code?: number | string; stderr?: string }) {
|
||||||
|
return Object.assign(new Error(message), options);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createService(responses: Record<string, string | Error>) {
|
||||||
|
return new GitService(async (_projectPath, args) => {
|
||||||
|
const key = args.join(' ');
|
||||||
|
const response = responses[key];
|
||||||
|
|
||||||
|
if (response === undefined) {
|
||||||
|
throw new Error(`Unexpected git command: ${key}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response instanceof Error) {
|
||||||
|
throw response;
|
||||||
|
}
|
||||||
|
|
||||||
|
return response;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('GitService', () => {
|
||||||
|
test('describes a git repository with branch, dirty state, change counts, and head commit summary', async () => {
|
||||||
|
const service = createService({
|
||||||
|
'rev-parse --show-toplevel': 'C:\\workspace\\repo\n',
|
||||||
|
'status --porcelain=1 --untracked-files=all': 'M README.md\n M src\\app.ts\n?? notes.txt\nUU conflict.txt\n',
|
||||||
|
'branch --show-current': 'feature/git-context\n',
|
||||||
|
'rev-parse --abbrev-ref --symbolic-full-name @{upstream}': 'origin/feature/git-context\n',
|
||||||
|
'rev-list --left-right --count @{upstream}...HEAD': '2\t1\n',
|
||||||
|
'log -1 --format=%H%n%h%n%s%n%cI': '0123456789abcdef\n0123456\nAdd git context plumbing\n2026-03-23T18:00:00+01:00\n',
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.describeProject('C:\\workspace\\repo', '2026-03-23T19:00:00.000Z')).resolves.toEqual({
|
||||||
|
status: 'ready',
|
||||||
|
scannedAt: '2026-03-23T19:00:00.000Z',
|
||||||
|
repoRoot: 'C:\\workspace\\repo',
|
||||||
|
branch: 'feature/git-context',
|
||||||
|
upstream: 'origin/feature/git-context',
|
||||||
|
ahead: 1,
|
||||||
|
behind: 2,
|
||||||
|
isDirty: true,
|
||||||
|
changedFileCount: 4,
|
||||||
|
changes: {
|
||||||
|
staged: 1,
|
||||||
|
unstaged: 1,
|
||||||
|
untracked: 1,
|
||||||
|
conflicted: 1,
|
||||||
|
},
|
||||||
|
head: {
|
||||||
|
hash: '0123456789abcdef',
|
||||||
|
shortHash: '0123456',
|
||||||
|
subject: 'Add git context plumbing',
|
||||||
|
committedAt: '2026-03-23T18:00:00+01:00',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports non-repository paths explicitly', 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.describeProject('C:\\workspace\\not-a-repo', '2026-03-23T19:00:00.000Z')).resolves.toEqual({
|
||||||
|
status: 'not-repository',
|
||||||
|
scannedAt: '2026-03-23T19:00:00.000Z',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('reports missing git binary explicitly', async () => {
|
||||||
|
const service = createService({
|
||||||
|
'rev-parse --show-toplevel': createGitError('spawn git ENOENT', {
|
||||||
|
code: 'ENOENT',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.describeProject('C:\\workspace\\repo', '2026-03-23T19:00:00.000Z')).resolves.toEqual({
|
||||||
|
status: 'git-missing',
|
||||||
|
scannedAt: '2026-03-23T19:00:00.000Z',
|
||||||
|
errorMessage: 'Git is not installed or is not available on PATH.',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('returns partial ready context when upstream or head commit are unavailable', async () => {
|
||||||
|
const service = createService({
|
||||||
|
'rev-parse --show-toplevel': 'C:\\workspace\\repo\n',
|
||||||
|
'status --porcelain=1 --untracked-files=all': '',
|
||||||
|
'branch --show-current': 'main\n',
|
||||||
|
'rev-parse --abbrev-ref --symbolic-full-name @{upstream}': createGitError('fatal: no upstream configured', {
|
||||||
|
stderr: 'fatal: no upstream configured for branch \'main\'',
|
||||||
|
}),
|
||||||
|
'rev-list --left-right --count @{upstream}...HEAD': createGitError('fatal: no upstream configured', {
|
||||||
|
stderr: 'fatal: no upstream configured for branch \'main\'',
|
||||||
|
}),
|
||||||
|
'log -1 --format=%H%n%h%n%s%n%cI': createGitError('fatal: your current branch does not have any commits yet', {
|
||||||
|
stderr: 'fatal: your current branch does not have any commits yet',
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(service.describeProject('C:\\workspace\\repo', '2026-03-23T19:00:00.000Z')).resolves.toEqual({
|
||||||
|
status: 'ready',
|
||||||
|
scannedAt: '2026-03-23T19:00:00.000Z',
|
||||||
|
repoRoot: 'C:\\workspace\\repo',
|
||||||
|
branch: 'main',
|
||||||
|
upstream: undefined,
|
||||||
|
ahead: undefined,
|
||||||
|
behind: undefined,
|
||||||
|
isDirty: false,
|
||||||
|
changedFileCount: 0,
|
||||||
|
changes: {
|
||||||
|
staged: 0,
|
||||||
|
unstaged: 0,
|
||||||
|
untracked: 0,
|
||||||
|
conflicted: 0,
|
||||||
|
},
|
||||||
|
head: undefined,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ describe('scratchpad project helpers', () => {
|
|||||||
expect(project.id).toBe(SCRATCHPAD_PROJECT_ID);
|
expect(project.id).toBe(SCRATCHPAD_PROJECT_ID);
|
||||||
expect(project.name).toBe(SCRATCHPAD_PROJECT_NAME);
|
expect(project.name).toBe(SCRATCHPAD_PROJECT_NAME);
|
||||||
expect(project.path).toContain('scratchpad');
|
expect(project.path).toContain('scratchpad');
|
||||||
|
expect(project.git).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('recognizes scratchpad project ids and records', () => {
|
test('recognizes scratchpad project ids and records', () => {
|
||||||
@@ -43,4 +44,48 @@ describe('scratchpad project helpers', () => {
|
|||||||
expect(merged[0].id).toBe(SCRATCHPAD_PROJECT_ID);
|
expect(merged[0].id).toBe(SCRATCHPAD_PROJECT_ID);
|
||||||
expect(merged[1].id).toBe('project-a');
|
expect(merged[1].id).toBe('project-a');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('preserves git context on non-scratchpad projects when merging scratchpad project', () => {
|
||||||
|
const merged = mergeScratchpadProject(
|
||||||
|
[
|
||||||
|
{
|
||||||
|
id: 'project-a',
|
||||||
|
name: 'Repo A',
|
||||||
|
path: 'C:\\repo-a',
|
||||||
|
addedAt: '2026-03-23T00:00:00.000Z',
|
||||||
|
git: {
|
||||||
|
status: 'ready',
|
||||||
|
scannedAt: '2026-03-23T00:05:00.000Z',
|
||||||
|
repoRoot: 'C:\\repo-a',
|
||||||
|
branch: 'main',
|
||||||
|
isDirty: true,
|
||||||
|
changedFileCount: 2,
|
||||||
|
changes: {
|
||||||
|
staged: 1,
|
||||||
|
unstaged: 1,
|
||||||
|
untracked: 0,
|
||||||
|
conflicted: 0,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'C:\\Users\\me\\AppData\\Roaming\\kopaya\\scratchpad',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(merged[0].git).toBeUndefined();
|
||||||
|
expect(merged[1].git).toEqual({
|
||||||
|
status: 'ready',
|
||||||
|
scannedAt: '2026-03-23T00:05:00.000Z',
|
||||||
|
repoRoot: 'C:\\repo-a',
|
||||||
|
branch: 'main',
|
||||||
|
isDirty: true,
|
||||||
|
changedFileCount: 2,
|
||||||
|
changes: {
|
||||||
|
staged: 1,
|
||||||
|
unstaged: 1,
|
||||||
|
untracked: 0,
|
||||||
|
conflicted: 0,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user