diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index eecf11344..97f8eee82 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -1,17 +1,38 @@ +/* eslint-disable max-lines -- Why: remove/list/sparse cleanup tests share one git runner + mock harness, and splitting them would duplicate setup without a clearer boundary. */ +import type * as FsPromises from 'fs/promises' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({ +const { + gitExecFileAsyncMock, + gitExecFileSyncMock, + translateWslOutputPathsMock, + statMock, + resolveGitDirMock +} = vi.hoisted(() => ({ gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn() + gitExecFileSyncMock: vi.fn(), + translateWslOutputPathsMock: vi.fn((output: string) => output), + statMock: vi.fn(), + resolveGitDirMock: vi.fn() })) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileSync: gitExecFileSyncMock, - translateWslOutputPaths: (output: string) => output + translateWslOutputPaths: translateWslOutputPathsMock })) -import { removeWorktree } from './worktree' +vi.mock('./status', () => ({ + resolveGitDir: resolveGitDirMock +})) + +vi.mock('fs/promises', async () => { + const actual = await vi.importActual('fs/promises') + return { ...actual, stat: statMock } +}) + +import { addSparseWorktree, listWorktrees, removeWorktree } from './worktree' type MockResult = { error?: Error @@ -54,11 +75,19 @@ describe('removeWorktree', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) + statMock.mockReset() + // Default: no worktree has a sparse-checkout config file. Tests that need + // sparse detection override this. + statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + resolveGitDirMock.mockReset() + resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) }) it('removes the worktree, prunes stale refs, and deletes its local branch', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -68,7 +97,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -92,7 +121,7 @@ branch refs/heads/main it('skips branch deletion when another worktree still points at the branch', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -106,7 +135,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -125,7 +154,7 @@ branch refs/heads/feature/test expect.arrayContaining([ 'git worktree remove /repo-feature', 'git worktree prune', - 'git worktree list --porcelain' + 'git worktree list --porcelain -z' ]) ) expect(calls).not.toContain('git branch -D feature/test') @@ -134,7 +163,7 @@ branch refs/heads/feature/test it('deletes the branch after prune removes stale sibling worktree entries', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -149,7 +178,7 @@ branch refs/heads/feature/test prunable gitdir file points to non-existent location ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -172,7 +201,7 @@ branch refs/heads/main it('passes --force before the worktree path when forced removal is requested', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -182,7 +211,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -197,7 +226,7 @@ branch refs/heads/main it('matches Windows worktree paths before deleting the branch', async () => { mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree C:/repo HEAD abc123 branch refs/heads/main @@ -207,7 +236,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree C:/repo HEAD abc123 branch refs/heads/main @@ -230,7 +259,7 @@ branch refs/heads/main it('keeps removal successful when branch cleanup fails', async () => { const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}) mockGitCommands({ - 'git worktree list --porcelain': { + 'git worktree list --porcelain -z': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -240,7 +269,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain#2': { + 'git worktree list --porcelain -z#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -262,3 +291,181 @@ branch refs/heads/main warnSpy.mockRestore() }) }) + +describe('listWorktrees', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) + statMock.mockReset() + // Default: no worktree has a sparse-checkout config file. Tests that need + // sparse detection override this. + statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + resolveGitDirMock.mockReset() + resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) + }) + + it('translates parsed path fields from NUL-delimited porcelain output', async () => { + gitExecFileAsyncMock.mockResolvedValueOnce({ + stdout: + 'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' + + 'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0sparse\0\0' + }) + translateWslOutputPathsMock.mockImplementation((output: string) => { + expect(output).not.toContain('\0') + return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + }) + + await expect(listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo')).resolves.toEqual([ + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + // Why: the non-sparse main worktree gets an fs probe of its sparse config + // file; the linked worktree short-circuits on the parsed `sparse` token and + // does not. Only one git subprocess runs regardless of worktree count. + expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) + expect(statMock).toHaveBeenCalledTimes(1) + expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) + }) + + it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => { + gitExecFileAsyncMock.mockImplementation((args: string[]) => { + if (args.join(' ') === 'worktree list --porcelain -z') { + return { + stdout: + 'worktree /home/me/repo\0HEAD abc123\0branch refs/heads/main\0\0' + + 'worktree /home/me/repo-feature\0HEAD def456\0branch refs/heads/feature/test\0\0', + stderr: '' + } + } + throw new Error(`Unexpected git call: ${args.join(' ')}`) + }) + translateWslOutputPathsMock.mockImplementation((output: string) => { + expect(output).not.toContain('\0') + return output.replace('/home/me/', '\\\\wsl.localhost\\Ubuntu\\home\\me\\') + }) + const featureWorktreePath = '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature' + resolveGitDirMock.mockImplementation(async (worktreePath: string) => + worktreePath === featureWorktreePath + ? `${featureWorktreePath}\\.git-worktrees\\feature` + : `${worktreePath}/.git` + ) + statMock.mockImplementation(async (filePath: string) => { + if (filePath.includes('repo-feature') && filePath.includes('sparse-checkout')) { + return { isFile: () => true, size: 32 } + } + throw Object.assign(new Error('ENOENT'), { code: 'ENOENT' }) + }) + + const worktrees = await listWorktrees('\\\\wsl.localhost\\Ubuntu\\home\\me\\repo') + + expect(worktrees).toEqual([ + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', + head: 'abc123', + branch: 'refs/heads/main', + isBare: false, + isMainWorktree: true + }, + { + path: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature', + head: 'def456', + branch: 'refs/heads/feature/test', + isBare: false, + isSparse: true, + isMainWorktree: false + } + ]) + expect(resolveGitDirMock).toHaveBeenCalledWith(featureWorktreePath) + // Why: the detection path must not spawn a git subprocess per worktree — + // the perf regression in #1131 came from `git sparse-checkout list` firing + // on every poll. + expect(getGitCalls()).toEqual(['git worktree list --porcelain -z']) + }) +}) + +describe('addSparseWorktree', () => { + beforeEach(() => { + gitExecFileAsyncMock.mockReset() + gitExecFileSyncMock.mockReset() + translateWslOutputPathsMock.mockReset() + translateWslOutputPathsMock.mockImplementation((output: string) => output) + statMock.mockReset() + // Default: no worktree has a sparse-checkout config file. Tests that need + // sparse detection override this. + statMock.mockRejectedValue(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })) + resolveGitDirMock.mockReset() + resolveGitDirMock.mockImplementation(async (worktreePath: string) => `${worktreePath}/.git`) + }) + + it('separates sparse checkout directory operands from options', async () => { + gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' }) + + await addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['-docs', 'src']) + + expect(gitExecFileAsyncMock).toHaveBeenCalledWith( + ['sparse-checkout', 'set', '--', '-docs', 'src'], + { cwd: '/repo-feature' } + ) + }) + + it('removes the worktree and deletes the created branch when sparse setup fails', async () => { + mockGitCommands({ + 'git sparse-checkout set -- packages/web': { + error: new Error('sparse setup failed') + }, + 'git worktree list --porcelain -z': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main + +worktree /repo-feature +HEAD def456 +branch refs/heads/feature/test +` + }, + 'git worktree list --porcelain -z#2': { + stdout: `worktree /repo +HEAD abc123 +branch refs/heads/main +` + } + }) + + await expect( + addSparseWorktree('/repo', '/repo-feature', 'feature/test', ['packages/web']) + ).rejects.toThrow('sparse setup failed') + + const calls = getGitCalls() + expect(calls).toEqual( + expect.arrayContaining([ + 'git worktree add --no-checkout -b feature/test /repo-feature', + 'git sparse-checkout init --cone', + 'git sparse-checkout set -- packages/web', + 'git worktree remove --force /repo-feature', + 'git worktree prune', + 'git branch -D feature/test' + ]) + ) + expectGitCallOrder( + calls, + 'git sparse-checkout set -- packages/web', + 'git worktree remove --force /repo-feature' + ) + expectGitCallOrder(calls, 'git worktree prune', 'git branch -D feature/test') + }) +}) diff --git a/src/main/git/status.ts b/src/main/git/status.ts index 4e62e94f0..8678cbe74 100644 --- a/src/main/git/status.ts +++ b/src/main/git/status.ts @@ -268,7 +268,7 @@ export async function detectConflictOperation(worktreePath: string): Promise { +export async function resolveGitDir(worktreePath: string): Promise { const dotGitPath = path.join(worktreePath, '.git') try { diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index c109d168d..b77b89e33 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1,6 +1,12 @@ -import { posix, win32 } from 'path' +import { stat } from 'fs/promises' +import { join, posix, win32 } from 'path' import type { GitWorktreeInfo } from '../../shared/types' import { gitExecFileAsync, translateWslOutputPaths } from './runner' +import { resolveGitDir } from './status' + +type SparseWorktreeCreateError = Error & { + cleanupFailed?: boolean +} function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') @@ -29,21 +35,23 @@ function looksLikeWindowsPath(pathValue: string): boolean { */ export function parseWorktreeList(output: string): GitWorktreeInfo[] { const worktrees: GitWorktreeInfo[] = [] - // [Fix]: Use /\r?\n\r?\n/ to handle both LF and CRLF (\r\n) line endings, - // which are common when running git on Windows. - const blocks = output.trim().split(/\r?\n\r?\n/) + const blocks = output.includes('\0') + ? parseNullDelimitedWorktreeBlocks(output) + : output + .trim() + .split(/\r?\n\r?\n/) + .map((block) => block.trim().split(/\r?\n/)) - for (const block of blocks) { - if (!block.trim()) { + for (const lines of blocks) { + if (lines.length === 0) { continue } - // [Fix]: Use /\r?\n/ to handle both LF and CRLF (\r\n) line endings. - const lines = block.trim().split(/\r?\n/) let path = '' let head = '' let branch = '' let isBare = false + let isSparse = false for (const line of lines) { if (line.startsWith('worktree ')) { @@ -54,12 +62,21 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { branch = line.slice('branch '.length) } else if (line === 'bare') { isBare = true + } else if (line === 'sparse') { + isSparse = true } } if (path) { // `git worktree list` always emits the main working tree first. - worktrees.push({ path, head, branch, isBare, isMainWorktree: worktrees.length === 0 }) + worktrees.push({ + path, + head, + branch, + isBare, + ...(isSparse ? { isSparse } : {}), + isMainWorktree: worktrees.length === 0 + }) } } @@ -71,14 +88,24 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { */ export async function listWorktrees(repoPath: string): Promise { try { - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { cwd: repoPath }) - // Why: when git runs inside WSL, worktree paths are Linux-native - // (e.g. /home/user/repo). Translate them back to Windows UNC paths - // so the rest of Orca can access them via Node fs APIs. - const translated = translateWslOutputPaths(stdout, repoPath) - return parseWorktreeList(translated) + // Why: WSL path translation is line-oriented, but `-z` porcelain output is + // NUL-delimited. Parse first so only complete path fields are translated. + const worktrees = parseWorktreeList(stdout).map((worktree) => { + const translatedPath = translateWorktreePath(worktree.path, repoPath) + return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } + }) + return Promise.all( + worktrees.map(async (worktree) => { + if (worktree.isBare || worktree.isSparse) { + return worktree + } + const isSparse = await detectSparseCheckout(worktree.path) + return isSparse ? { ...worktree, isSparse } : worktree + }) + ) } catch { return [] } @@ -96,7 +123,8 @@ export async function addWorktree( worktreePath: string, branch: string, baseBranch?: string, - refreshLocalBaseRef = false + refreshLocalBaseRef = false, + noCheckout = false ): Promise { // Why: Some users want Orca-created worktrees to make plain commands like // `git diff main...HEAD` work out of the box, while others do not want @@ -152,13 +180,49 @@ export async function addWorktree( } } - const args = ['worktree', 'add', '-b', branch, worktreePath] + const args = ['worktree', 'add'] + if (noCheckout) { + args.push('--no-checkout') + } + args.push('-b', branch, worktreePath) if (baseBranch) { args.push(baseBranch) } await gitExecFileAsync(args, { cwd: repoPath }) } +export async function addSparseWorktree( + repoPath: string, + worktreePath: string, + branch: string, + directories: string[], + baseBranch?: string, + refreshLocalBaseRef = false +): Promise { + let created = false + try { + await addWorktree(repoPath, worktreePath, branch, baseBranch, refreshLocalBaseRef, true) + created = true + await gitExecFileAsync(['sparse-checkout', 'init', '--cone'], { cwd: worktreePath }) + await gitExecFileAsync(['sparse-checkout', 'set', '--', ...directories], { cwd: worktreePath }) + await gitExecFileAsync(['checkout', branch], { cwd: worktreePath }) + } catch (error) { + const wrapped: SparseWorktreeCreateError = + error instanceof Error ? (error as SparseWorktreeCreateError) : new Error(String(error)) + if (created) { + try { + await removeWorktree(repoPath, worktreePath, true) + } catch { + wrapped.cleanupFailed = true + // Why: the user needs to know that manual cleanup may be required — + // otherwise a half-created worktree silently lingers on disk. + wrapped.message = `${wrapped.message} (cleanup also failed — the partially created worktree at "${worktreePath}" may need manual removal)` + } + } + throw wrapped + } +} + /** * Remove a worktree. */ @@ -208,3 +272,57 @@ export async function removeWorktree( ) } } + +function parseNullDelimitedWorktreeBlocks(output: string): string[][] { + const blocks: string[][] = [] + let current: string[] = [] + + for (const token of output.split('\0')) { + if (!token) { + if (current.length > 0) { + blocks.push(current) + current = [] + } + continue + } + current.push(token) + } + + if (current.length > 0) { + blocks.push(current) + } + + return blocks +} + +function translateWorktreePath(worktreePath: string, repoPath: string): string { + const prefix = 'worktree ' + const translated = translateWslOutputPaths(`${prefix}${worktreePath}`, repoPath) + return translated.startsWith(prefix) ? translated.slice(prefix.length) : worktreePath +} + +async function detectSparseCheckout(worktreePath: string): Promise { + // Why: `listWorktrees` runs on every 3-second git-status poll and on every + // worktree refresh, so this probe fires N times per poll for N worktrees. + // The previous `git sparse-checkout list` subprocess made that N*poll extra + // git processes, which regressed app responsiveness on machines with many + // worktrees (see PR #1131 revert in #1290). A single fs.stat on the + // per-worktree sparse-checkout config file is ~two orders of magnitude + // cheaper and has the same truthiness semantics: Git writes this file when + // sparse checkout is enabled for the worktree and does not write it + // otherwise. + // + // Why per-worktree gitdir and not `/.git/info/sparse-checkout`: + // linked worktrees have a `.git` file that points at + // `/.git/worktrees/`, and that is where Git stores the + // worktree-local sparse-checkout config. `core.sparseCheckout` itself is + // shared across all worktrees, so the presence of the config file is the + // correct per-worktree signal. + try { + const gitDir = await resolveGitDir(worktreePath) + const stats = await stat(join(gitDir, 'info', 'sparse-checkout')) + return stats.isFile() && stats.size > 0 + } catch { + return false + } +} diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts new file mode 100644 index 000000000..71ab88b6a --- /dev/null +++ b/src/main/ipc/repos-sparse-presets.test.ts @@ -0,0 +1,223 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type * as CryptoModule from 'crypto' +import type { SparsePreset } from '../../shared/types' + +const { handleMock, randomUUIDMock, mockStore } = vi.hoisted(() => ({ + handleMock: vi.fn(), + randomUUIDMock: vi.fn(() => 'preset-new'), + mockStore: { + getRepos: vi.fn().mockReturnValue([]), + addRepo: vi.fn(), + removeRepo: vi.fn(), + getRepo: vi.fn(), + updateRepo: vi.fn(), + getSparsePresets: vi.fn(), + saveSparsePreset: vi.fn(), + removeSparsePreset: vi.fn() + } +})) + +vi.mock('crypto', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + randomUUID: randomUUIDMock + } +}) + +vi.mock('electron', () => ({ + dialog: { showOpenDialog: vi.fn() }, + ipcMain: { + handle: handleMock, + removeHandler: vi.fn() + } +})) + +vi.mock('../git/repo', () => ({ + isGitRepo: vi.fn().mockReturnValue(true), + getGitUsername: vi.fn().mockReturnValue(''), + getRepoName: vi.fn().mockImplementation((path: string) => path.split('/').pop()), + getBaseRefDefault: vi.fn().mockResolvedValue('origin/main'), + searchBaseRefs: vi.fn().mockResolvedValue([]), + BASE_REF_SEARCH_ARGS: ['for-each-ref'], + filterBaseRefSearchOutput: vi.fn().mockReturnValue([]) +})) + +vi.mock('./filesystem-auth', () => ({ + rebuildAuthorizedRootsCache: vi.fn().mockResolvedValue(undefined) +})) + +vi.mock('../providers/ssh-git-dispatch', () => ({ + getSshGitProvider: vi.fn() +})) + +vi.mock('./ssh', () => ({ + getActiveMultiplexer: vi.fn() +})) + +import { registerRepoHandlers } from './repos' + +type HandlerMap = Map unknown> + +function makePreset( + overrides: Partial & { id: string; repoId: string } +): SparsePreset { + return { + name: overrides.id, + directories: ['packages/web'], + createdAt: 10, + updatedAt: 20, + ...overrides + } +} + +describe('sparse preset repo IPC handlers', () => { + const handlers: HandlerMap = new Map() + const mainWindow = { + isDestroyed: () => false, + webContents: { send: vi.fn() } + } + + beforeEach(() => { + handlers.clear() + handleMock.mockReset() + handleMock.mockImplementation((channel: string, handler: (...a: unknown[]) => unknown) => { + handlers.set(channel, handler) + }) + randomUUIDMock.mockReturnValue('preset-new') + mainWindow.webContents.send.mockReset() + mockStore.getRepo.mockReset().mockReturnValue({ + id: 'repo-1', + path: '/repo', + displayName: 'repo', + badgeColor: '#000', + addedAt: 0 + }) + mockStore.getSparsePresets.mockReset().mockReturnValue([]) + mockStore.saveSparsePreset.mockReset().mockImplementation((preset: SparsePreset) => preset) + mockStore.removeSparsePreset.mockReset() + + registerRepoHandlers(mainWindow as never, mockStore as never) + }) + + it('normalizes and de-duplicates saved sparse preset directories', () => { + const saved = handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: ' Web preset ', + directories: [' packages/web ', 'apps\\api\\', 'packages/web/', '.', ''] + }) + + expect(mockStore.saveSparsePreset).toHaveBeenCalledWith( + expect.objectContaining({ + id: 'preset-new', + repoId: 'repo-1', + name: 'Web preset', + directories: ['packages/web', 'apps/api'] + }) + ) + expect(saved).toEqual( + expect.objectContaining({ + name: 'Web preset', + directories: ['packages/web', 'apps/api'] + }) + ) + expect(mainWindow.webContents.send).toHaveBeenCalledWith('sparsePresets:changed', { + repoId: 'repo-1' + }) + }) + + it('preserves createdAt and id when editing an existing preset', () => { + mockStore.getSparsePresets.mockReturnValue([ + makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Old', createdAt: 123 }) + ]) + + const saved = handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + id: 'preset-1', + name: 'New', + directories: ['packages/new'] + }) + + expect(saved).toEqual( + expect.objectContaining({ + id: 'preset-1', + name: 'New', + createdAt: 123, + directories: ['packages/new'] + }) + ) + }) + + it('rejects invalid sparse preset saves before persistence', () => { + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['../secrets'] + }) + ).toThrow('Preset directories must be repo-relative paths.') + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: ' ', + directories: ['packages/web'] + }) + ).toThrow('Preset name is required.') + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['.', ''] + }) + ).toThrow('Preset must have at least one directory.') + + expect(mockStore.saveSparsePreset).not.toHaveBeenCalled() + }) + + it.each([ + '/Users/me/repo/packages/web', + 'C:\\repo\\packages\\web', + '\\\\server\\share\\repo', + '\\repo\\packages\\web' + ])('rejects absolute sparse preset directory before normalization: %s', (directory) => { + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'repo-1', + name: 'Web', + directories: ['packages/web', directory] + }) + ).toThrow('Preset directories must be repo-relative paths.') + + expect(mockStore.saveSparsePreset).not.toHaveBeenCalled() + }) + + it('rejects sparse preset saves for unknown repos', () => { + mockStore.getRepo.mockReturnValue(null) + + expect(() => + handlers.get('sparsePresets:save')!(null, { + repoId: 'missing', + name: 'Web', + directories: ['packages/web'] + }) + ).toThrow('Repo "missing" not found') + }) + + it('rejects sparse preset removals for unknown repos before persistence', () => { + mockStore.getRepo.mockReturnValue(null) + + expect(() => + handlers.get('sparsePresets:remove')!(null, { + repoId: 'missing', + presetId: 'preset-1' + }) + ).toThrow('Repo "missing" not found') + + expect(mockStore.removeSparsePreset).not.toHaveBeenCalled() + expect(mainWindow.webContents.send).not.toHaveBeenCalledWith('sparsePresets:changed', { + repoId: 'missing' + }) + }) +}) diff --git a/src/main/ipc/repos.ts b/src/main/ipc/repos.ts index b59aa8fb7..3f1b13df5 100644 --- a/src/main/ipc/repos.ts +++ b/src/main/ipc/repos.ts @@ -5,7 +5,7 @@ import type { BrowserWindow } from 'electron' import { dialog, ipcMain } from 'electron' import { randomUUID } from 'crypto' import type { Store } from '../persistence' -import type { Repo, BaseRefDefaultResult } from '../../shared/types' +import type { Repo, BaseRefDefaultResult, SparsePreset } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { REPO_COLORS } from '../../shared/constants' import { rebuildAuthorizedRootsCache } from './filesystem-auth' @@ -28,6 +28,7 @@ import { } from '../git/repo' import { getSshGitProvider } from '../providers/ssh-git-dispatch' import { getActiveMultiplexer } from './ssh' +import { normalizeSparseDirectories } from './sparse-checkout-directories' // Why: module-scoped so the abort handle survives window re-creation on macOS. // registerRepoHandlers is called again when a new BrowserWindow is created, @@ -50,6 +51,9 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v ipcMain.removeHandler('repos:getBaseRefDefault') ipcMain.removeHandler('repos:searchBaseRefs') ipcMain.removeHandler('repos:addRemote') + ipcMain.removeHandler('sparsePresets:list') + ipcMain.removeHandler('sparsePresets:save') + ipcMain.removeHandler('sparsePresets:remove') ipcMain.handle('repos:list', () => { return store.getRepos() @@ -219,6 +223,55 @@ export function registerRepoHandlers(mainWindow: BrowserWindow, store: Store): v } ) + // ── Sparse presets ───────────────────────────────────────────── + // Why: presets are repo-scoped reusable directory lists used by the + // new-workspace composer. Persisted via Store and broadcast back to the + // renderer so any open composer reflects new/edited/deleted presets + // immediately. + + ipcMain.handle('sparsePresets:list', (_event, args: { repoId: string }) => { + return store.getSparsePresets(args.repoId) + }) + + ipcMain.handle( + 'sparsePresets:save', + ( + _event, + args: { repoId: string; id?: string; name: string; directories: string[] } + ): SparsePreset => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo "${args.repoId}" not found`) + } + const name = normalizeSparsePresetName(args.name) + const directories = normalizeSparsePresetDirectories(args.directories) + const now = Date.now() + const existing = args.id + ? store.getSparsePresets(args.repoId).find((preset) => preset.id === args.id) + : undefined + const preset: SparsePreset = { + id: existing?.id ?? randomUUID(), + repoId: args.repoId, + name, + directories, + createdAt: existing?.createdAt ?? now, + updatedAt: now + } + const saved = store.saveSparsePreset(preset) + notifySparsePresetsChanged(mainWindow, args.repoId) + return saved + } + ) + + ipcMain.handle('sparsePresets:remove', (_event, args: { repoId: string; presetId: string }) => { + const repo = store.getRepo(args.repoId) + if (!repo) { + throw new Error(`Repo "${args.repoId}" not found`) + } + store.removeSparsePreset(args.repoId, args.presetId) + notifySparsePresetsChanged(mainWindow, args.repoId) + }) + ipcMain.handle('repos:pickFolder', async () => { const result = await dialog.showOpenDialog(mainWindow, { properties: ['openDirectory'] @@ -514,3 +567,39 @@ function notifyReposChanged(mainWindow: BrowserWindow): void { mainWindow.webContents.send('repos:changed') } } + +function notifySparsePresetsChanged(mainWindow: BrowserWindow, repoId: string): void { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('sparsePresets:changed', { repoId }) + } +} + +function normalizeSparsePresetName(name: string): string { + const trimmed = name.trim() + if (!trimmed) { + throw new Error('Preset name is required.') + } + if (trimmed.length > 80) { + throw new Error('Preset name is too long.') + } + return trimmed +} + +function normalizeSparsePresetDirectories(directories: string[]): string[] { + let normalized: string[] + try { + normalized = normalizeSparseDirectories(directories) + } catch (err) { + if ( + err instanceof Error && + err.message === 'Sparse checkout directories must be repo-relative paths.' + ) { + throw new Error('Preset directories must be repo-relative paths.') + } + throw err + } + if (normalized.length === 0) { + throw new Error('Preset must have at least one directory.') + } + return normalized +} diff --git a/src/main/ipc/sparse-checkout-directories.ts b/src/main/ipc/sparse-checkout-directories.ts new file mode 100644 index 000000000..74f2873d0 --- /dev/null +++ b/src/main/ipc/sparse-checkout-directories.ts @@ -0,0 +1,29 @@ +const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/ + +function isAbsoluteSparseDirectoryPath(entry: string): boolean { + return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry) +} + +export function normalizeSparseDirectories(directories: string[]): string[] { + const seen = new Set() + return directories + .map((entry) => entry.trim()) + .map((entry) => { + // Why: absolute paths can look repo-relative after slash normalization. + if (isAbsoluteSparseDirectoryPath(entry)) { + throw new Error('Sparse checkout directories must be repo-relative paths.') + } + return entry.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '') + }) + .filter((entry) => entry.length > 0 && entry !== '.') + .filter((entry) => { + if (entry.split('/').includes('..')) { + throw new Error('Sparse checkout directories must be repo-relative paths.') + } + if (seen.has(entry)) { + return false + } + seen.add(entry) + return true + }) +} diff --git a/src/main/ipc/worktree-logic.ts b/src/main/ipc/worktree-logic.ts index 70e60f2ef..f4fc0e292 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -160,6 +160,7 @@ export function mergeWorktree( head: git.head, branch: git.branch, isBare: git.isBare, + ...(git.isSparse === true ? { isSparse: true } : {}), isMainWorktree: git.isMainWorktree, displayName: meta?.displayName || branchShort || defaultDisplayName || basename(git.path), comment: meta?.comment || '', @@ -171,6 +172,13 @@ export function mergeWorktree( isPinned: meta?.isPinned ?? false, sortOrder: meta?.sortOrder ?? 0, lastActivityAt: meta?.lastActivityAt ?? 0, + ...(git.isSparse === true + ? { + sparseDirectories: meta?.sparseDirectories, + sparseBaseRef: meta?.sparseBaseRef, + sparsePresetId: meta?.sparsePresetId + } + : {}), // Why: diff comments are persisted on WorktreeMeta (see `WorktreeMeta` in // shared/types) and forwarded verbatim so the renderer store mirrors // on-disk state. `undefined` here means the worktree has no comments yet. diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 548dcc7ce..31867bbc6 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -13,7 +13,7 @@ import type { WorktreeMeta } from '../../shared/types' import { getPRForBranch } from '../github/client' -import { listWorktrees, addWorktree } from '../git/worktree' +import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' import { gitExecFileAsync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' @@ -31,6 +31,7 @@ import { areWorktreePathsEqual } from './worktree-logic' import { invalidateAuthorizedRootsCache } from './filesystem-auth' +import { normalizeSparseDirectories } from './sparse-checkout-directories' export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void { if (!mainWindow.isDestroyed()) { @@ -44,6 +45,10 @@ export async function createRemoteWorktree( store: Store, mainWindow: BrowserWindow ): Promise { + if (args.sparseCheckout) { + throw new Error('Sparse checkout is not supported for remote SSH repos yet.') + } + const provider = getSshGitProvider(repo.connectionId!) as SshGitProvider | undefined if (!provider) { throw new Error(`No git provider for connection "${repo.connectionId}"`) @@ -307,6 +312,32 @@ export async function createLocalWorktree( // Resolve it before mutating git state so missing UI input cannot strand // a real worktree on disk while the renderer reports "create failed". const shouldLaunchSetup = setupScript ? shouldRunSetupForCreate(repo, args.setupDecision) : false + const sparseDirectories = args.sparseCheckout + ? normalizeSparseDirectories(args.sparseCheckout.directories) + : [] + if (args.sparseCheckout && sparseDirectories.length === 0) { + throw new Error('Sparse checkout requires at least one repo-relative directory.') + } + let sparsePresetId: string | undefined + if (args.sparseCheckout?.presetId) { + const preset = store + .getSparsePresets(repo.id) + .find((entry) => entry.id === args.sparseCheckout?.presetId) + if (preset?.repoId === repo.id) { + try { + const presetDirectories = normalizeSparseDirectories(preset.directories) + // Why: use Set-based comparison so directory order does not affect + // attribution — matches the renderer's sparseDirectoriesMatch logic. + const presetSet = new Set(presetDirectories) + const directoriesMatch = + presetDirectories.length === sparseDirectories.length && + sparseDirectories.every((entry) => presetSet.has(entry)) + sparsePresetId = directoriesMatch ? preset.id : undefined + } catch { + // Why: corrupt preset data should not block creation or falsely label the new worktree. + } + } + } // Why: `git fetch` previously blocked worktree creation for 1–5s on every // click, even though the fetch result isn't actually required — the @@ -319,13 +350,22 @@ export async function createLocalWorktree( // Fetch is best-effort — don't block worktree creation if offline }) - await addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) + await (sparseDirectories.length > 0 + ? addSparseWorktree( + repo.path, + worktreePath, + branchName, + sparseDirectories, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) + : addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + )) // Re-list to get the freshly created worktree info const gitWorktrees = await listWorktrees(repo.path) @@ -342,6 +382,13 @@ export async function createLocalWorktree( lastActivityAt: Date.now(), ...(shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName) ? { displayName: effectiveRequestedName } + : {}), + ...(sparseDirectories.length > 0 + ? { + sparseDirectories, + sparseBaseRef: baseBranch, + sparsePresetId + } : {}) } const meta = store.setWorktreeMeta(worktreeId, metaUpdates) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 0bbba1a6a..f32730d0b 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,6 +6,7 @@ const { removeHandlerMock, listWorktreesMock, addWorktreeMock, + addSparseWorktreeMock, removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, @@ -26,6 +27,7 @@ const { removeHandlerMock: vi.fn(), listWorktreesMock: vi.fn(), addWorktreeMock: vi.fn(), + addSparseWorktreeMock: vi.fn(), removeWorktreeMock: vi.fn(), getGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), @@ -53,6 +55,7 @@ vi.mock('electron', () => ({ vi.mock('../git/worktree', () => ({ listWorktrees: listWorktreesMock, addWorktree: addWorktreeMock, + addSparseWorktree: addSparseWorktreeMock, removeWorktree: removeWorktreeMock })) @@ -113,6 +116,7 @@ describe('registerWorktreeHandlers', () => { const store = { getRepos: vi.fn(), getRepo: vi.fn(), + getSparsePresets: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), @@ -125,6 +129,7 @@ describe('registerWorktreeHandlers', () => { removeHandlerMock, listWorktreesMock, addWorktreeMock, + addSparseWorktreeMock, removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, @@ -143,6 +148,7 @@ describe('registerWorktreeHandlers', () => { mainWindow.webContents.send, store.getRepos, store.getRepo, + store.getSparsePresets, store.getSettings, store.getWorktreeMeta, store.setWorktreeMeta, @@ -168,6 +174,7 @@ describe('registerWorktreeHandlers', () => { } store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) + store.getSparsePresets.mockReturnValue([]) store.getSettings.mockReturnValue({ branchPrefix: 'none', nestWorkspaces: false, @@ -471,6 +478,173 @@ describe('registerWorktreeHandlers', () => { ) }) + it('creates a sparse worktree and persists its sparse metadata', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.setWorktreeMeta.mockReturnValue({ + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'Frontend and API', + directories: ['packages/web', 'apps/api'], + createdAt: 1, + updatedAt: 1 + } + ]) + + const result = await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: [' packages/web ', 'apps\\api\\', 'packages/web/'], + presetId: 'preset-1' + } + }) + + expect(addWorktreeMock).not.toHaveBeenCalled() + expect(addSparseWorktreeMock).toHaveBeenCalledWith( + '/workspace/repo', + '/workspace/improve-dashboard', + 'improve-dashboard', + ['packages/web', 'apps/api'], + 'origin/main', + false + ) + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + ) + expect(result).toEqual({ + worktree: expect.objectContaining({ + repoId: 'repo-1', + path: '/workspace/improve-dashboard', + sparseDirectories: ['packages/web', 'apps/api'], + sparseBaseRef: 'origin/main', + sparsePresetId: 'preset-1' + }) + }) + }) + + it('clears sparse preset attribution when the preset id does not belong to the repo', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-2', + repoId: 'repo-1', + name: 'Other preset', + directories: ['packages/web'], + createdAt: 1, + updatedAt: 1 + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web'], + presetId: 'preset-1' + } + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web'], + sparseBaseRef: 'origin/main', + sparsePresetId: undefined + }) + ) + }) + + it('clears sparse preset attribution when normalized directories do not match', async () => { + listWorktreesMock.mockResolvedValue([ + { + ...createdWorktreeList[0], + isSparse: true + } + ]) + store.getSparsePresets.mockReturnValue([ + { + id: 'preset-1', + repoId: 'repo-1', + name: 'Frontend and API', + directories: ['packages/web', 'apps/api'], + createdAt: 1, + updatedAt: 1 + } + ]) + + await handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web'], + presetId: 'preset-1' + } + }) + + expect(store.setWorktreeMeta).toHaveBeenCalledWith( + 'repo-1::/workspace/improve-dashboard', + expect.objectContaining({ + sparseDirectories: ['packages/web'], + sparseBaseRef: 'origin/main', + sparsePresetId: undefined + }) + ) + }) + + it('rejects sparse checkout directories that traverse above the repo root', async () => { + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web', '../secrets'] + } + }) + ).rejects.toThrow('Sparse checkout directories must be repo-relative paths.') + + expect(addSparseWorktreeMock).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + }) + + it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])( + 'rejects absolute sparse checkout directory before normalization: %s', + async (directory) => { + await expect( + handlers['worktrees:create'](null, { + repoId: 'repo-1', + name: 'improve-dashboard', + sparseCheckout: { + directories: ['packages/web', directory] + } + }) + ).rejects.toThrow('Sparse checkout directories must be repo-relative paths.') + + expect(addSparseWorktreeMock).not.toHaveBeenCalled() + expect(addWorktreeMock).not.toHaveBeenCalled() + } + ) + it('still returns the created worktree when setup runner generation fails', async () => { listWorktreesMock.mockResolvedValue(createdWorktreeList) getEffectiveHooksMock.mockReturnValue({ diff --git a/src/main/persistence.ts b/src/main/persistence.ts index 0e9f89b8d..578c926c2 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -6,7 +6,13 @@ import { readFileSync, writeFileSync, mkdirSync, existsSync, renameSync, unlinkS import { writeFile, rename, mkdir, rm } from 'fs/promises' import { join, dirname } from 'path' import { homedir } from 'os' -import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types' +import type { + PersistedState, + Repo, + SparsePreset, + WorktreeMeta, + GlobalSettings +} from '../shared/types' import type { SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getGitUsername } from './git/repo' @@ -343,6 +349,9 @@ export class Store { removeRepo(id: string): void { this.state.repos = this.state.repos.filter((r) => r.id !== id) + // Why: presets are repo-scoped, so removing the repo means the presets + // can never be referenced again — drop them with the parent. + delete this.state.sparsePresetsByRepo[id] // Clean up worktree meta for this repo const prefix = `${id}::` for (const key of Object.keys(this.state.worktreeMeta)) { @@ -393,6 +402,31 @@ export class Store { } } + // ── Sparse Presets ───────────────────────────────────────────────── + + getSparsePresets(repoId: string): SparsePreset[] { + return [...(this.state.sparsePresetsByRepo[repoId] ?? [])].sort((left, right) => + left.name.localeCompare(right.name) + ) + } + + saveSparsePreset(preset: SparsePreset): SparsePreset { + const existing = this.state.sparsePresetsByRepo[preset.repoId] ?? [] + const index = existing.findIndex((entry) => entry.id === preset.id) + this.state.sparsePresetsByRepo[preset.repoId] = + index === -1 + ? [...existing, preset] + : existing.map((entry, i) => (i === index ? preset : entry)) + this.scheduleSave() + return preset + } + + removeSparsePreset(repoId: string, presetId: string): void { + const existing = this.state.sparsePresetsByRepo[repoId] ?? [] + this.state.sparsePresetsByRepo[repoId] = existing.filter((entry) => entry.id !== presetId) + this.scheduleSave() + } + // ── Worktree Meta ────────────────────────────────────────────────── getWorktreeMeta(worktreeId: string): WorktreeMeta | undefined { diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index 829ea4b02..d3cb04832 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -8,6 +8,7 @@ import type { BrowserSessionProfileSource, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, + CreateWorktreeArgs, CreateWorktreeResult, DirEntry, FsChangedPayload, @@ -46,6 +47,7 @@ import type { PRComment, PRInfo, Repo, + SparsePreset, SearchOptions, SearchResult, StatsSummary, @@ -329,15 +331,21 @@ export type PreloadApi = { searchBaseRefs: (args: { repoId: string; query: string; limit?: number }) => Promise onChanged: (callback: () => void) => () => void } + sparsePresets: { + list: (args: { repoId: string }) => Promise + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }) => Promise + remove: (args: { repoId: string; presetId: string }) => Promise + onChanged: (callback: (data: { repoId: string }) => void) => () => void + } worktrees: { list: (args: { repoId: string }) => Promise listAll: () => Promise - create: (args: { - repoId: string - name: string - baseBranch?: string - setupDecision?: 'inherit' | 'run' | 'skip' - }) => Promise + create: (args: CreateWorktreeArgs) => Promise resolvePrBase: (args: { repoId: string prNumber: number diff --git a/src/preload/index.ts b/src/preload/index.ts index a78945c1a..9225b9fa5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,6 +9,7 @@ import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { BaseRefDefaultResult, + CreateWorktreeArgs, FsChangedPayload, GitHubAssignableUser, GitHubCommentResult, @@ -246,18 +247,36 @@ const api = { } }, + sparsePresets: { + list: (args: { repoId: string }): Promise => + ipcRenderer.invoke('sparsePresets:list', args), + + save: (args: { + repoId: string + id?: string + name: string + directories: string[] + }): Promise => ipcRenderer.invoke('sparsePresets:save', args), + + remove: (args: { repoId: string; presetId: string }): Promise => + ipcRenderer.invoke('sparsePresets:remove', args), + + onChanged: (callback: (data: { repoId: string }) => void): (() => void) => { + const listener = (_event: Electron.IpcRendererEvent, data: { repoId: string }) => + callback(data) + ipcRenderer.on('sparsePresets:changed', listener) + return () => ipcRenderer.removeListener('sparsePresets:changed', listener) + } + }, + worktrees: { list: (args: { repoId: string }): Promise => ipcRenderer.invoke('worktrees:list', args), listAll: (): Promise => ipcRenderer.invoke('worktrees:listAll'), - create: (args: { - repoId: string - name: string - baseBranch?: string - setupDecision?: 'inherit' | 'run' | 'skip' - }): Promise => ipcRenderer.invoke('worktrees:create', args), + create: (args: CreateWorktreeArgs): Promise => + ipcRenderer.invoke('worktrees:create', args), resolvePrBase: (args: { repoId: string diff --git a/src/renderer/src/components/NewWorkspaceComposerCard.tsx b/src/renderer/src/components/NewWorkspaceComposerCard.tsx index 3b27aab19..c72a55ebd 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -18,7 +18,8 @@ import AgentCombobox from '@/components/agent/AgentCombobox' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import type { TuiAgent } from '../../../shared/types' +import type { SparsePreset, TuiAgent } from '../../../shared/types' +import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -51,6 +52,10 @@ type NewWorkspaceComposerCardProps = { shouldWaitForSetupCheck: boolean resolvedSetupDecision: 'run' | 'skip' | null createError: string | null + canUseSparseCheckout: boolean + sparsePresets: SparsePreset[] + sparseSelectedPresetId: string | null + onSparseSelectPreset: (preset: SparsePreset | null) => void } function SetupCommandPreview({ @@ -186,7 +191,11 @@ export default function NewWorkspaceComposerCard({ onSetupDecisionChange, shouldWaitForSetupCheck, resolvedSetupDecision, - createError + createError, + canUseSparseCheckout, + sparsePresets, + sparseSelectedPresetId, + onSparseSelectPreset }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) @@ -337,8 +346,8 @@ export default function NewWorkspaceComposerCard({
@@ -347,7 +356,14 @@ export default function NewWorkspaceComposerCard({ textarea's 3px outset focus ring has horizontal breathing room inside the overflow-hidden drawer above. Without it the ring gets clipped on the right edge when the field is focused. */} -
+