diff --git a/config/scripts/build-relay.mjs b/config/scripts/build-relay.mjs index b793b64e757..8a794a97376 100644 --- a/config/scripts/build-relay.mjs +++ b/config/scripts/build-relay.mjs @@ -15,8 +15,7 @@ import { join, dirname } from 'path' import { fileURLToPath } from 'url' const __dirname = dirname(fileURLToPath(import.meta.url)) -// Why: the script moved from `scripts/` to `config/scripts/` — go two levels up to reach the repo root. -const ROOT = join(__dirname, '..', '..') +const ROOT = join(__dirname, '..') const RELAY_ENTRY = join(ROOT, 'src', 'relay', 'relay.ts') const PLATFORMS = ['linux-x64', 'linux-arm64', 'darwin-x64', 'darwin-arm64'] diff --git a/src/main/git/remove-worktree.test.ts b/src/main/git/remove-worktree.test.ts index 0832c0b8e78..eecf11344dc 100644 --- a/src/main/git/remove-worktree.test.ts +++ b/src/main/git/remove-worktree.test.ts @@ -1,22 +1,17 @@ -/* 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 { beforeEach, describe, expect, it, vi } from 'vitest' -const { gitExecFileAsyncMock, gitExecFileSyncMock, translateWslOutputPathsMock } = vi.hoisted( - () => ({ - gitExecFileAsyncMock: vi.fn(), - gitExecFileSyncMock: vi.fn(), - translateWslOutputPathsMock: vi.fn((output: string) => output) - }) -) +const { gitExecFileAsyncMock, gitExecFileSyncMock } = vi.hoisted(() => ({ + gitExecFileAsyncMock: vi.fn(), + gitExecFileSyncMock: vi.fn() +})) vi.mock('./runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock, gitExecFileSync: gitExecFileSyncMock, - translateWslOutputPaths: translateWslOutputPathsMock + translateWslOutputPaths: (output: string) => output })) -import { addSparseWorktree, listWorktrees, removeWorktree } from './worktree' +import { removeWorktree } from './worktree' type MockResult = { error?: Error @@ -59,13 +54,11 @@ describe('removeWorktree', () => { beforeEach(() => { gitExecFileAsyncMock.mockReset() gitExecFileSyncMock.mockReset() - translateWslOutputPathsMock.mockReset() - translateWslOutputPathsMock.mockImplementation((output: string) => output) }) it('removes the worktree, prunes stale refs, and deletes its local branch', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -75,7 +68,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -99,7 +92,7 @@ branch refs/heads/main it('skips branch deletion when another worktree still points at the branch', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -113,7 +106,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -132,7 +125,7 @@ branch refs/heads/feature/test expect.arrayContaining([ 'git worktree remove /repo-feature', 'git worktree prune', - 'git worktree list --porcelain -z' + 'git worktree list --porcelain' ]) ) expect(calls).not.toContain('git branch -D feature/test') @@ -141,7 +134,7 @@ branch refs/heads/feature/test it('deletes the branch after prune removes stale sibling worktree entries', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -156,7 +149,7 @@ branch refs/heads/feature/test prunable gitdir file points to non-existent location ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -179,7 +172,7 @@ branch refs/heads/main it('passes --force before the worktree path when forced removal is requested', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -189,7 +182,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -204,7 +197,7 @@ branch refs/heads/main it('matches Windows worktree paths before deleting the branch', async () => { mockGitCommands({ - 'git worktree list --porcelain -z': { + 'git worktree list --porcelain': { stdout: `worktree C:/repo HEAD abc123 branch refs/heads/main @@ -214,7 +207,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree C:/repo HEAD abc123 branch refs/heads/main @@ -237,7 +230,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 -z': { + 'git worktree list --porcelain': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -247,7 +240,7 @@ HEAD def456 branch refs/heads/feature/test ` }, - 'git worktree list --porcelain -z#2': { + 'git worktree list --porcelain#2': { stdout: `worktree /repo HEAD abc123 branch refs/heads/main @@ -269,159 +262,3 @@ branch refs/heads/main warnSpy.mockRestore() }) }) - -describe('listWorktrees', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - gitExecFileSyncMock.mockReset() - translateWslOutputPathsMock.mockReset() - translateWslOutputPathsMock.mockImplementation((output: string) => output) - }) - - 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 - } - ]) - expect(getGitCalls()).toEqual(['git worktree list --porcelain -z', 'git sparse-checkout list']) - expect(translateWslOutputPathsMock).toHaveBeenCalledTimes(2) - }) - - it('detects sparse checkout after translating paths when porcelain omits sparse token', async () => { - gitExecFileAsyncMock.mockImplementation((args: string[], options: { cwd: 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: '' - } - } - if (args.join(' ') === 'sparse-checkout list') { - const isFeatureWorktree = - options.cwd === '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature' - return { - stdout: isFeatureWorktree ? 'packages/web\n' : '', - 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 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(gitExecFileAsyncMock).toHaveBeenCalledWith(['sparse-checkout', 'list'], { - cwd: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo-feature' - }) - }) -}) - -describe('addSparseWorktree', () => { - beforeEach(() => { - gitExecFileAsyncMock.mockReset() - gitExecFileSyncMock.mockReset() - translateWslOutputPathsMock.mockReset() - translateWslOutputPathsMock.mockImplementation((output: string) => output) - }) - - 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/worktree.ts b/src/main/git/worktree.ts index 303d709058b..c109d168d69 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -2,10 +2,6 @@ import { posix, win32 } from 'path' import type { GitWorktreeInfo } from '../../shared/types' import { gitExecFileAsync, translateWslOutputPaths } from './runner' -type SparseWorktreeCreateError = Error & { - cleanupFailed?: boolean -} - function normalizeLocalBranchRef(branch: string): string { return branch.replace(/^refs\/heads\//, '') } @@ -33,23 +29,21 @@ function looksLikeWindowsPath(pathValue: string): boolean { */ export function parseWorktreeList(output: string): GitWorktreeInfo[] { const worktrees: GitWorktreeInfo[] = [] - const blocks = output.includes('\0') - ? parseNullDelimitedWorktreeBlocks(output) - : output - .trim() - .split(/\r?\n\r?\n/) - .map((block) => block.trim().split(/\r?\n/)) + // [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/) - for (const lines of blocks) { - if (lines.length === 0) { + for (const block of blocks) { + if (!block.trim()) { 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 ')) { @@ -60,21 +54,12 @@ 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, - ...(isSparse ? { isSparse } : {}), - isMainWorktree: worktrees.length === 0 - }) + worktrees.push({ path, head, branch, isBare, isMainWorktree: worktrees.length === 0 }) } } @@ -86,24 +71,14 @@ export function parseWorktreeList(output: string): GitWorktreeInfo[] { */ export async function listWorktrees(repoPath: string): Promise { try { - const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain', '-z'], { + const { stdout } = await gitExecFileAsync(['worktree', 'list', '--porcelain'], { cwd: repoPath }) - // 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 - }) - ) + // 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) } catch { return [] } @@ -121,8 +96,7 @@ export async function addWorktree( worktreePath: string, branch: string, baseBranch?: string, - refreshLocalBaseRef = false, - noCheckout = false + refreshLocalBaseRef = 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 @@ -178,49 +152,13 @@ export async function addWorktree( } } - const args = ['worktree', 'add'] - if (noCheckout) { - args.push('--no-checkout') - } - args.push('-b', branch, worktreePath) + const args = ['worktree', 'add', '-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. */ @@ -270,44 +208,3 @@ 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: `core.sparseCheckout` is repo-level config shared across all worktrees. - // Querying it would false-positive every linked worktree when the main repo - // has sparse checkout enabled. Instead, check the worktree-local - // `sparse-checkout` config file which only exists when sparse is active. - try { - const { stdout } = await gitExecFileAsync(['sparse-checkout', 'list'], { cwd: worktreePath }) - return stdout.trim().length > 0 - } catch { - return false - } -} diff --git a/src/main/ipc/repos-sparse-presets.test.ts b/src/main/ipc/repos-sparse-presets.test.ts deleted file mode 100644 index 71ab88b6a4d..00000000000 --- a/src/main/ipc/repos-sparse-presets.test.ts +++ /dev/null @@ -1,223 +0,0 @@ -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 3f1b13df5d2..b59aa8fb7ca 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, SparsePreset } from '../../shared/types' +import type { Repo, BaseRefDefaultResult } from '../../shared/types' import { isFolderRepo } from '../../shared/repo-kind' import { REPO_COLORS } from '../../shared/constants' import { rebuildAuthorizedRootsCache } from './filesystem-auth' @@ -28,7 +28,6 @@ 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, @@ -51,9 +50,6 @@ 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() @@ -223,55 +219,6 @@ 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'] @@ -567,39 +514,3 @@ 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 deleted file mode 100644 index 74f2873d062..00000000000 --- a/src/main/ipc/sparse-checkout-directories.ts +++ /dev/null @@ -1,29 +0,0 @@ -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 f4fc0e292b8..70e60f2ef44 100644 --- a/src/main/ipc/worktree-logic.ts +++ b/src/main/ipc/worktree-logic.ts @@ -160,7 +160,6 @@ 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 || '', @@ -172,13 +171,6 @@ 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 31867bbc6b6..548dcc7cead 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, addSparseWorktree } from '../git/worktree' +import { listWorktrees, addWorktree } from '../git/worktree' import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo' import { gitExecFileAsync } from '../git/runner' import { isWslPath, parseWslPath, getWslHome } from '../wsl' @@ -31,7 +31,6 @@ 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()) { @@ -45,10 +44,6 @@ 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}"`) @@ -312,32 +307,6 @@ 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 @@ -350,22 +319,13 @@ export async function createLocalWorktree( // Fetch is best-effort — don't block worktree creation if offline }) - await (sparseDirectories.length > 0 - ? addSparseWorktree( - repo.path, - worktreePath, - branchName, - sparseDirectories, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - ) - : addWorktree( - repo.path, - worktreePath, - branchName, - baseBranch, - settings.refreshLocalBaseRefOnWorktreeCreate - )) + await addWorktree( + repo.path, + worktreePath, + branchName, + baseBranch, + settings.refreshLocalBaseRefOnWorktreeCreate + ) // Re-list to get the freshly created worktree info const gitWorktrees = await listWorktrees(repo.path) @@ -382,13 +342,6 @@ 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 f32730d0b33..0bbba1a6a48 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -6,7 +6,6 @@ const { removeHandlerMock, listWorktreesMock, addWorktreeMock, - addSparseWorktreeMock, removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, @@ -27,7 +26,6 @@ const { removeHandlerMock: vi.fn(), listWorktreesMock: vi.fn(), addWorktreeMock: vi.fn(), - addSparseWorktreeMock: vi.fn(), removeWorktreeMock: vi.fn(), getGitUsernameMock: vi.fn(), getDefaultBaseRefMock: vi.fn(), @@ -55,7 +53,6 @@ vi.mock('electron', () => ({ vi.mock('../git/worktree', () => ({ listWorktrees: listWorktreesMock, addWorktree: addWorktreeMock, - addSparseWorktree: addSparseWorktreeMock, removeWorktree: removeWorktreeMock })) @@ -116,7 +113,6 @@ describe('registerWorktreeHandlers', () => { const store = { getRepos: vi.fn(), getRepo: vi.fn(), - getSparsePresets: vi.fn(), getSettings: vi.fn(), getWorktreeMeta: vi.fn(), setWorktreeMeta: vi.fn(), @@ -129,7 +125,6 @@ describe('registerWorktreeHandlers', () => { removeHandlerMock, listWorktreesMock, addWorktreeMock, - addSparseWorktreeMock, removeWorktreeMock, getGitUsernameMock, getDefaultBaseRefMock, @@ -148,7 +143,6 @@ describe('registerWorktreeHandlers', () => { mainWindow.webContents.send, store.getRepos, store.getRepo, - store.getSparsePresets, store.getSettings, store.getWorktreeMeta, store.setWorktreeMeta, @@ -174,7 +168,6 @@ describe('registerWorktreeHandlers', () => { } store.getRepos.mockReturnValue([repo]) store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null }) - store.getSparsePresets.mockReturnValue([]) store.getSettings.mockReturnValue({ branchPrefix: 'none', nestWorkspaces: false, @@ -478,173 +471,6 @@ 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 6f7da5cf87b..7c4601775c6 100644 --- a/src/main/persistence.ts +++ b/src/main/persistence.ts @@ -6,13 +6,7 @@ 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, - SparsePreset, - WorktreeMeta, - GlobalSettings -} from '../shared/types' +import type { PersistedState, Repo, WorktreeMeta, GlobalSettings } from '../shared/types' import type { SshTarget } from '../shared/ssh-types' import { isFolderRepo } from '../shared/repo-kind' import { getGitUsername } from './git/repo' @@ -320,9 +314,6 @@ 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)) { @@ -373,31 +364,6 @@ 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 ac23be812ae..9f94cd95a97 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -8,7 +8,6 @@ import type { BrowserSessionProfileSource, ClaudeRateLimitAccountsState, CodexRateLimitAccountsState, - CreateWorktreeArgs, CreateWorktreeResult, DirEntry, FsChangedPayload, @@ -45,7 +44,6 @@ import type { PRComment, PRInfo, Repo, - SparsePreset, SearchOptions, SearchResult, StatsSummary, @@ -329,21 +327,15 @@ 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: CreateWorktreeArgs) => Promise + create: (args: { + repoId: string + name: string + baseBranch?: string + setupDecision?: 'inherit' | 'run' | 'skip' + }) => Promise resolvePrBase: (args: { repoId: string prNumber: number diff --git a/src/preload/index.ts b/src/preload/index.ts index bb5da6001cc..70a105b2ba6 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -9,7 +9,6 @@ import type { CliInstallStatus } from '../shared/cli-install-types' import type { AgentHookInstallStatus } from '../shared/agent-hook-types' import type { BaseRefDefaultResult, - CreateWorktreeArgs, FsChangedPayload, GitHubAssignableUser, GitHubCommentResult, @@ -245,36 +244,18 @@ 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: CreateWorktreeArgs): Promise => - ipcRenderer.invoke('worktrees:create', args), + create: (args: { + repoId: string + name: string + baseBranch?: string + setupDecision?: 'inherit' | 'run' | 'skip' + }): 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 c72a55ebde8..3b27aab1996 100644 --- a/src/renderer/src/components/NewWorkspaceComposerCard.tsx +++ b/src/renderer/src/components/NewWorkspaceComposerCard.tsx @@ -18,8 +18,7 @@ import AgentCombobox from '@/components/agent/AgentCombobox' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { useAppStore } from '@/store' import { cn } from '@/lib/utils' -import type { SparsePreset, TuiAgent } from '../../../shared/types' -import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPresetSelect' +import type { TuiAgent } from '../../../shared/types' const isMac = typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') @@ -52,10 +51,6 @@ 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({ @@ -191,11 +186,7 @@ export default function NewWorkspaceComposerCard({ onSetupDecisionChange, shouldWaitForSetupCheck, resolvedSetupDecision, - createError, - canUseSparseCheckout, - sparsePresets, - sparseSelectedPresetId, - onSparseSelectPreset + createError }: NewWorkspaceComposerCardProps): React.JSX.Element { const { isFileDragOver, dragHandlers } = useComposerFileDragOver() const openModal = useAppStore((s) => s.openModal) @@ -346,8 +337,8 @@ export default function NewWorkspaceComposerCard({
@@ -356,14 +347,7 @@ 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. */} -
+