diff --git a/electron/ipc/channel-manifest.json b/electron/ipc/channel-manifest.json index d4f545d0..1d9316aa 100644 --- a/electron/ipc/channel-manifest.json +++ b/electron/ipc/channel-manifest.json @@ -18,6 +18,7 @@ "GetFileDiffFromBranch": "get_file_diff_from_branch", "GetGitignoredDirs": "get_gitignored_dirs", "ListImportableWorktrees": "list_importable_worktrees", + "GetBranchWorktreePath": "get_branch_worktree_path", "GetWorktreeStatus": "get_worktree_status", "CheckMergeStatus": "check_merge_status", "MergeTask": "merge_task", diff --git a/electron/ipc/git.test.ts b/electron/ipc/git.test.ts index be6137c6..50f87860 100644 --- a/electron/ipc/git.test.ts +++ b/electron/ipc/git.test.ts @@ -59,6 +59,7 @@ import { getFileDiff, getUncommittedChangedFiles, checkMergeStatus, + getBranchWorktreePath, listImportableWorktrees, mergeTask, } from './git.js'; @@ -442,6 +443,7 @@ function uniqueWorktreePath(): string { */ function buildWorktreeMockHandler(opts: { mergeBase?: string; + mergeBaseTimestamp?: string; finalRawNumstat?: string; committedRawNumstat?: string; uncommittedRawNumstat?: string; @@ -540,7 +542,7 @@ function buildWorktreeMockHandler(opts: { ) { const fallback = [opts.committedRawNumstat, opts.uncommittedRawNumstat] .filter((part) => part && part.length > 0) - .join('\n'); + .join(''); cb(null, opts.finalRawNumstat ?? fallback, ''); return; } @@ -591,6 +593,11 @@ function buildWorktreeMockHandler(opts: { return; } + if (cmd === 'show' && args.includes('--format=%cI')) { + cb(null, `${opts.mergeBaseTimestamp ?? ''}\n`, ''); + return; + } + // git status --porcelain if (cmd === 'status' && args.includes('--porcelain')) { cb(null, opts.statusPorcelain ?? '', ''); @@ -604,13 +611,19 @@ function buildWorktreeMockHandler(opts: { /** * Build a raw+numstat combined output string for a single modified file. - * Format matches `git diff --raw --numstat` output. + * Format matches `git diff --raw --numstat -z` output. */ function rawNumstatEntry(filePath: string, added: number, removed: number, status = 'M'): string { - return [ - `:100644 100644 aaa111 bbb222 ${status}\t${filePath}`, - `${added}\t${removed}\t${filePath}`, - ].join('\n'); + return `:100644 100644 aaa111 bbb222 ${status}\0${filePath}\0${added}\t${removed}\t${filePath}\0`; +} + +function rawNumstatRenameEntry( + previousPath: string, + path: string, + added: number, + removed: number, +): string { + return `:100644 100644 aaa111 bbb222 R100\0${previousPath}\0${path}\0${added}\t${removed}\t\0${previousPath}\0${path}\0`; } // --------------------------------------------------------------------------- @@ -667,6 +680,75 @@ describe('getChangedFiles (worktree-based, merge-base diff)', () => { expect(files[0].status).toBe('A'); }); + it('should preserve the original path for renamed files', async () => { + const calls: string[][] = []; + setupMock( + calls, + buildWorktreeMockHandler({ + committedRawNumstat: rawNumstatRenameEntry('src/old-name.ts', 'src/new-name.ts', 0, 0), + }), + ); + + const files = await getChangedFiles(uniqueWorktreePath(), 'main'); + + expect(files).toEqual([ + expect.objectContaining({ + path: 'src/new-name.ts', + previous_path: 'src/old-name.ts', + status: 'R', + }), + ]); + }); + + it.each([ + ['dir/old => literal.ts', 'dir/new.ts'], + ['dir/{old}.ts', 'dir/{new}.ts'], + ])('should preserve exact Git paths when renaming %s', async (previousPath, path) => { + const calls: string[][] = []; + setupMock( + calls, + buildWorktreeMockHandler({ + committedRawNumstat: rawNumstatRenameEntry(previousPath, path, 4, 2), + }), + ); + + const files = await getChangedFiles(uniqueWorktreePath(), 'main'); + + expect(files).toEqual([ + expect.objectContaining({ + path, + previous_path: previousPath, + lines_added: 4, + lines_removed: 2, + status: 'R', + }), + ]); + expect( + calls.some((args) => args[0] === 'diff' && args.includes('--raw') && args.includes('-z')), + ).toBe(true); + }); + + it('should preserve a literal arrow in an ordinary modified filename', async () => { + const calls: string[][] = []; + setupMock( + calls, + buildWorktreeMockHandler({ + committedRawNumstat: rawNumstatEntry('ordinary => modified.txt', 3, 1), + }), + ); + + const files = await getChangedFiles(uniqueWorktreePath(), 'main'); + + expect(files).toEqual([ + expect.objectContaining({ + path: 'ordinary => modified.txt', + lines_added: 3, + lines_removed: 1, + status: 'M', + }), + ]); + }); + it('should return multiple committed files', async () => { const calls: string[][] = []; setupMock( @@ -675,7 +757,7 @@ describe('getChangedFiles (worktree-based, merge-base diff)', () => { committedRawNumstat: [ rawNumstatEntry('file-a.ts', 5, 2), rawNumstatEntry('file-b.ts', 3, 1), - ].join('\n'), + ].join(''), }), ); @@ -1419,6 +1501,50 @@ describe('listImportableWorktrees', () => { }); }); +describe('getBranchWorktreePath', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('returns only an existing worktree on the requested branch', async () => { + const calls: string[][] = []; + setupMock(calls, (args, cb) => { + if (args[0] === 'worktree' && args[1] === 'list') { + return cb( + null, + [ + 'worktree /repo', + 'HEAD aaa111', + 'branch refs/heads/main', + '', + 'worktree /repo-task', + 'HEAD bbb222', + 'branch refs/heads/task/coverage', + '', + ].join('\n'), + '', + ); + } + if (args[0] === 'show') { + return cb(null, '2026-07-25T12:00:00-04:00\n', ''); + } + return cb(new Error(`unexpected git call: ${args.join(' ')}`), '', ''); + }); + + await expect(getBranchWorktreePath('/repo', 'main')).resolves.toEqual({ + path: '/repo', + head: 'aaa111', + headCommittedAt: '2026-07-25T16:00:00.000Z', + }); + await expect(getBranchWorktreePath('/repo', 'missing')).resolves.toBeNull(); + expect(calls).toEqual([ + ['worktree', 'list', '--porcelain'], + ['show', '-s', '--format=%cI', 'aaa111'], + ['worktree', 'list', '--porcelain'], + ]); + }); +}); + // --------------------------------------------------------------------------- // checkMergeStatus — main-ahead count uses cherry-pick filtering so rebased // patch-equivalent commits in main don't trigger a needless rebase prompt. diff --git a/electron/ipc/git.ts b/electron/ipc/git.ts index 4bc4d176..b920a8a5 100644 --- a/electron/ipc/git.ts +++ b/electron/ipc/git.ts @@ -533,50 +533,65 @@ async function detectRepoLockKey(p: string): Promise { function normalizeStatusPath(raw: string): string { const trimmed = raw.trim(); if (!trimmed) return ''; - // Handle rename/copy "old -> new" - const destination = trimmed.split(' -> ').pop()?.trim() ?? trimmed; - return destination.replace(/^"|"$/g, '').replace(/\\(.)/g, '$1'); + return trimmed.replace(/^"|"$/g, '').replace(/\\(.)/g, '$1'); } -/** Parse combined `git diff --raw --numstat` output into status and numstat maps. */ +/** Parse combined `git diff --raw --numstat -z` output into status and numstat maps. */ function parseDiffRawNumstat(output: string): { statusMap: Map; numstatMap: Map; + previousPathMap: Map; } { const statusMap = new Map(); const numstatMap = new Map(); - - for (const line of output.split('\n')) { - if (line.startsWith(':')) { - // --raw format: ":old_mode new_mode old_hash new_hash status\tpath" - const parts = line.split('\t'); - if (parts.length >= 2) { - const statusLetter = parts[0].split(/\s+/).pop()?.charAt(0) ?? 'M'; - const rawPath = parts[parts.length - 1]; - const p = normalizeStatusPath(rawPath); - if (p) statusMap.set(p, statusLetter); + const previousPathMap = new Map(); + + const fields = output.split('\0'); + for (let index = 0; index < fields.length; index++) { + const field = fields[index]; + if (!field) continue; + + if (field.startsWith(':')) { + const statusLetter = field.split(/\s+/).pop()?.charAt(0) ?? 'M'; + const firstPath = fields[++index] ?? ''; + if (statusLetter === 'R' || statusLetter === 'C') { + const destinationPath = fields[++index] ?? ''; + if (destinationPath) { + statusMap.set(destinationPath, statusLetter); + if (firstPath) previousPathMap.set(destinationPath, firstPath); + } + } else if (firstPath) { + statusMap.set(firstPath, statusLetter); } continue; } - // --numstat format: "added\tremoved\tpath" - const parts = line.split('\t'); - if (parts.length >= 3) { - const added = parseInt(parts[0], 10); - const removed = parseInt(parts[1], 10); - if (!isNaN(added) && !isNaN(removed)) { - const rawPath = parts[parts.length - 1]; - const p = normalizeStatusPath(rawPath); - if (p) numstatMap.set(p, [added, removed]); + + const firstTab = field.indexOf('\t'); + const secondTab = firstTab < 0 ? -1 : field.indexOf('\t', firstTab + 1); + if (secondTab < 0) continue; + + const added = Number.parseInt(field.slice(0, firstTab), 10); + const removed = Number.parseInt(field.slice(firstTab + 1, secondTab), 10); + if (!Number.isFinite(added) || !Number.isFinite(removed)) continue; + + let destinationPath = field.slice(secondTab + 1); + if (!destinationPath) { + const previousPath = fields[++index] ?? ''; + destinationPath = fields[++index] ?? ''; + if (destinationPath && previousPath && !previousPathMap.has(destinationPath)) { + previousPathMap.set(destinationPath, previousPath); } } + if (destinationPath) numstatMap.set(destinationPath, [added, removed]); } - return { statusMap, numstatMap }; + return { statusMap, numstatMap, previousPathMap }; } export function changedFilesFromMaps(opts: { statusMap: Map; numstatMap: Map; + previousPathMap?: Map; committed: boolean | ((filePath: string) => boolean); sort?: boolean; }): ChangedFile[] { @@ -589,6 +604,7 @@ export function changedFilesFromMaps(opts: { seen.add(p); files.push({ path: p, + previous_path: opts.previousPathMap?.get(p), lines_added: added, lines_removed: removed, status: opts.statusMap.get(p) ?? 'M', @@ -600,6 +616,7 @@ export function changedFilesFromMaps(opts: { if (seen.has(p)) continue; files.push({ path: p, + previous_path: opts.previousPathMap?.get(p), lines_added: 0, lines_removed: 0, status, @@ -665,6 +682,7 @@ function safeRealpath(p: string): string { interface ListedWorktree { path: string; + head: string | null; branchName: string | null; detached: boolean; } @@ -685,6 +703,7 @@ function parseWorktreeList(output: string): ListedWorktree[] { if (current?.path) entries.push(current); current = { path: line.slice('worktree '.length).trim(), + head: null, branchName: null, detached: false, }; @@ -692,6 +711,10 @@ function parseWorktreeList(output: string): ListedWorktree[] { } if (!current) continue; + if (line.startsWith('HEAD ')) { + current.head = line.slice('HEAD '.length).trim() || null; + continue; + } if (line.startsWith('branch ')) { const ref = line.slice('branch '.length).trim(); const prefix = 'refs/heads/'; @@ -1088,7 +1111,7 @@ export async function getChangedFiles( let finalDiffStr = ''; try { - const { stdout } = await exec('git', ['diff', '--raw', '--numstat', diffBase.sha], { + const { stdout } = await exec('git', ['diff', '--raw', '--numstat', '-z', diffBase.sha], { cwd: worktreePath, maxBuffer: MAX_BUFFER, }); @@ -1097,8 +1120,11 @@ export async function getChangedFiles( /* empty */ } - const { statusMap: finalStatusMap, numstatMap: finalNumstatMap } = - parseDiffRawNumstat(finalDiffStr); + const { + statusMap: finalStatusMap, + numstatMap: finalNumstatMap, + previousPathMap: finalPreviousPathMap, + } = parseDiffRawNumstat(finalDiffStr); // git diff --raw --numstat — tracked uncommitted changes (HEAD vs working tree). // Compares HEAD tree directly to the working tree, so it does not need the index @@ -1106,7 +1132,7 @@ export async function getChangedFiles( // git ls-files --others --exclude-standard — untracked files (no index lock needed). // Both commands run in parallel since they are independent. const [uncommittedResult, untrackedResult] = await Promise.all([ - exec('git', ['diff', '--raw', '--numstat', headHash], { + exec('git', ['diff', '--raw', '--numstat', '-z', headHash], { cwd: worktreePath, maxBuffer: MAX_BUFFER, }).catch(() => ({ stdout: '' })), @@ -1131,6 +1157,7 @@ export async function getChangedFiles( const files = changedFilesFromMaps({ statusMap: finalStatusMap, numstatMap: finalNumstatMap, + previousPathMap: finalPreviousPathMap, committed: isCommitted, sort: false, }); @@ -1284,7 +1311,7 @@ export async function getUncommittedChangedFiles(worktreePath: string): Promise< const headHash = await pinHead(worktreePath); let diffStr = ''; try { - const { stdout } = await exec('git', ['diff', '--raw', '--numstat', headHash], { + const { stdout } = await exec('git', ['diff', '--raw', '--numstat', '-z', headHash], { cwd: worktreePath, maxBuffer: MAX_BUFFER, }); @@ -1293,8 +1320,14 @@ export async function getUncommittedChangedFiles(worktreePath: string): Promise< /* empty */ } - const { statusMap, numstatMap } = parseDiffRawNumstat(diffStr); - const files = changedFilesFromMaps({ statusMap, numstatMap, committed: false, sort: false }); + const { statusMap, numstatMap, previousPathMap } = parseDiffRawNumstat(diffStr); + const files = changedFilesFromMaps({ + statusMap, + numstatMap, + previousPathMap, + committed: false, + sort: false, + }); const seen = new Set(files.map((file) => file.path)); files.push(...(await getUntrackedChangedFiles(worktreePath, seen))); @@ -1540,6 +1573,34 @@ export async function listImportableWorktrees(projectRoot: string): Promise< return filtered; } +/** Resolve an already checked-out local branch without creating or switching worktrees. */ +export async function getBranchWorktreePath( + projectRoot: string, + branchName: string, +): Promise<{ path: string; head: string; headCommittedAt: string | null } | null> { + const { stdout } = await exec('git', ['worktree', 'list', '--porcelain'], { + cwd: projectRoot, + maxBuffer: MAX_BUFFER, + }); + const match = parseWorktreeList(stdout).find( + (entry) => !entry.detached && entry.branchName === branchName, + ); + if (!match?.path || !match.head) return null; + try { + const { stdout } = await exec('git', ['show', '-s', '--format=%cI', match.head], { + cwd: match.path, + }); + const timestamp = new Date(stdout.trim()); + return { + path: match.path, + head: match.head, + headCommittedAt: Number.isNaN(timestamp.getTime()) ? null : timestamp.toISOString(), + }; + } catch { + return { path: match.path, head: match.head, headCommittedAt: null }; + } +} + /** Stage all changes and commit in a worktree. */ export async function commitAll(worktreePath: string, message: string): Promise { await exec('git', ['add', '-A'], { cwd: worktreePath }); @@ -1747,7 +1808,7 @@ export async function getChangedFilesFromBranch( let diffStr = ''; try { - const { stdout } = await exec('git', ['diff', '--raw', '--numstat', diffRange], { + const { stdout } = await exec('git', ['diff', '--raw', '--numstat', '-z', diffRange], { cwd: projectRoot, maxBuffer: MAX_BUFFER, }); @@ -1756,9 +1817,9 @@ export async function getChangedFilesFromBranch( return []; } - const { statusMap, numstatMap } = parseDiffRawNumstat(diffStr); + const { statusMap, numstatMap, previousPathMap } = parseDiffRawNumstat(diffStr); - return changedFilesFromMaps({ statusMap, numstatMap, committed: true }); + return changedFilesFromMaps({ statusMap, numstatMap, previousPathMap, committed: true }); } export async function getFileDiffFromBranch( @@ -1965,7 +2026,7 @@ export async function getCommitChangedFiles( try { const { stdout } = await exec( 'git', - ['diff', '--raw', '--numstat', `${commitHash}^..${commitHash}`], + ['diff', '--raw', '--numstat', '-z', `${commitHash}^..${commitHash}`], { cwd: worktreePath, maxBuffer: MAX_BUFFER }, ); diffStr = stdout; @@ -1974,7 +2035,7 @@ export async function getCommitChangedFiles( try { const { stdout } = await exec( 'git', - ['diff', '--raw', '--numstat', `${EMPTY_TREE}..${commitHash}`], + ['diff', '--raw', '--numstat', '-z', `${EMPTY_TREE}..${commitHash}`], { cwd: worktreePath, maxBuffer: MAX_BUFFER }, ); diffStr = stdout; @@ -1984,9 +2045,9 @@ export async function getCommitChangedFiles( } } - const { statusMap, numstatMap } = parseDiffRawNumstat(diffStr); + const { statusMap, numstatMap, previousPathMap } = parseDiffRawNumstat(diffStr); - return changedFilesFromMaps({ statusMap, numstatMap, committed: true }); + return changedFilesFromMaps({ statusMap, numstatMap, previousPathMap, committed: true }); } export async function getCommitDiffs(worktreePath: string, commitHash: string): Promise { diff --git a/electron/ipc/register.ts b/electron/ipc/register.ts index aae2d88a..c85d92de 100644 --- a/electron/ipc/register.ts +++ b/electron/ipc/register.ts @@ -54,6 +54,7 @@ import { getFileDiffFromBranch, getWorktreeStatus, listImportableWorktrees, + getBranchWorktreePath, commitAll, discardUncommitted, checkMergeStatus, @@ -603,6 +604,9 @@ export function registerAllHandlers(win: BrowserWindow): void { ipcMain.handle(IPC.ListImportableWorktrees, (_e, args) => { return listImportableWorktrees(projectRootArg(args)); }); + ipcMain.handle(IPC.GetBranchWorktreePath, (_e, args) => { + return getBranchWorktreePath(projectRootArg(args), branchNameArg(args)); + }); ipcMain.handle(IPC.GetWorktreeStatus, (_e, args) => { const worktreePath = worktreePathArg(args); return getWorktreeStatus(worktreePath, optionalBaseBranch(args)); diff --git a/electron/ipc/shared-types.ts b/electron/ipc/shared-types.ts index 9ef93cc6..8e53cd09 100644 --- a/electron/ipc/shared-types.ts +++ b/electron/ipc/shared-types.ts @@ -29,6 +29,8 @@ export interface CreateTaskResult { export interface ChangedFile { path: string; + /** Original path when Git reports a rename or copy. */ + previous_path?: string; lines_added: number; lines_removed: number; status: string; diff --git a/electron/preload.cjs b/electron/preload.cjs index d0126c6c..1ce97c81 100644 --- a/electron/preload.cjs +++ b/electron/preload.cjs @@ -22,6 +22,7 @@ const ALLOWED_CHANNELS = new Set([ 'get_file_diff_from_branch', 'get_gitignored_dirs', 'list_importable_worktrees', + 'get_branch_worktree_path', 'get_worktree_status', 'check_merge_status', 'merge_task', diff --git a/package-lock.json b/package-lock.json index 4ee8b55b..865eaf20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -47,6 +47,7 @@ "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-solid": "^0.14.5", + "happy-dom": "^20.11.1", "husky": "^9.1.7", "knip": "^6.12.2", "lint-staged": "^16.2.7", @@ -3592,6 +3593,13 @@ "license": "MIT", "optional": true }, + "node_modules/@types/whatwg-mimetype": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/whatwg-mimetype/-/whatwg-mimetype-3.0.2.tgz", + "integrity": "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -4862,6 +4870,19 @@ "dev": true, "license": "MIT" }, + "node_modules/buffer-image-size": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/buffer-image-size/-/buffer-image-size-0.6.4.tgz", + "integrity": "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + }, + "engines": { + "node": ">=4.0" + } + }, "node_modules/builder-util": { "version": "26.8.1", "resolved": "https://registry.npmjs.org/builder-util/-/builder-util-26.8.1.tgz", @@ -8294,6 +8315,38 @@ "integrity": "sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==", "license": "MIT" }, + "node_modules/happy-dom": { + "version": "20.11.1", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-20.11.1.tgz", + "integrity": "sha512-XSt8tMzbW9ymE7687xztkO1ckR7qJNQ3LywY9vlYGhGi3zXrGBHuUo2Cl1ztZaICW+1eAGdkLbj6iwVqDT33kg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": ">=20.0.0", + "@types/whatwg-mimetype": "^3.0.2", + "@types/ws": "^8.18.1", + "buffer-image-size": "^0.6.4", + "entities": "^7.0.1", + "whatwg-mimetype": "^3.0.0", + "ws": "^8.21.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/happy-dom/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -13739,6 +13792,16 @@ "defaults": "^1.0.3" } }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/which/-/which-5.0.0.tgz", diff --git a/package.json b/package.json index 314e7819..37c8362c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,9 @@ "lint:secrets": "command -v gitleaks >/dev/null 2>&1 && gitleaks detect --config .gitleaks.toml || (echo 'gitleaks not installed (brew install gitleaks)' >&2; exit 1)", "format": "prettier --write .", "format:check": "prettier --check .", - "test": "vitest run", + "test": "npm run test:unit && npm run test:client", + "test:unit": "vitest run", + "test:client": "vitest run --config vitest.client.config.ts", "test:coordinator-pty": "RUN_COORDINATOR_PTY_TEST=1 vitest run electron/mcp/coordinator-real-pty.integration.test.ts", "test:coverage": "vitest run --coverage", "check:coordinator-log": "node scripts/check-coordinator-run.mjs", @@ -75,6 +77,7 @@ "eslint": "^9.39.3", "eslint-config-prettier": "^10.1.8", "eslint-plugin-solid": "^0.14.5", + "happy-dom": "^20.11.1", "husky": "^9.1.7", "knip": "^6.12.2", "lint-staged": "^16.2.7", diff --git a/src/components/ChangedFilesList.client.test.tsx b/src/components/ChangedFilesList.client.test.tsx new file mode 100644 index 00000000..834d4ad3 --- /dev/null +++ b/src/components/ChangedFilesList.client.test.tsx @@ -0,0 +1,126 @@ +import { render } from 'solid-js/web'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { IPC } from '../../electron/ipc/channels'; +import type { ChangedFile, CoverageSummary } from '../ipc/types'; +import type { CoverageComparison } from '../lib/coverage-comparison'; +import { invoke } from '../lib/ipc'; +import { UNCOMMITTED_SELECTION } from './CommitNavBar'; +import { ChangedFilesList } from './ChangedFilesList'; + +vi.mock('../lib/ipc', () => ({ + invoke: vi.fn(), +})); + +const disposers: Array<() => void> = []; + +afterEach(() => { + while (disposers.length > 0) disposers.pop()?.(); + document.body.replaceChildren(); + vi.mocked(invoke).mockReset(); +}); + +function coverageSummary(repoRoot: string, pct: number): CoverageSummary { + return { + format: 'istanbul-summary', + generatedAt: '2026-08-01T12:00:00.000Z', + reportPath: `${repoRoot}/coverage/coverage-summary.json`, + totals: { + lines: { total: 100, covered: pct, skipped: 0, pct }, + statements: { total: 100, covered: pct, skipped: 0, pct }, + functions: { total: 10, covered: Math.round(pct / 10), skipped: 0, pct }, + branches: { total: 20, covered: Math.round(pct / 5), skipped: 0, pct }, + }, + files: { + 'src/example.ts': { + path: 'src/example.ts', + lines: { total: 100, covered: pct, skipped: 0, pct }, + statements: { total: 100, covered: pct, skipped: 0, pct }, + functions: { total: 10, covered: Math.round(pct / 10), skipped: 0, pct }, + branches: { total: 20, covered: Math.round(pct / 5), skipped: 0, pct }, + }, + }, + }; +} + +async function waitFor(predicate: () => boolean): Promise { + for (let attempt = 0; attempt < 50; attempt += 1) { + if (predicate()) return; + await new Promise((resolve) => setTimeout(resolve, 0)); + } + throw new Error('Timed out waiting for mounted ChangedFilesList state'); +} + +describe('ChangedFilesList coverage inventory fallbacks', () => { + it.each(['loading', 'failed'] as const)( + 'suppresses per-file comparison output while inventory is %s', + async (inventoryState) => { + const changedFile: ChangedFile = { + path: 'src/example.ts', + lines_added: 1, + lines_removed: 0, + status: 'M', + committed: false, + }; + const taskCoverage = coverageSummary('/task', 60); + const baseCoverage = coverageSummary('/base', 80); + + vi.mocked(invoke).mockImplementation(((channel: string, args?: Record) => { + if (channel === IPC.GetUncommittedChangedFiles) { + return Promise.resolve([changedFile]); + } + if (channel === IPC.GetChangedFiles) { + return inventoryState === 'loading' + ? new Promise(() => undefined) + : Promise.reject(new Error('inventory unavailable')); + } + if (channel === IPC.GetBranchWorktreePath) { + const branchName = args?.branchName; + return Promise.resolve( + branchName === 'main' + ? { path: '/base', headCommittedAt: '2026-08-01T11:00:00.000Z' } + : { path: '/task', headCommittedAt: '2026-08-01T11:00:00.000Z' }, + ); + } + if (channel === IPC.GetCoverageSummary) { + return Promise.resolve(args?.repoRoot === '/base' ? baseCoverage : taskCoverage); + } + return Promise.reject(new Error(`Unexpected IPC call: ${channel}`)); + }) as typeof invoke); + + const container = document.createElement('div'); + document.body.append(container); + const state: { latestComparison: CoverageComparison | null } = { latestComparison: null }; + disposers.push( + render( + () => ( + { + state.latestComparison = comparison; + }} + /> + ), + container, + ), + ); + + await waitFor( + () => + state.latestComparison?.inventoryState === inventoryState && + container.textContent.includes('base 80%'), + ); + + expect(state.latestComparison?.aggregate.delta).toBe(-20); + expect(Object.keys(state.latestComparison?.files ?? {})).toEqual([]); + expect(state.latestComparison?.impactedUnchangedFiles).toEqual([]); + expect(container.textContent).toContain('base 80% → task 60% (-20pp)'); + expect(container.querySelector('[title^="Lines 60%"]')).not.toBeNull(); + expect(container.textContent).not.toContain('↕'); + }, + ); +}); diff --git a/src/components/ChangedFilesList.test.ts b/src/components/ChangedFilesList.test.ts index fef489b8..ec783dcc 100644 --- a/src/components/ChangedFilesList.test.ts +++ b/src/components/ChangedFilesList.test.ts @@ -1,8 +1,10 @@ +import { renderToString } from 'solid-js/web'; import { describe, expect, it } from 'vitest'; -import type { ChangedFile } from '../ipc/types'; +import type { ChangedFile, CoverageFileSummary } from '../ipc/types'; import { coverageFooterLabel, coverageFooterTitle, + FileCoverageBadge, filesFooterLabel, filesFooterTitle, isCoverageEligible, @@ -80,3 +82,28 @@ describe('filesFooterLabel', () => { expect(filesFooterTitle(7, 2)).toBe('7 changed files, 2 uncommitted.'); }); }); + +describe('FileCoverageBadge', () => { + it('keeps task-only coverage visible while comparison inventory is unavailable', () => { + const summary: CoverageFileSummary = { + path: 'src/example.ts', + lines: { total: 100, covered: 82, skipped: 0, pct: 82 }, + statements: { total: 100, covered: 81, skipped: 0, pct: 81 }, + functions: { total: 10, covered: 8, skipped: 0, pct: 80 }, + branches: { total: 20, covered: 15, skipped: 0, pct: 75 }, + }; + + const html = renderToString(() => + FileCoverageBadge({ + file: changedFile({ path: summary.path }), + summary, + comparison: undefined, + hasCoverageArtifact: true, + }), + ); + + expect(html).toContain('82%'); + expect(html).toContain('Lines 82%'); + expect(html).not.toContain('No recent coverage data'); + }); +}); diff --git a/src/components/ChangedFilesList.tsx b/src/components/ChangedFilesList.tsx index f44ca2e8..8a32845b 100644 --- a/src/components/ChangedFilesList.tsx +++ b/src/components/ChangedFilesList.tsx @@ -6,6 +6,14 @@ import { sf } from '../lib/fontScale'; import { getStatusColor } from '../lib/status-colors'; import { openFileInEditor } from '../lib/shell'; import { buildFileTree, flattenVisibleTree } from '../lib/file-tree'; +import { + buildCoverageComparison, + formatCoverageDelta, + isBaselineInformational, + type CoverageComparison, + type CoverageFileComparison, + type CoverageValue, +} from '../lib/coverage-comparison'; import { type CommitSelection, isCommitHashSelection, @@ -30,6 +38,8 @@ interface ChangedFilesListProps { branchName?: string | null; /** Base branch for diff comparison (e.g. 'main', 'develop'). Undefined = auto-detect. */ baseBranch?: string; + /** Reports coverage changes when task or base coverage data refreshes. */ + onCoverageComparisonChange?: (comparison: CoverageComparison | null) => void; /** * Selection mode for the file list: * - undefined/null: all changes (committed + uncommitted) @@ -42,12 +52,40 @@ interface ChangedFilesListProps { const SOURCE_FILE_RE = /\.(?:[cm]?[jt]sx?)$/i; const TEST_FILE_RE = /\.(?:test|spec)\.(?:[cm]?[jt]sx?)$/i; +function isCoverageCandidate(file: ChangedFile): boolean { + return ( + SOURCE_FILE_RE.test(file.path) && !TEST_FILE_RE.test(file.path) && !file.path.endsWith('.d.ts') + ); +} + export function isCoverageEligible(file: ChangedFile): boolean { + return file.status !== 'D' && isCoverageCandidate(file); +} + +function sameChangedFiles(left: ChangedFile[] | null, right: ChangedFile[] | null): boolean { + if (left === right) return true; + if (!left || !right || left.length !== right.length) return false; + return left.every((file, index) => { + const other = right[index]; + return ( + file.path === other.path && + file.previous_path === other.previous_path && + file.lines_added === other.lines_added && + file.lines_removed === other.lines_removed && + file.status === other.status && + file.committed === other.committed + ); + }); +} + +function sameCoverageSummary(left: CoverageSummary | null, right: CoverageSummary | null): boolean { return ( - file.status !== 'D' && - SOURCE_FILE_RE.test(file.path) && - !TEST_FILE_RE.test(file.path) && - !file.path.endsWith('.d.ts') + left === right || + (left !== null && + right !== null && + left.format === right.format && + left.reportPath === right.reportPath && + left.generatedAt === right.generatedAt) ); } @@ -99,19 +137,122 @@ function coverageBadgeTitle(summary: CoverageFileSummary): string { return `Lines ${summary.lines.pct}% · Branches ${summary.branches.pct}% · Functions ${summary.functions.pct}% · Statements ${summary.statements.pct}%`; } -function FileCoverageBadge(props: { +function coverageValueLabel(value: CoverageValue): string { + if (value.state === 'available') return `${value.pct}%`; + if (value.state === 'no-executable-lines') return 'no lines'; + if (value.state === 'file-not-present') return 'not present'; + return 'no report'; +} + +function deltaColor(delta: number): string { + if (delta > 0) return theme.success; + if (delta < 0) return theme.error; + return theme.fgMuted; +} + +function comparisonBadge( + comparison: CoverageFileComparison, + baseline: CoverageComparison['baseline'], +): { label: string; color: string; title: string } | null { + const taskLabel = coverageValueLabel(comparison.task); + const baseLabel = coverageValueLabel(comparison.base); + const baselineInformational = isBaselineInformational(baseline); + const baselineBranch = baseline?.baseBranch ?? 'base branch'; + const baselineDetail = baseline?.taskStale + ? ' The task report predates task HEAD, so this delta is informational only.' + : baseline?.taskUnanchored + ? ' The task report cannot be anchored to task HEAD, so this delta is informational only.' + : baseline?.stale + ? ` The base report predates ${baselineBranch} as currently checked out, so this delta is informational only.` + : baseline?.unanchored + ? ` The base report cannot be anchored to ${baselineBranch} as currently checked out, so this delta is informational only.` + : ''; + const renameDetail = + comparison.kind === 'renamed' ? ` (${comparison.basePath} → ${comparison.path})` : ''; + + if (comparison.kind === 'deleted') { + if (comparison.base.state === 'no-report') return null; + return { + label: comparison.base.state === 'available' ? `del ${baseLabel}` : 'deleted', + color: theme.fgMuted, + title: `Deleted file${renameDetail}. Base: ${baseLabel}; task: not present.`, + }; + } + + if (comparison.task.state === 'no-executable-lines') { + return { + label: 'no lines', + color: theme.fgMuted, + title: `Task: no executable lines; base: ${baseLabel}${renameDetail}.`, + }; + } + + if (comparison.task.state === 'no-report' && comparison.base.state !== 'no-report') { + return { + label: 'no report', + color: theme.fgMuted, + title: `No task coverage report; base: ${baseLabel}${renameDetail}.`, + }; + } + + if (comparison.task.state !== 'available') return null; + + if (comparison.delta !== null) { + return { + label: `${taskLabel} ${formatCoverageDelta(comparison.delta)}`, + color: baselineInformational ? theme.fgMuted : deltaColor(comparison.delta), + title: `Task: ${taskLabel}; base: ${baseLabel}; delta: ${formatCoverageDelta(comparison.delta)}${renameDetail}.${baselineDetail}`, + }; + } + + const kindLabel = + comparison.kind === 'new' ? ' new' : comparison.kind === 'renamed' ? ' renamed' : ''; + return { + label: `${taskLabel}${kindLabel}`, + color: coverageColor(comparison.task.pct ?? 0), + title: `Task: ${taskLabel}; base: ${baseLabel}${renameDetail}.`, + }; +} + +export function FileCoverageBadge(props: { file: ChangedFile; selectedCommit?: CommitSelection; summary?: CoverageFileSummary; + comparison?: CoverageFileComparison; + baseline?: CoverageComparison['baseline']; hasCoverageArtifact: boolean; }) { - const isEligible = () => - !isCommitHashSelection(props.selectedCommit) && isCoverageEligible(props.file); - const summary = () => (isEligible() ? props.summary : undefined); + const isCandidate = () => + !isCommitHashSelection(props.selectedCommit) && isCoverageCandidate(props.file); + const summary = () => (isCandidate() ? props.summary : undefined); + const badge = () => + isCandidate() && props.comparison ? comparisonBadge(props.comparison, props.baseline) : null; return ( <> - + + {(coverageBadge) => ( + + {coverageBadge.label} + + )} + + {(coverageSummary) => ( )} - + { + if (!projectRoot) return null; + return invoke<{ + path: string; + headCommittedAt: string | null; + } | null>(IPC.GetBranchWorktreePath, { + projectRoot, + branchName, + }).catch(() => null); +} + +async function resolveBaseCoverageRoot( + projectRoot: string | undefined, + baseBranch: string, + taskRoot: string, +): Promise<{ path: string; headCommittedAt: string | null } | null> { + const baseWorktree = await resolveCoverageWorktree(projectRoot, baseBranch); + if (!baseWorktree || baseWorktree.path === taskRoot) return null; + return baseWorktree; +} + function OpenInEditorButton(props: { worktreePath: string; filePath: string; @@ -188,7 +361,14 @@ function OpenInEditorButton(props: { export function ChangedFilesList(props: ChangedFilesListProps) { const [files, setFiles] = createSignal([]); + const [comparisonFiles, setComparisonFiles] = createSignal(null); const [coverage, setCoverage] = createSignal(null); + const [baseCoverage, setBaseCoverage] = createSignal(null); + const [baseHeadAt, setBaseHeadAt] = createSignal(null); + const [taskHeadAt, setTaskHeadAt] = createSignal(null); + const [baseBranchName, setBaseBranchName] = createSignal(); + const [comparisonInventoryState, setComparisonInventoryState] = + createSignal>('loading'); const [canOpenFilesInEditor, setCanOpenFilesInEditor] = createSignal(false); const [selectedIndex, setSelectedIndex] = createSignal(-1); const [collapsed, setCollapsed] = createSignal>(new Set()); @@ -198,7 +378,45 @@ export function ChangedFilesList(props: ChangedFilesListProps) { const visibleRows = createMemo(() => flattenVisibleTree(tree(), collapsed())); const coverageFiles = createMemo(() => coverage()?.files ?? {}); const hasCoverageArtifact = createMemo(() => coverage() !== null); + const hasBaseCoverageArtifact = createMemo(() => baseCoverage() !== null); const eligibleFiles = createMemo(() => files().filter((file) => isCoverageEligible(file))); + const showCoverageFooter = createMemo( + () => + !isCommitHashSelection(props.selectedCommit) && + (files().some((file) => isCoverageCandidate(file)) || + hasCoverageArtifact() || + hasBaseCoverageArtifact()), + ); + const coverageComparison = createMemo(() => { + const inventory = comparisonFiles(); + const taskReport = coverage(); + const baseReport = baseCoverage(); + if (isCommitHashSelection(props.selectedCommit)) return null; + if (!inventory && !taskReport && !baseReport) return null; + const comparison = buildCoverageComparison( + taskReport, + baseReport, + inventory ?? [], + baseHeadAt(), + baseBranchName(), + taskHeadAt(), + ); + const inventoryState = inventory ? 'available' : comparisonInventoryState(); + + if (!inventory) { + return { + ...comparison, + files: Object.create(null) as Record, + impactedUnchangedFiles: [], + inventoryState, + }; + } + + return { + ...comparison, + inventoryState, + }; + }); const coveredEligibleFiles = createMemo(() => eligibleFiles().filter((file) => Boolean(coverageFiles()[file.path])), ); @@ -224,6 +442,80 @@ export function ChangedFilesList(props: ChangedFilesListProps) { if (totalLines === 0) return null; return Math.round((coveredLines / totalLines) * 100); }); + const aggregateCoverageLabel = createMemo(() => { + const comparison = coverageComparison()?.aggregate; + if (!comparison) return null; + if (!hasBaseCoverageArtifact()) return null; + const delta = comparison.delta === null ? '' : ` (${formatCoverageDelta(comparison.delta)})`; + return `base ${coverageValueLabel(comparison.base)} → task ${coverageValueLabel(comparison.task)}${delta}`; + }); + const aggregateCoverageTitle = createMemo(() => { + const comparison = coverageComparison(); + const taskReport = coverage(); + const baseReport = baseCoverage(); + if (!baseReport || !comparison) return ''; + const lines = [ + `Base: ${coverageValueLabel(comparison.aggregate.base)} (${baseReport.reportPath}, updated ${baseReport.generatedAt ?? 'unknown time'}).`, + taskReport + ? `Task: ${coverageValueLabel(comparison.aggregate.task)} (${taskReport.reportPath}, updated ${taskReport.generatedAt ?? 'unknown time'}).` + : 'Task: no coverage report.', + ]; + if (comparison.aggregate.delta !== null) { + lines.push(`Delta: ${formatCoverageDelta(comparison.aggregate.delta)}.`); + } + if (comparison.inventoryState === 'loading') { + lines.push( + 'The changed-file inventory is still loading, so merge readiness ignores the comparison.', + ); + } else if (comparison.inventoryState === 'failed') { + lines.push( + 'The changed-file inventory is unavailable, so merge readiness ignores the comparison.', + ); + } + if (comparison.baseline?.taskUnanchored) { + lines.push( + 'The task report cannot be anchored to task HEAD, so merge readiness ignores the delta.', + ); + } else if (comparison.baseline?.taskStale) { + lines.push('The task report predates task HEAD, so merge readiness ignores the delta.'); + } + if (comparison.baseline?.unanchored) { + lines.push( + `The base report cannot be anchored to ${comparison.baseline.baseBranch ?? 'the base branch'} as currently checked out, so merge readiness ignores the delta.`, + ); + } else if (comparison.baseline?.baseHeadAt) { + lines.push( + `Baseline: ${comparison.baseline.baseBranch ?? 'base branch'} as currently checked out (${comparison.baseline.baseHeadAt}).`, + ); + lines.push( + `This comparison may include changes merged into ${comparison.baseline.baseBranch ?? 'the base branch'} after the task branched.`, + ); + if (comparison.baseline.stale) { + lines.push( + `The base report predates ${comparison.baseline.baseBranch ?? 'the base branch'} as currently checked out, so merge readiness ignores the delta.`, + ); + } + } + if (comparison.impactedUnchangedFiles.length > 0) { + const impacted = comparison.impactedUnchangedFiles + .slice(0, 3) + .map((file) => + file.delta === null + ? `${file.path} ${coverageValueLabel(file.base)} → ${coverageValueLabel(file.task)}` + : `${file.path} ${formatCoverageDelta(file.delta)}`, + ) + .join(', '); + lines.push( + `${comparison.impactedUnchangedFiles.length} materially impacted unchanged file${comparison.impactedUnchangedFiles.length === 1 ? '' : 's'}: ${impacted}.`, + ); + } + return lines.join(' '); + }); + + createEffect(() => { + if (!props.onCoverageComparisonChange) return; + props.onCoverageComparisonChange(coverageComparison()); + }); function toggleDir(path: string) { const isCollapsing = !collapsed().has(path); @@ -330,6 +622,18 @@ export function ChangedFilesList(props: ChangedFilesListProps) { // wouldn't otherwise activate the task and trigger a fetch. Polling at 5s // (matching git status) is still gated on isActive to avoid running git // pipelines for every off-screen task. + createEffect(() => { + void props.worktreePath; + void props.projectRoot; + void props.branchName; + void props.baseBranch; + void props.selectedCommit; + batch(() => { + setComparisonFiles(null); + setComparisonInventoryState('loading'); + }); + }); + createEffect(() => { const path = props.worktreePath; const projectRoot = props.projectRoot; @@ -338,6 +642,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { const selection = props.selectedCommit; const singleCommitHash = isCommitHashSelection(selection) ? selection : null; const uncommittedOnly = isUncommittedSelection(selection); + const comparisonEnabled = hasCoverageArtifact() || hasBaseCoverageArtifact(); let cancelled = false; let inFlight = false; let usingBranchFallback = false; @@ -355,7 +660,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { commitHash: singleCommitHash, }); if (!cancelled) { - setFiles(result); + setFiles((current) => (sameChangedFiles(current, result) ? current : result)); setCanOpenFilesInEditor(true); } } catch { @@ -368,12 +673,42 @@ export function ChangedFilesList(props: ChangedFilesListProps) { } if (uncommittedOnly && path) { + if (!comparisonEnabled && !cancelled) { + batch(() => { + setComparisonFiles(null); + setComparisonInventoryState('loading'); + }); + } + const comparisonRequest = comparisonEnabled + ? invoke(IPC.GetChangedFiles, { + worktreePath: path, + baseBranch, + }) + .then((result) => { + if (!cancelled) { + batch(() => { + setComparisonFiles((current) => + sameChangedFiles(current, result) ? current : result, + ); + setComparisonInventoryState('available'); + }); + } + }) + .catch(() => { + if (!cancelled) { + batch(() => { + setComparisonFiles(null); + setComparisonInventoryState('failed'); + }); + } + }) + : Promise.resolve(); try { const result = await invoke(IPC.GetUncommittedChangedFiles, { worktreePath: path, }); if (!cancelled) { - setFiles(result); + setFiles((current) => (sameChangedFiles(current, result) ? current : result)); setCanOpenFilesInEditor(true); } } catch { @@ -382,6 +717,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { setCanOpenFilesInEditor(false); } } + await comparisonRequest; return; } @@ -393,12 +729,24 @@ export function ChangedFilesList(props: ChangedFilesListProps) { baseBranch, }); if (!cancelled) { - setFiles(result); - setCanOpenFilesInEditor(true); + batch(() => { + setFiles((current) => (sameChangedFiles(current, result) ? current : result)); + setComparisonFiles((current) => + sameChangedFiles(current, result) ? current : result, + ); + setComparisonInventoryState('available'); + setCanOpenFilesInEditor(true); + }); } return; } catch { - if (!cancelled) setCanOpenFilesInEditor(false); + if (!cancelled) { + batch(() => { + setComparisonFiles(null); + setComparisonInventoryState('failed'); + setCanOpenFilesInEditor(false); + }); + } // Worktree may not exist — try branch fallback below } } @@ -413,14 +761,36 @@ export function ChangedFilesList(props: ChangedFilesListProps) { baseBranch, }); if (!cancelled) { - setFiles(uncommittedOnly ? result.filter((f) => !f.committed) : result); - setCanOpenFilesInEditor(false); + const displayedFiles = uncommittedOnly + ? result.filter((file) => !file.committed) + : result; + batch(() => { + setFiles((current) => + sameChangedFiles(current, displayedFiles) ? current : displayedFiles, + ); + setComparisonFiles((current) => + sameChangedFiles(current, result) ? current : result, + ); + setComparisonInventoryState('available'); + setCanOpenFilesInEditor(false); + }); + return; } } catch { - if (!cancelled) setCanOpenFilesInEditor(false); + if (!cancelled) { + batch(() => { + setComparisonFiles(null); + setComparisonInventoryState('failed'); + setCanOpenFilesInEditor(false); + }); + } // Branch may no longer exist } } + + if (!cancelled && comparisonEnabled) { + setComparisonInventoryState('failed'); + } } finally { inFlight = false; } @@ -443,26 +813,69 @@ export function ChangedFilesList(props: ChangedFilesListProps) { createEffect(() => { const repoRoot = props.worktreePath; + const projectRoot = props.projectRoot; + const taskBranch = props.branchName; + const baseBranch = props.baseBranch; const selection = props.selectedCommit; if (!repoRoot || isCommitHashSelection(selection)) { - setCoverage(null); + batch(() => { + setCoverage(null); + setBaseCoverage(null); + setBaseHeadAt(null); + setTaskHeadAt(null); + setBaseBranchName(undefined); + }); return; } if (!props.isActive) return; let cancelled = false; let inFlight = false; - async function refresh() { if (inFlight) return; inFlight = true; try { - const result = await invoke(IPC.GetCoverageSummary, { + const taskResult = await invoke(IPC.GetCoverageSummary, { repoRoot, reportPath: props.coverageReportPath, - }); - if (!cancelled) setCoverage(result); - } catch { - if (!cancelled) setCoverage(null); + }).catch(() => null); + let baseResult: CoverageSummary | null = null; + let baseHeadResult: string | null = null; + let taskHeadResult: string | null = null; + let resolvedBaseBranch: string | null = null; + if (taskResult) { + const taskWorktree = taskBranch + ? await resolveCoverageWorktree(projectRoot, taskBranch) + : null; + taskHeadResult = taskWorktree?.headCommittedAt ?? null; + resolvedBaseBranch = baseBranch + ? baseBranch + : projectRoot + ? await invoke(IPC.GetMainBranch, { projectRoot }).catch(() => null) + : null; + const baseWorktree = resolvedBaseBranch + ? await resolveBaseCoverageRoot(projectRoot, resolvedBaseBranch, repoRoot) + : null; + if (baseWorktree) { + baseHeadResult = baseWorktree.headCommittedAt; + baseResult = await invoke(IPC.GetCoverageSummary, { + repoRoot: baseWorktree.path, + reportPath: props.coverageReportPath, + }).catch(() => null); + } + } + if (!cancelled) { + batch(() => { + setCoverage((current) => + sameCoverageSummary(current, taskResult) ? current : taskResult, + ); + setBaseCoverage((current) => + sameCoverageSummary(current, baseResult) ? current : baseResult, + ); + setBaseHeadAt(baseHeadResult); + setTaskHeadAt(taskHeadResult); + setBaseBranchName(resolvedBaseBranch ?? undefined); + }); + } } finally { inFlight = false; } @@ -610,6 +1023,8 @@ export function ChangedFilesList(props: ChangedFilesListProps) { file={file} selectedCommit={props.selectedCommit} summary={coverageFiles()[row().node.path]} + comparison={coverageComparison()?.files[row().node.path]} + baseline={coverageComparison()?.baseline} hasCoverageArtifact={hasCoverageArtifact()} /> )} @@ -658,7 +1073,7 @@ export function ChangedFilesList(props: ChangedFilesListProps) { 'flex-wrap': 'wrap', }} > - 0}> +
+ + {(label) => ( + + {label()} + + )} + + 0}> + + ↕ {coverageComparison()?.impactedUnchangedFiles.length} other + +
diff --git a/src/components/MergeDialog.tsx b/src/components/MergeDialog.tsx index 9b169153..b2e8c1b5 100644 --- a/src/components/MergeDialog.tsx +++ b/src/components/MergeDialog.tsx @@ -14,6 +14,7 @@ import { ChangedFilesList } from './ChangedFilesList'; import { MergeReadinessPanel } from './MergeReadinessPanel'; import { buildMergeReadiness } from './merge-readiness'; import { theme, bannerStyle } from '../lib/theme'; +import type { CoverageComparison } from '../lib/coverage-comparison'; import type { Task } from '../store/types'; import type { ChangedFile, MergeStatus, WorktreeStatus } from '../ipc/types'; @@ -34,6 +35,7 @@ export function MergeDialog(props: MergeDialogProps) { const [rebasing, setRebasing] = createSignal(false); const [rebaseError, setRebaseError] = createSignal(''); const [rebaseSuccess, setRebaseSuccess] = createSignal(false); + const [coverageComparison, setCoverageComparison] = createSignal(null); const resourceSource = () => props.open ? { path: props.task.worktreePath, baseBranch: props.task.baseBranch } : null; @@ -91,6 +93,7 @@ export function MergeDialog(props: MergeDialogProps) { worktreeStatusLoading: worktreeStatus.loading, verification: props.task.verification, prChecks: getPrChecks(props.task.id), + coverage: coverageComparison(), }); createEffect(() => { @@ -103,6 +106,7 @@ export function MergeDialog(props: MergeDialogProps) { setRebaseSuccess(false); setMerging(false); setRebasing(false); + setCoverageComparison(null); // Drop the previous open's cached data so accessors return undefined // during refetch — otherwise unguarded reads (uncommitted-changes // warning, branch-mismatch banner) flash the stale snapshot until the @@ -444,10 +448,13 @@ export function MergeDialog(props: MergeDialogProps) { >
{/* Imported worktrees are user-owned — never offer to delete them or their branch. */} diff --git a/src/components/MergeReadinessPanel.test.ts b/src/components/MergeReadinessPanel.test.ts index b16bf7f6..85d15e19 100644 --- a/src/components/MergeReadinessPanel.test.ts +++ b/src/components/MergeReadinessPanel.test.ts @@ -44,6 +44,7 @@ describe('buildMergeReadiness', () => { expect(readiness.checks).toEqual([ expect.objectContaining({ label: 'Merge safety', status: 'pass' }), expect.objectContaining({ label: 'Verification', status: 'pass' }), + expect.objectContaining({ label: 'Coverage', status: 'neutral' }), expect.objectContaining({ label: 'PR checks', status: 'neutral' }), ]); }); @@ -153,7 +154,7 @@ describe('buildMergeReadiness', () => { expect(readiness.checks[1]).toEqual( expect.objectContaining({ status: 'warning', detail: 'test failed — 2 tests failed' }), ); - expect(readiness.checks[2]).toEqual( + expect(readiness.checks[3]).toEqual( expect.objectContaining({ status: 'warning', detail: '1 pending, 2 passing.' }), ); }); @@ -166,13 +167,289 @@ describe('buildMergeReadiness', () => { ); expect(readiness.overall).toBe('attention'); - expect(readiness.checks[2]).toEqual( + expect(readiness.checks[3]).toEqual( expect.objectContaining({ status: 'warning', detail: '1 pending, 2 passing, 1 failing.', }), ); }); + + it('reports attention for aggregate coverage regression and impacted unchanged files', () => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 78 }, + base: { state: 'available', pct: 82 }, + delta: -4, + }, + files: {}, + impactedUnchangedFiles: [ + { + path: 'src/shared.ts', + task: { state: 'available', pct: 70 }, + base: { state: 'available', pct: 80 }, + delta: -10, + }, + ], + }, + }), + ); + + expect(readiness.overall).toBe('attention'); + expect(readiness.checks[2]).toEqual( + expect.objectContaining({ + label: 'Coverage', + status: 'warning', + detail: 'Base 82% → task 78% (-4pp). 1 unchanged file also regressed.', + }), + ); + }); + + it('keeps an unchanged-file regression visible when aggregate coverage improves', () => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 84 }, + base: { state: 'available', pct: 82 }, + delta: 2, + }, + files: {}, + impactedUnchangedFiles: [ + { + path: 'src/shared.ts', + task: { state: 'available', pct: 70 }, + base: { state: 'available', pct: 80 }, + delta: -10, + }, + ], + }, + }), + ); + + expect(readiness.checks[2]).toEqual( + expect.objectContaining({ + status: 'warning', + detail: 'Base 82% → task 84% (+2pp). 1 unchanged file also regressed.', + }), + ); + }); + + it('does not attribute base-only covered files to the task', () => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 82 }, + base: { state: 'available', pct: 82 }, + delta: 0, + }, + files: {}, + impactedUnchangedFiles: [ + { + path: 'src/added-on-base.ts', + task: { state: 'file-not-present', pct: null }, + base: { state: 'available', pct: 80 }, + delta: null, + }, + ], + }, + }), + ); + + expect(readiness.overall).toBe('ready'); + expect(readiness.checks[2]).toEqual( + expect.objectContaining({ + status: 'pass', + detail: 'Base 82% → task 82% (0pp).', + }), + ); + }); + + it('does not warn for aggregate drift below the materiality threshold', () => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 81.99 }, + base: { state: 'available', pct: 82 }, + delta: -0.01, + }, + files: {}, + impactedUnchangedFiles: [], + }, + }), + ); + + expect(readiness.overall).toBe('ready'); + expect(readiness.checks[2]).toEqual( + expect.objectContaining({ + status: 'pass', + detail: 'Base 82% → task 81.99% (-0.01pp).', + }), + ); + }); + + it('warns when aggregate coverage reaches the materiality threshold', () => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 81 }, + base: { state: 'available', pct: 82 }, + delta: -1, + }, + files: {}, + impactedUnchangedFiles: [], + }, + }), + ); + + expect(readiness.checks[2]).toEqual(expect.objectContaining({ status: 'warning' })); + }); + + it('keeps coverage informational until an ahead base branch is rebased', () => { + const readiness = buildMergeReadiness( + input({ + mergeStatus: { ...cleanMergeStatus, main_ahead_count: 2 }, + coverage: { + aggregate: { + task: { state: 'available', pct: 78 }, + base: { state: 'available', pct: 82 }, + delta: -4, + }, + files: {}, + impactedUnchangedFiles: [], + }, + }), + ); + + expect(readiness.checks[2]).toEqual( + expect.objectContaining({ + status: 'neutral', + detail: 'main is 2 commits ahead; rebase and regenerate task coverage before comparing.', + }), + ); + }); + + it('keeps a stale task report neutral even when its delta is negative', () => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 78 }, + base: { state: 'available', pct: 82 }, + delta: -4, + }, + files: {}, + impactedUnchangedFiles: [], + baseline: { + baseBranch: 'main', + stale: false, + taskHeadAt: '2026-07-26T00:00:00.000Z', + taskStale: true, + }, + }, + }), + ); + + expect(readiness.checks[2]).toEqual( + expect.objectContaining({ + status: 'neutral', + detail: 'Task coverage report predates task HEAD; regenerate it before comparing.', + }), + ); + }); + + it.each([ + ['loading', 'still loading'], + ['failed', 'unavailable'], + ] as const)( + 'keeps task coverage visible but neutral when changed-file inventory is %s', + (inventoryState, detailFragment) => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 78 }, + base: { state: 'available', pct: 82 }, + delta: -4, + }, + files: {}, + impactedUnchangedFiles: [], + inventoryState, + }, + }), + ); + + expect(readiness.checks[2]).toEqual(expect.objectContaining({ status: 'neutral' })); + expect(readiness.checks[2].detail).toContain('Task 78%'); + expect(readiness.checks[2].detail).toContain(detailFragment); + expect(readiness.checks[2].detail).not.toContain('No task coverage report'); + }, + ); + + it('keeps a stale base report neutral even when its delta is negative', () => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 78 }, + base: { state: 'available', pct: 82 }, + delta: -4, + }, + files: {}, + impactedUnchangedFiles: [], + baseline: { + baseBranch: 'main', + baseHeadAt: '2026-07-26T00:00:00.000Z', + stale: true, + }, + }, + }), + ); + + expect(readiness.overall).toBe('ready'); + expect(readiness.checks[2]).toEqual( + expect.objectContaining({ + status: 'neutral', + detail: + 'Base coverage report predates main as currently checked out; regenerate it before comparing.', + }), + ); + }); + + it('keeps an unanchored base report neutral even when its delta is positive', () => { + const readiness = buildMergeReadiness( + input({ + coverage: { + aggregate: { + task: { state: 'available', pct: 84 }, + base: { state: 'available', pct: 82 }, + delta: 2, + }, + files: {}, + impactedUnchangedFiles: [], + baseline: { + baseBranch: 'main', + stale: false, + unanchored: true, + }, + }, + }), + ); + + expect(readiness.overall).toBe('ready'); + expect(readiness.checks[2]).toEqual( + expect.objectContaining({ + status: 'neutral', + detail: + 'Base coverage report cannot be anchored to main as currently checked out; comparison is informational only.', + }), + ); + }); }); describe('MergeReadinessPanel', () => { @@ -185,6 +462,8 @@ describe('MergeReadinessPanel', () => { expect(html).toContain('Merge safety'); expect(html).toContain('Verification'); expect(html).toContain('2 checks passed.'); + expect(html).toContain('Coverage'); + expect(html).toContain('No task coverage report.'); expect(html).toContain('PR checks'); expect(html).toContain('No PR checks available.'); expect(html).toContain( @@ -199,5 +478,8 @@ describe('MergeReadinessPanel', () => { expect(html).toContain( 'title="Uses checks reported for a detected GitHub pull request. Pull requests are optional, and unavailable check data is neutral."', ); + expect(html).toContain( + 'title="Compares existing task and base-branch coverage reports. Opening the dialog never runs tests or modifies either worktree."', + ); }); }); diff --git a/src/components/MergeReadinessPanel.tsx b/src/components/MergeReadinessPanel.tsx index 547d5a69..cd803334 100644 --- a/src/components/MergeReadinessPanel.tsx +++ b/src/components/MergeReadinessPanel.tsx @@ -23,6 +23,9 @@ function checkHelp(label: string): string | undefined { if (label === 'PR checks') { return 'Uses checks reported for a detected GitHub pull request. Pull requests are optional, and unavailable check data is neutral.'; } + if (label === 'Coverage') { + return 'Compares existing task and base-branch coverage reports. Opening the dialog never runs tests or modifies either worktree.'; + } return undefined; } diff --git a/src/components/TaskChangedFilesSection.tsx b/src/components/TaskChangedFilesSection.tsx index 137c26f0..77e20163 100644 --- a/src/components/TaskChangedFilesSection.tsx +++ b/src/components/TaskChangedFilesSection.tsx @@ -151,6 +151,8 @@ export function TaskChangedFilesSection(props: TaskChangedFilesSectionProps) {
0) { + return { + label: 'Coverage', + status: 'neutral', + detail: `${mergeStatus.base_branch} is ${countLabel(mergeStatus.main_ahead_count, 'commit')} ahead; rebase and regenerate task coverage before comparing.`, + }; + } + if (isBaselineInformational(coverage.baseline)) { + const baseBranch = coverage.baseline?.baseBranch ?? 'base branch'; + return { + label: 'Coverage', + status: 'neutral', + detail: coverage.baseline?.taskStale + ? 'Task coverage report predates task HEAD; regenerate it before comparing.' + : coverage.baseline?.taskUnanchored + ? 'Task coverage report cannot be anchored to task HEAD; comparison is informational only.' + : coverage.baseline?.stale + ? `Base coverage report predates ${baseBranch} as currently checked out; regenerate it before comparing.` + : `Base coverage report cannot be anchored to ${baseBranch} as currently checked out; comparison is informational only.`, + }; + } + + const regressedUnchanged = coverage.impactedUnchangedFiles.filter( + (file) => + file.base.state === 'available' && + file.task.state === 'available' && + file.delta !== null && + file.delta <= -MATERIAL_COVERAGE_DELTA, + ); + const impactedDetail = + regressedUnchanged.length > 0 + ? ` ${countLabel(regressedUnchanged.length, 'unchanged file')} also regressed.` + : ''; + return { + label: 'Coverage', + status: + aggregate.delta <= -MATERIAL_COVERAGE_DELTA || regressedUnchanged.length > 0 + ? 'warning' + : 'pass', + detail: `Base ${aggregate.base.pct}% → task ${taskPct}% (${formatCoverageDelta(aggregate.delta)}).${impactedDetail}`, + }; +} + export function buildMergeReadiness(input: MergeReadinessInput): MergeReadiness { const checks = [ mergeSafetyCheck(input), verificationCheck(input.verification), + coverageCheck(input.coverage, input.mergeStatus), prCheck(input.prChecks), ]; const overall = checks.some((check) => check.status === 'blocked') diff --git a/src/lib/coverage-comparison.test.ts b/src/lib/coverage-comparison.test.ts new file mode 100644 index 00000000..88ee312b --- /dev/null +++ b/src/lib/coverage-comparison.test.ts @@ -0,0 +1,294 @@ +import { describe, expect, it } from 'vitest'; +import type { + ChangedFile, + CoverageFileSummary, + CoverageMetricSummary, + CoverageSummary, +} from '../ipc/types'; +import { + buildCoverageComparison, + formatCoverageDelta, + MATERIAL_COVERAGE_DELTA, +} from './coverage-comparison'; + +function metric(pct: number, total = 100): CoverageMetricSummary { + return { + total, + covered: total === 0 ? 0 : Math.round((pct / 100) * total), + skipped: 0, + pct, + }; +} + +function file(path: string, pct: number, total = 100): CoverageFileSummary { + const value = metric(pct, total); + return { + path, + lines: value, + statements: value, + functions: value, + branches: value, + }; +} + +function report( + totalPct: number, + files: CoverageFileSummary[], + generatedAt = '2026-07-25T00:00:00.000Z', +): CoverageSummary { + const total = metric(totalPct); + return { + format: 'istanbul-summary', + generatedAt, + reportPath: '/repo/coverage/coverage-summary.json', + totals: { + lines: total, + statements: total, + functions: total, + branches: total, + }, + files: Object.fromEntries(files.map((entry) => [entry.path, entry])), + }; +} + +function changed(path: string, status = 'M', previousPath?: string): ChangedFile { + return { + path, + previous_path: previousPath, + lines_added: 1, + lines_removed: 1, + status, + committed: true, + }; +} + +describe('buildCoverageComparison', () => { + it('calculates aggregate and per-file positive, negative, and zero deltas', () => { + const base = report(80, [ + file('src/up.ts', 70), + file('src/down.ts', 90), + file('src/same.ts', 75), + ]); + const task = report(82.25, [ + file('src/up.ts', 80), + file('src/down.ts', 85), + file('src/same.ts', 75), + ]); + + const result = buildCoverageComparison(task, base, [ + changed('src/up.ts'), + changed('src/down.ts'), + changed('src/same.ts'), + ]); + + expect(result.aggregate.delta).toBe(2.25); + expect(result.files['src/up.ts'].delta).toBe(10); + expect(result.files['src/down.ts'].delta).toBe(-5); + expect(result.files['src/same.ts'].delta).toBe(0); + }); + + it('defines new, deleted, and renamed file behavior', () => { + const base = report(80, [file('src/deleted.ts', 70), file('src/old-name.ts', 60)]); + const task = report(82, [file('src/new.ts', 90), file('src/new-name.ts', 75)]); + + const result = buildCoverageComparison(task, base, [ + changed('src/new.ts', 'A'), + changed('src/deleted.ts', 'D'), + changed('src/new-name.ts', 'R', 'src/old-name.ts'), + ]); + + expect(result.files['src/new.ts']).toMatchObject({ + kind: 'new', + task: { state: 'available', pct: 90 }, + base: { state: 'file-not-present', pct: null }, + delta: null, + }); + expect(result.files['src/deleted.ts']).toMatchObject({ + kind: 'deleted', + task: { state: 'file-not-present', pct: null }, + base: { state: 'available', pct: 70 }, + delta: null, + }); + expect(result.files['src/new-name.ts']).toMatchObject({ + kind: 'renamed', + basePath: 'src/old-name.ts', + delta: 15, + }); + }); + + it('distinguishes no report, file absence, and no executable lines', () => { + const noLines = file('src/no-lines.ts', 100, 0); + const task = report(80, [noLines]); + + const noBase = buildCoverageComparison(task, null, [changed('src/no-lines.ts')]); + expect(noBase.aggregate.base.state).toBe('no-report'); + expect(noBase.files['src/no-lines.ts'].task.state).toBe('no-executable-lines'); + expect(noBase.files['src/no-lines.ts'].base.state).toBe('no-report'); + + const missing = buildCoverageComparison(task, report(75, []), [changed('src/missing.ts')]); + expect(missing.files['src/missing.ts'].task.state).toBe('file-not-present'); + expect(missing.files['src/missing.ts'].base.state).toBe('file-not-present'); + }); + + it('reports materially impacted unchanged files without duplicating changed paths', () => { + const base = report(80, [ + file('src/changed.ts', 80), + file('src/regressed.ts', 90), + file('src/noise.ts', 80), + ]); + const task = report(78, [ + file('src/changed.ts', 70), + file('src/regressed.ts', 82), + file('src/noise.ts', 80 + MATERIAL_COVERAGE_DELTA / 2), + ]); + + const result = buildCoverageComparison(task, base, [changed('src/changed.ts')]); + + expect(result.impactedUnchangedFiles).toEqual([ + { + path: 'src/regressed.ts', + task: { state: 'available', pct: 82 }, + base: { state: 'available', pct: 90 }, + delta: -8, + }, + ]); + }); + + it('retains base-only unchanged files as unavailable task coverage', () => { + const base = report(80, [file('src/base-only.ts', 40)]); + const task = report(85, []); + + const result = buildCoverageComparison(task, base, []); + + expect(result.impactedUnchangedFiles).toEqual([ + { + path: 'src/base-only.ts', + task: { state: 'file-not-present', pct: null }, + base: { state: 'available', pct: 40 }, + delta: null, + }, + ]); + }); + + it('retains available-to-no-lines transitions for unchanged files', () => { + const base = report(80, [file('src/no-lines-now.ts', 75)]); + const task = report(85, [file('src/no-lines-now.ts', 100, 0)]); + + const result = buildCoverageComparison(task, base, []); + + expect(result.impactedUnchangedFiles).toEqual([ + { + path: 'src/no-lines-now.ts', + task: { state: 'no-executable-lines', pct: null }, + base: { state: 'available', pct: 75 }, + delta: null, + }, + ]); + }); + + it('does not classify non-source Git-changed paths as unchanged coverage impacts', () => { + const base = report(80, [file('src/example.test.ts', 90)]); + const task = report(80, [file('src/example.test.ts', 60)]); + + const result = buildCoverageComparison(task, base, [changed('src/example.test.ts')]); + + expect(result.files['src/example.test.ts'].delta).toBe(-30); + expect(result.impactedUnchangedFiles).toEqual([]); + }); + + it('marks a base report older than the base branch HEAD as stale', () => { + const result = buildCoverageComparison( + report(82, []), + report(80, []), + [], + '2026-07-26T00:00:00.000Z', + 'main', + ); + + expect(result.baseline).toEqual({ + baseBranch: 'main', + baseHeadAt: '2026-07-26T00:00:00.000Z', + stale: true, + }); + }); + + it('accepts a base report generated after the base branch HEAD', () => { + const result = buildCoverageComparison( + report(82, []), + report(80, []), + [], + '2026-07-24T00:00:00.000Z', + 'main', + ); + + expect(result.baseline).toEqual({ + baseBranch: 'main', + baseHeadAt: '2026-07-24T00:00:00.000Z', + stale: false, + }); + }); + + it('marks a present base report with an unknown base branch HEAD as unanchored', () => { + const result = buildCoverageComparison(report(82, []), report(80, []), [], null, 'main'); + + expect(result.baseline).toEqual({ + baseBranch: 'main', + stale: false, + unanchored: true, + }); + }); + + it('marks a task report older than task HEAD as stale', () => { + const result = buildCoverageComparison( + report(82, [], '2026-07-25T00:00:00.000Z'), + report(80, []), + [], + '2026-07-24T00:00:00.000Z', + 'main', + '2026-07-26T00:00:00.000Z', + ); + + expect(result.baseline).toMatchObject({ + taskHeadAt: '2026-07-26T00:00:00.000Z', + taskStale: true, + }); + }); + + it('marks a task report with an unknown task HEAD as unanchored', () => { + const result = buildCoverageComparison( + report(82, []), + report(80, []), + [], + '2026-07-24T00:00:00.000Z', + 'main', + null, + ); + + expect(result.baseline).toMatchObject({ + taskStale: false, + taskUnanchored: true, + }); + }); + + it.each(['toString', 'constructor', '__proto__'])( + 'treats a missing report entry named %s as absent instead of reading Object.prototype', + (path) => { + const result = buildCoverageComparison(report(80, []), report(80, []), [changed(path)]); + + expect(Object.getPrototypeOf(result.files)).toBeNull(); + expect(result.files[path]).toMatchObject({ + path, + task: { state: 'file-not-present', pct: null }, + base: { state: 'file-not-present', pct: null }, + }); + }, + ); +}); + +describe('formatCoverageDelta', () => { + it('formats signed percentage-point values', () => { + expect(formatCoverageDelta(2.345)).toBe('+2.35pp'); + expect(formatCoverageDelta(-1.2)).toBe('-1.2pp'); + expect(formatCoverageDelta(0)).toBe('0pp'); + }); +}); diff --git a/src/lib/coverage-comparison.ts b/src/lib/coverage-comparison.ts new file mode 100644 index 00000000..1011e3d2 --- /dev/null +++ b/src/lib/coverage-comparison.ts @@ -0,0 +1,205 @@ +import type { ChangedFile, CoverageSummary } from '../ipc/types'; + +export type CoverageValueState = + | 'available' + | 'no-report' + | 'file-not-present' + | 'no-executable-lines'; + +export interface CoverageValue { + state: CoverageValueState; + pct: number | null; +} + +export type CoverageFileChangeKind = 'changed' | 'new' | 'deleted' | 'renamed'; + +export interface CoverageFileComparison { + path: string; + basePath: string; + kind: CoverageFileChangeKind; + task: CoverageValue; + base: CoverageValue; + delta: number | null; +} + +export interface ImpactedCoverageFile { + path: string; + task: CoverageValue; + base: CoverageValue; + delta: number | null; +} + +export interface CoverageComparison { + aggregate: { + task: CoverageValue; + base: CoverageValue; + delta: number | null; + }; + files: Record; + impactedUnchangedFiles: ImpactedCoverageFile[]; + inventoryState?: 'available' | 'loading' | 'failed'; + baseline?: { + baseBranch?: string; + baseHeadAt?: string; + taskHeadAt?: string; + stale: boolean; + unanchored?: boolean; + taskStale?: boolean; + taskUnanchored?: boolean; + }; +} + +export const MATERIAL_COVERAGE_DELTA = 1; + +export function isBaselineInformational(baseline: CoverageComparison['baseline']): boolean { + return Boolean( + baseline?.stale || baseline?.unanchored || baseline?.taskStale || baseline?.taskUnanchored, + ); +} + +function roundPercentage(value: number): number { + return Math.round(value * 100) / 100; +} + +function aggregateValue(summary: CoverageSummary | null): CoverageValue { + if (!summary) return { state: 'no-report', pct: null }; + if (summary.totals.lines.total === 0) { + return { state: 'no-executable-lines', pct: null }; + } + return { state: 'available', pct: summary.totals.lines.pct }; +} + +function fileValue( + summary: CoverageSummary | null, + filePath: string, + forceMissing = false, +): CoverageValue { + if (!summary) return { state: 'no-report', pct: null }; + const file = + forceMissing || !Object.hasOwn(summary.files, filePath) ? undefined : summary.files[filePath]; + if (!file) return { state: 'file-not-present', pct: null }; + if (file.lines.total === 0) return { state: 'no-executable-lines', pct: null }; + return { state: 'available', pct: file.lines.pct }; +} + +function coverageDelta(task: CoverageValue, base: CoverageValue): number | null { + if (task.state !== 'available' || base.state !== 'available') return null; + return roundPercentage((task.pct ?? 0) - (base.pct ?? 0)); +} + +function changeKind(file: ChangedFile): CoverageFileChangeKind { + if (file.status === 'D') return 'deleted'; + if (file.status === 'R') return 'renamed'; + if (file.status === 'A' || file.status === '?' || file.status === 'C') return 'new'; + return 'changed'; +} + +export function formatCoverageDelta(delta: number): string { + const rounded = roundPercentage(delta); + if (rounded > 0) return `+${rounded}pp`; + return `${rounded}pp`; +} + +export function buildCoverageComparison( + taskSummary: CoverageSummary | null, + baseSummary: CoverageSummary | null, + changedFiles: ChangedFile[], + baseHeadAt?: string | null, + baseBranch?: string, + taskHeadAt?: string | null, +): CoverageComparison { + const taskAggregate = aggregateValue(taskSummary); + const baseAggregate = aggregateValue(baseSummary); + const files = Object.create(null) as Record; + const changedPaths = new Set(); + + for (const file of changedFiles) { + const kind = changeKind(file); + const basePath = file.previous_path ?? file.path; + changedPaths.add(file.path); + changedPaths.add(basePath); + + const task = fileValue(taskSummary, file.path, kind === 'deleted'); + const base = fileValue(baseSummary, basePath, kind === 'new'); + files[file.path] = { + path: file.path, + basePath, + kind, + task, + base, + delta: coverageDelta(task, base), + }; + } + + const impactedUnchangedFiles: ImpactedCoverageFile[] = []; + if (taskSummary && baseSummary) { + const reportPaths = new Set([ + ...Object.keys(taskSummary.files), + ...Object.keys(baseSummary.files), + ]); + for (const filePath of reportPaths) { + if (changedPaths.has(filePath)) continue; + const task = fileValue(taskSummary, filePath); + const base = fileValue(baseSummary, filePath); + const delta = coverageDelta(task, base); + if (task.state === base.state && delta === null) continue; + if (delta !== null && Math.abs(delta) < MATERIAL_COVERAGE_DELTA) continue; + impactedUnchangedFiles.push({ + path: filePath, + task, + base, + delta, + }); + } + } + + impactedUnchangedFiles.sort( + (a, b) => + Number(b.delta === null) - Number(a.delta === null) || + Math.abs(b.delta ?? 0) - Math.abs(a.delta ?? 0) || + a.path.localeCompare(b.path), + ); + + const baseHeadTime = baseHeadAt ? Date.parse(baseHeadAt) : Number.NaN; + const baseGeneratedTime = baseSummary ? Date.parse(baseSummary.generatedAt) : Number.NaN; + const taskHeadTime = taskHeadAt ? Date.parse(taskHeadAt) : Number.NaN; + const taskGeneratedTime = taskSummary ? Date.parse(taskSummary.generatedAt) : Number.NaN; + const taskAnchor = + taskHeadAt === undefined + ? {} + : taskHeadAt && Number.isFinite(taskHeadTime) && Number.isFinite(taskGeneratedTime) + ? { + taskHeadAt, + taskStale: taskGeneratedTime < taskHeadTime, + } + : { + taskStale: false, + taskUnanchored: true, + }; + const baseline = !baseSummary + ? undefined + : baseHeadAt && Number.isFinite(baseHeadTime) && Number.isFinite(baseGeneratedTime) + ? { + ...(baseBranch ? { baseBranch } : {}), + baseHeadAt, + stale: baseGeneratedTime < baseHeadTime, + ...taskAnchor, + } + : { + ...(baseBranch ? { baseBranch } : {}), + stale: false, + unanchored: true, + ...taskAnchor, + }; + + return { + aggregate: { + task: taskAggregate, + base: baseAggregate, + delta: coverageDelta(taskAggregate, baseAggregate), + }, + files, + impactedUnchangedFiles, + baseline, + }; +} diff --git a/vitest.client.config.ts b/vitest.client.config.ts new file mode 100644 index 00000000..aba8afc5 --- /dev/null +++ b/vitest.client.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vitest/config'; +import solidPlugin from 'vite-plugin-solid'; + +export default defineConfig({ + plugins: [solidPlugin({ ssr: false })], + test: { + environment: 'happy-dom', + include: ['src/**/*.client.test.tsx'], + }, +});