diff --git a/HANDOVER.md b/HANDOVER.md new file mode 100644 index 0000000..8b82604 --- /dev/null +++ b/HANDOVER.md @@ -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; +``` + +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 diff --git a/src/main/KopayaAppService.ts b/src/main/KopayaAppService.ts index 1e90e6c..414bca4 100644 --- a/src/main/KopayaAppService.ts +++ b/src/main/KopayaAppService.ts @@ -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 { 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 { return this.loadSidecarCapabilities(); @@ -73,6 +76,13 @@ export class KopayaAppService extends EventEmitter { 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 { 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 { return queryWorkspaceSessions(workspace, input); } + async refreshProjectGitContext(projectId?: string): Promise { + 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 { const workspace = await this.loadWorkspace(); workspace.selectedProjectId = projectId; @@ -389,6 +415,20 @@ export class KopayaAppService extends EventEmitter { return project; } + private async refreshGitContextForProject(project: ProjectRecord): Promise { + 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) { diff --git a/src/main/git/gitService.ts b/src/main/git/gitService.ts new file mode 100644 index 0000000..47a6d96 --- /dev/null +++ b/src/main/git/gitService.ts @@ -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; + +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 { + 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 { + 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 { + 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 { + try { + return { + ok: true, + stdout: await this.runGitCommand(projectPath, args), + }; + } catch (error) { + return { + ok: false, + error: createGitCommandFailure(projectPath, args, error), + }; + } + } +} diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index e3a7c2a..26aa600 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -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) => diff --git a/src/preload/index.ts b/src/preload/index.ts index 329fd80..c9f1d84 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index 34ebe64..5681afc 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -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', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index 93d2788..521b34d 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -55,6 +55,7 @@ export interface ElectronApi { loadWorkspace(): Promise; addProject(): Promise; removeProject(projectId: string): Promise; + refreshProjectGitContext(projectId?: string): Promise; savePattern(input: SavePatternInput): Promise; deletePattern(patternId: string): Promise; createSession(input: CreateSessionInput): Promise; diff --git a/src/shared/domain/project.ts b/src/shared/domain/project.ts index b8fbe88..5324645 100644 --- a/src/shared/domain/project.ts +++ b/src/shared/domain/project.ts @@ -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'; diff --git a/tests/main/gitService.test.ts b/tests/main/gitService.test.ts new file mode 100644 index 0000000..320cedf --- /dev/null +++ b/tests/main/gitService.test.ts @@ -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) { + 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, + }); + }); +}); diff --git a/tests/shared/project.test.ts b/tests/shared/project.test.ts index 90b5a70..8db83d8 100644 --- a/tests/shared/project.test.ts +++ b/tests/shared/project.test.ts @@ -15,6 +15,7 @@ describe('scratchpad project helpers', () => { expect(project.id).toBe(SCRATCHPAD_PROJECT_ID); expect(project.name).toBe(SCRATCHPAD_PROJECT_NAME); expect(project.path).toContain('scratchpad'); + expect(project.git).toBeUndefined(); }); 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[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, + }, + }); + }); });