fix(worktree): restore sparse checkout without per-poll git subprocesses (#1316)

Co-authored-by: Orca <help@stably.ai>
Co-authored-by: Colin <colin.chambachan@gmail.com>
This commit is contained in:
Jinwoo Hong 2026-05-01 13:25:22 -07:00 committed by GitHub
parent d4bbf473c1
commit bbd77c2449
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
35 changed files with 2543 additions and 76 deletions

View File

@ -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<typeof FsPromises>('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')
})
})

View File

@ -268,7 +268,7 @@ export async function detectConflictOperation(worktreePath: string): Promise<Git
return 'unknown'
}
async function resolveGitDir(worktreePath: string): Promise<string> {
export async function resolveGitDir(worktreePath: string): Promise<string> {
const dotGitPath = path.join(worktreePath, '.git')
try {

View File

@ -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<GitWorktreeInfo[]> {
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<void> {
// 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<void> {
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<boolean> {
// 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 `<worktreePath>/.git/info/sparse-checkout`:
// linked worktrees have a `.git` file that points at
// `<repo>/.git/worktrees/<name>`, 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
}
}

View File

@ -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<typeof CryptoModule>()
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<string, (_event: unknown, args: unknown) => unknown>
function makePreset(
overrides: Partial<SparsePreset> & { 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'
})
})
})

View File

@ -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
}

View File

@ -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<string>()
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
})
}

View File

@ -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.

View File

@ -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<CreateWorktreeResult> {
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 15s 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)

View File

@ -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({

View File

@ -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 {

View File

@ -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<string[]>
onChanged: (callback: () => void) => () => void
}
sparsePresets: {
list: (args: { repoId: string }) => Promise<SparsePreset[]>
save: (args: {
repoId: string
id?: string
name: string
directories: string[]
}) => Promise<SparsePreset>
remove: (args: { repoId: string; presetId: string }) => Promise<void>
onChanged: (callback: (data: { repoId: string }) => void) => () => void
}
worktrees: {
list: (args: { repoId: string }) => Promise<Worktree[]>
listAll: () => Promise<Worktree[]>
create: (args: {
repoId: string
name: string
baseBranch?: string
setupDecision?: 'inherit' | 'run' | 'skip'
}) => Promise<CreateWorktreeResult>
create: (args: CreateWorktreeArgs) => Promise<CreateWorktreeResult>
resolvePrBase: (args: {
repoId: string
prNumber: number

View File

@ -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<unknown[]> =>
ipcRenderer.invoke('sparsePresets:list', args),
save: (args: {
repoId: string
id?: string
name: string
directories: string[]
}): Promise<unknown> => ipcRenderer.invoke('sparsePresets:save', args),
remove: (args: { repoId: string; presetId: string }): Promise<void> =>
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<unknown[]> =>
ipcRenderer.invoke('worktrees:list', args),
listAll: (): Promise<unknown[]> => ipcRenderer.invoke('worktrees:listAll'),
create: (args: {
repoId: string
name: string
baseBranch?: string
setupDecision?: 'inherit' | 'run' | 'skip'
}): Promise<unknown> => ipcRenderer.invoke('worktrees:create', args),
create: (args: CreateWorktreeArgs): Promise<unknown> =>
ipcRenderer.invoke('worktrees:create', args),
resolvePrBase: (args: {
repoId: string

View File

@ -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({
<div
className={cn(
'grid overflow-hidden transition-[grid-template-rows,opacity] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'
'grid overflow-hidden transition-[grid-template-rows] duration-200 ease-out',
advancedOpen ? 'grid-rows-[1fr]' : 'grid-rows-[0fr]'
)}
aria-hidden={!advancedOpen}
>
@ -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. */}
<div className="space-y-4 px-1 pt-1">
<div
className={cn(
'space-y-4 px-1 pt-1 pb-3 transition-[opacity,transform] duration-150 ease-out',
advancedOpen
? 'translate-y-0 opacity-100 delay-200'
: '-translate-y-1 opacity-0 delay-0'
)}
>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Note</label>
<textarea
@ -449,6 +465,22 @@ export default function NewWorkspaceComposerCard({
) : null}
</div>
) : null}
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">Sparse checkout</label>
<SparseCheckoutPresetSelect
repoId={repoId}
presets={sparsePresets}
selectedPresetId={sparseSelectedPresetId}
onSelectPreset={onSparseSelectPreset}
disabled={!canUseSparseCheckout}
/>
{!canUseSparseCheckout ? (
<p className="text-[11px] text-muted-foreground">
Only available for local repositories.
</p>
) : null}
</div>
</div>
</div>
</div>

View File

@ -476,7 +476,10 @@ function AnimatedTabPanels({
// leaving no room for a ring to paint. `overflow-clip-margin` gives
// the ring breathing room on every side without re-introducing scroll
// containers or letting the inactive panel leak layout.
className="relative overflow-clip transition-[height] duration-200 ease-out"
className={cn(
'relative overflow-clip',
isAnimating && 'transition-[height] duration-200 ease-out'
)}
style={{
...(wrapperHeight !== null ? { height: wrapperHeight } : null),
overflowClipMargin: '8px'

View File

@ -10,6 +10,7 @@ import { Trash2 } from 'lucide-react'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import { BaseRefPicker } from './BaseRefPicker'
import { RepositoryHooksSection } from './RepositoryHooksSection'
import { SparsePresetSettingsSection } from './SparsePresetSettingsSection'
import { SearchableSetting } from './SearchableSetting'
import { matchesSettingsSearch, type SettingsSearchEntry } from './settings-search'
import { useAppStore } from '../../store'
@ -43,6 +44,20 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
title: 'Default Worktree Base',
description: 'Default base branch or ref when creating worktrees.',
keywords: [repo.displayName, 'base ref', 'branch']
},
{
title: 'Sparse Checkout Presets',
description: 'Saved directory sets for sparse worktree creation.',
keywords: [
repo.displayName,
'sparse',
'checkout',
'preset',
'presets',
'directory',
'directories',
'monorepo'
]
}
]),
{
@ -164,6 +179,9 @@ export function RepositoryPane({
const identityEntries = allEntries.filter((entry) =>
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Repo'].includes(entry.title)
)
const sparsePresetEntries = allEntries.filter((entry) =>
['Sparse Checkout Presets'].includes(entry.title)
)
const hooksEntries = allEntries.filter((entry) =>
[
'orca.yaml hooks',
@ -269,6 +287,9 @@ export function RepositoryPane({
) : null}
</section>
) : null,
!isFolder && matchesSettingsSearch(searchQuery, sparsePresetEntries) ? (
<SparsePresetSettingsSection key="sparse-presets" repoId={repo.id} />
) : null,
!isFolder && matchesSettingsSearch(searchQuery, hooksEntries) ? (
<RepositoryHooksSection
key="hooks"

View File

@ -0,0 +1,335 @@
import { useEffect, useState } from 'react'
import { Bookmark, LoaderCircle, Pencil, Plus, Save, Trash2, X } from 'lucide-react'
import type { SparsePreset } from '../../../../shared/types'
import { useAppStore } from '../../store'
import { cn } from '@/lib/utils'
import { parseSparsePresetDirectories } from '@/lib/sparse-preset-draft'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { formatSparsePresetUpdatedAt } from './sparse-preset-date'
type SparsePresetSettingsSectionProps = {
repoId: string
}
type SparsePresetDraft = {
mode: 'new' | 'edit'
presetId?: string
name: string
directoriesText: string
}
function SparsePresetDirectoryPreview({
directories
}: {
directories: string[]
}): React.JSX.Element {
const visibleDirectories = directories.slice(0, 6)
const hiddenCount = directories.length - visibleDirectories.length
return (
<div className="flex flex-wrap gap-1.5">
{visibleDirectories.map((directory) => (
<span
key={directory}
className="min-w-0 max-w-full truncate rounded-md border border-border/50 bg-muted/35 px-2 py-1 font-mono text-[11px] text-foreground/80"
title={directory}
>
{directory}
</span>
))}
{hiddenCount > 0 ? (
<span className="rounded-md border border-border/50 bg-muted/35 px-2 py-1 text-[11px] text-muted-foreground">
+{hiddenCount} more
</span>
) : null}
</div>
)
}
export function SparsePresetSettingsSection({
repoId
}: SparsePresetSettingsSectionProps): React.JSX.Element {
const presets = useAppStore((s) => s.sparsePresetsByRepo[repoId])
const fetchSparsePresets = useAppStore((s) => s.fetchSparsePresets)
const saveSparsePreset = useAppStore((s) => s.saveSparsePreset)
const removeSparsePreset = useAppStore((s) => s.removeSparsePreset)
const [draft, setDraft] = useState<SparsePresetDraft | null>(null)
const [submitting, setSubmitting] = useState(false)
const [confirmingDeleteId, setConfirmingDeleteId] = useState<string | null>(null)
useEffect(() => {
if (presets === undefined) {
void fetchSparsePresets(repoId)
}
}, [fetchSparsePresets, presets, repoId])
const sortedPresets = presets ?? []
const parsedDirectories = draft ? parseSparsePresetDirectories(draft.directoriesText) : null
const trimmedName = draft?.name.trim() ?? ''
const lowerName = trimmedName.toLowerCase()
const collidingPreset =
draft && trimmedName
? (sortedPresets.find(
(preset) => preset.id !== draft.presetId && preset.name.toLowerCase() === lowerName
) ?? null)
: null
const nameError =
draft && trimmedName.length === 0
? 'Name is required.'
: trimmedName.length > 80
? 'Name must be 80 characters or fewer.'
: collidingPreset
? `"${collidingPreset.name}" already exists.`
: null
const canSaveDraft =
!!draft && !submitting && !nameError && parsedDirectories !== null && !parsedDirectories.error
const startNewPreset = (): void => {
setConfirmingDeleteId(null)
setDraft({
mode: 'new',
name: '',
directoriesText: ''
})
}
const startEditPreset = (preset: SparsePreset): void => {
setConfirmingDeleteId(null)
setDraft({
mode: 'edit',
presetId: preset.id,
name: preset.name,
directoriesText: preset.directories.join('\n')
})
}
const handleSaveDraft = async (): Promise<void> => {
if (!draft || !canSaveDraft || !parsedDirectories) {
return
}
setSubmitting(true)
try {
const saved = await saveSparsePreset({
repoId,
id: draft.presetId,
name: trimmedName,
directories: parsedDirectories.directories
})
if (saved) {
setDraft(null)
}
} finally {
setSubmitting(false)
}
}
const handleDeletePreset = async (preset: SparsePreset): Promise<void> => {
if (confirmingDeleteId !== preset.id) {
setConfirmingDeleteId(preset.id)
return
}
if (draft?.presetId === preset.id) {
setDraft(null)
}
setConfirmingDeleteId(null)
await removeSparsePreset({ repoId, presetId: preset.id })
}
const renderDraftEditor = (): React.JSX.Element | null => {
if (!draft) {
return null
}
return (
<div className="rounded-xl border border-border/60 bg-background/80 p-4 shadow-sm">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="space-y-0.5">
<h5 className="text-sm font-semibold">
{draft.mode === 'new' ? 'New Preset' : 'Edit Preset'}
</h5>
<p className="text-xs text-muted-foreground">
Saved directories are used when creating sparse worktrees for this repository.
</p>
</div>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label="Cancel preset edit"
onClick={() => setDraft(null)}
disabled={submitting}
>
<X className="size-3.5" />
</Button>
</div>
<div className="grid gap-4 md:grid-cols-[minmax(0,0.8fr)_minmax(0,1.2fr)]">
<div className="space-y-2">
<Label htmlFor="sparse-preset-settings-name">Name</Label>
<Input
id="sparse-preset-settings-name"
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
placeholder="e.g. web-only"
maxLength={80}
autoComplete="off"
spellCheck={false}
className="h-9 text-sm"
/>
{nameError ? <p className="text-xs text-destructive">{nameError}</p> : null}
</div>
<div className="space-y-2">
<Label htmlFor="sparse-preset-settings-directories">Directories</Label>
<textarea
id="sparse-preset-settings-directories"
value={draft.directoriesText}
onChange={(event) => setDraft({ ...draft, directoriesText: event.target.value })}
placeholder={`packages/web\nshared/ui`}
rows={5}
spellCheck={false}
className="w-full min-w-0 resize-y rounded-md border border-input bg-transparent px-3 py-2 font-mono text-xs shadow-xs outline-none transition-[color,box-shadow] placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50"
/>
{parsedDirectories?.error ? (
<p className="text-xs text-destructive">{parsedDirectories.error}</p>
) : (
<p className="text-xs text-muted-foreground">
{parsedDirectories?.directories.length === 1
? '1 directory will be saved.'
: `${parsedDirectories?.directories.length ?? 0} directories will be saved.`}{' '}
Use repo-relative paths like packages/web or apps/api.
</p>
)}
</div>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => setDraft(null)}
disabled={submitting}
>
Cancel
</Button>
<Button
type="button"
size="sm"
onClick={() => void handleSaveDraft()}
disabled={!canSaveDraft}
>
{submitting ? (
<LoaderCircle className="size-3.5 animate-spin" />
) : (
<Save className="size-3.5" />
)}
Save Preset
</Button>
</div>
</div>
)
}
return (
<section className="space-y-4">
<div className="flex items-start justify-between gap-4">
<div className="space-y-1">
<h3 className="text-sm font-semibold">Sparse Checkout Presets</h3>
<p className="text-xs text-muted-foreground">
Manage saved directory sets for sparse worktree creation.
</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={startNewPreset}
disabled={!!draft}
>
<Plus className="size-3.5" />
New Preset
</Button>
</div>
{renderDraftEditor()}
{presets === undefined ? (
<div className="rounded-xl border border-dashed border-border/60 bg-background/60 px-4 py-6 text-sm text-muted-foreground">
Loading sparse presets...
</div>
) : sortedPresets.length === 0 && !draft ? (
<div className="rounded-xl border border-dashed border-border/60 bg-background/60 px-4 py-6 text-sm text-muted-foreground">
No sparse presets saved for this repository.
</div>
) : (
<div className="space-y-2">
{sortedPresets.map((preset) => {
// Why: users can already have locally persisted presets from older
// builds or hand-edited state; a bad timestamp must not blank Settings.
const updatedLabel = formatSparsePresetUpdatedAt(preset.updatedAt)
return (
<div
key={preset.id}
className="rounded-xl border border-border/50 bg-background/70 px-4 py-3 shadow-sm"
>
<div className="flex items-start gap-3">
<div className="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg border border-border/50 bg-muted/30">
<Bookmark className="size-4 text-muted-foreground" />
</div>
<div className="min-w-0 flex-1 space-y-2">
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<h4 className="min-w-0 truncate text-sm font-medium">{preset.name}</h4>
<span className="text-[11px] text-muted-foreground">
{preset.directories.length === 1
? '1 directory'
: `${preset.directories.length} directories`}
</span>
<span className="text-[11px] text-muted-foreground">
{updatedLabel ? `Updated ${updatedLabel}` : 'Updated date unknown'}
</span>
</div>
<SparsePresetDirectoryPreview directories={preset.directories} />
</div>
<div className="flex shrink-0 items-center gap-1">
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Edit ${preset.name}`}
onClick={() => startEditPreset(preset)}
disabled={submitting}
>
<Pencil className="size-3.5" />
</Button>
<Button
type="button"
variant={confirmingDeleteId === preset.id ? 'destructive' : 'ghost'}
size="sm"
aria-label={`Delete ${preset.name}`}
onClick={() => void handleDeletePreset(preset)}
onBlur={() => setConfirmingDeleteId(null)}
disabled={submitting}
className={cn(
'w-[5.75rem] px-2 text-xs',
confirmingDeleteId !== preset.id && 'text-muted-foreground'
)}
>
<Trash2 className="size-3.5" />
{confirmingDeleteId === preset.id ? 'Confirm' : 'Delete'}
</Button>
</div>
</div>
</div>
)
})}
</div>
)}
</section>
)
}

View File

@ -0,0 +1,13 @@
import { describe, expect, it } from 'vitest'
import { formatSparsePresetUpdatedAt } from './sparse-preset-date'
describe('formatSparsePresetUpdatedAt', () => {
it('formats valid timestamps', () => {
expect(formatSparsePresetUpdatedAt(Date.UTC(2026, 0, 2))).toContain('2026')
})
it('returns null for invalid persisted timestamps', () => {
expect(formatSparsePresetUpdatedAt(Number.NaN)).toBeNull()
expect(formatSparsePresetUpdatedAt(Number.POSITIVE_INFINITY)).toBeNull()
})
})

View File

@ -0,0 +1,16 @@
export function formatSparsePresetUpdatedAt(timestamp: number): string | null {
if (!Number.isFinite(timestamp)) {
return null
}
const date = new Date(timestamp)
if (Number.isNaN(date.getTime())) {
return null
}
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric'
}).format(date)
}

View File

@ -40,6 +40,11 @@ type WorktreeCardProps = {
hintNumber?: number
}
function formatSparseDirectoryPreview(directories: string[]): string {
const preview = directories.slice(0, 4).join(', ')
return directories.length <= 4 ? preview : `${preview}, +${directories.length - 4} more`
}
const WorktreeCard = React.memo(function WorktreeCard({
worktree,
repo,
@ -445,6 +450,29 @@ const WorktreeCard = React.memo(function WorktreeCard({
</TooltipContent>
</Tooltip>
)}
{worktree.isSparse && (
<Tooltip>
<TooltipTrigger asChild>
<Badge
variant="outline"
className="h-[16px] px-1.5 text-[10px] font-medium rounded shrink-0 leading-none text-amber-700 dark:text-amber-300 border-amber-500/30 bg-amber-500/5"
>
sparse
</Badge>
</TooltipTrigger>
<TooltipContent side="right" sideOffset={8} className="max-w-72">
<div className="space-y-1">
<div>Partial checkout. Files outside these paths are not on disk.</div>
{worktree.sparseDirectories && worktree.sparseDirectories.length > 0 ? (
<div className="font-mono text-[11px] opacity-80">
{formatSparseDirectoryPreview(worktree.sparseDirectories)}
</div>
) : null}
</div>
</TooltipContent>
</Tooltip>
)}
</div>
{/* CI Checks & PR state on the right */}

View File

@ -0,0 +1,387 @@
import React, { useCallback, useMemo, useRef, useState } from 'react'
import { Check, ChevronsUpDown, LoaderCircle, Pencil, Plus, RefreshCcw } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { useAppStore } from '@/store'
import { cn } from '@/lib/utils'
import { parseSparsePresetDirectories } from '@/lib/sparse-preset-draft'
import type { SparsePreset } from '../../../../shared/types'
type SparseCheckoutPresetSelectProps = {
repoId: string
presets: SparsePreset[]
selectedPresetId: string | null
onSelectPreset: (preset: SparsePreset | null) => void
disabled?: boolean
}
type PresetDraft = {
mode: 'new' | 'edit'
presetId?: string
name: string
directoriesText: string
}
export default function SparseCheckoutPresetSelect({
repoId,
presets,
selectedPresetId,
onSelectPreset,
disabled = false
}: SparseCheckoutPresetSelectProps): React.JSX.Element {
const fetchSparsePresets = useAppStore((s) => s.fetchSparsePresets)
const saveSparsePreset = useAppStore((s) => s.saveSparsePreset)
const presetsForRepo = useAppStore((s) => s.sparsePresetsByRepo[repoId])
const presetsLoadStatus = useAppStore((s) => s.sparsePresetsLoadStatusByRepo[repoId] ?? 'idle')
const presetsLoading = presetsLoadStatus === 'loading'
const presetsLoadError = useAppStore((s) => s.sparsePresetsErrorByRepo[repoId] ?? null)
const [open, setOpen] = useState(false)
const [draft, setDraft] = useState<PresetDraft | null>(null)
const [submitting, setSubmitting] = useState(false)
const nameInputRef = useRef<HTMLInputElement>(null)
const visiblePresets = presetsForRepo ?? presets
const presetsLoaded = presetsForRepo !== undefined
const isLoadingPresets = !disabled && presetsLoading
const hasPresetLoadError = !disabled && !presetsLoaded && !!presetsLoadError
const selectedPreset = useMemo(
() => visiblePresets.find((preset) => preset.id === selectedPresetId) ?? null,
[visiblePresets, selectedPresetId]
)
const parsedDirectories = draft ? parseSparsePresetDirectories(draft.directoriesText) : null
const trimmedName = draft?.name.trim() ?? ''
const nameCollision =
draft && trimmedName
? (visiblePresets.find(
(preset) =>
preset.id !== draft.presetId && preset.name.toLowerCase() === trimmedName.toLowerCase()
) ?? null)
: null
const nameError =
draft && trimmedName.length === 0
? 'Name is required.'
: trimmedName.length > 80
? 'Name must be 80 characters or fewer.'
: nameCollision
? `"${nameCollision.name}" already exists.`
: null
const canSave =
draft !== null &&
!submitting &&
!disabled &&
presetsLoaded &&
!nameError &&
parsedDirectories !== null &&
!parsedDirectories.error
const startDraft = useCallback(
(nextDraft: PresetDraft): void => {
if (disabled || !presetsLoaded) {
return
}
setDraft(nextDraft)
requestAnimationFrame(() => {
nameInputRef.current?.focus()
nameInputRef.current?.select()
})
},
[disabled, presetsLoaded]
)
const startNewPreset = useCallback((): void => {
startDraft({ mode: 'new', name: '', directoriesText: '' })
}, [startDraft])
const handleRetryLoadPresets = useCallback((): void => {
if (disabled || presetsLoading) {
return
}
setDraft(null)
void fetchSparsePresets(repoId)
}, [disabled, fetchSparsePresets, presetsLoading, repoId])
const startEditPreset = useCallback(
(preset: SparsePreset): void => {
startDraft({
mode: 'edit',
presetId: preset.id,
name: preset.name,
directoriesText: preset.directories.join('\n')
})
},
[startDraft]
)
const handleSaveDraft = useCallback(async (): Promise<void> => {
if (!draft || !canSave || !parsedDirectories) {
return
}
setSubmitting(true)
try {
const saved = await saveSparsePreset({
repoId,
id: draft.presetId,
name: trimmedName,
directories: parsedDirectories.directories
})
if (saved) {
if (draft.mode === 'new' || selectedPresetId === saved.id) {
onSelectPreset(saved)
}
setDraft(null)
setOpen(false)
}
} finally {
setSubmitting(false)
}
}, [
canSave,
draft,
onSelectPreset,
parsedDirectories,
repoId,
saveSparsePreset,
selectedPresetId,
trimmedName
])
const handleSelectOff = useCallback((): void => {
if (disabled || !presetsLoaded) {
return
}
onSelectPreset(null)
setDraft(null)
setOpen(false)
}, [disabled, onSelectPreset, presetsLoaded])
const handleSelectPreset = useCallback(
(preset: SparsePreset): void => {
if (disabled || !presetsLoaded) {
return
}
onSelectPreset(preset)
setDraft(null)
setOpen(false)
},
[disabled, onSelectPreset, presetsLoaded]
)
const triggerLabel = isLoadingPresets
? 'Loading presets...'
: hasPresetLoadError
? 'Retry loading presets'
: !presetsLoaded
? 'Load presets'
: selectedPreset
? selectedPreset.name
: 'Off'
return (
<Popover
open={open}
onOpenChange={(nextOpen) => {
if (nextOpen && presetsLoading) {
setOpen(false)
setDraft(null)
return
}
setOpen(nextOpen)
if (!nextOpen) {
setDraft(null)
}
}}
>
<PopoverTrigger asChild>
<Button
type="button"
variant="outline"
role="combobox"
aria-expanded={open}
aria-busy={isLoadingPresets}
disabled={disabled || isLoadingPresets}
className="h-9 w-full justify-between px-3 text-sm font-normal text-foreground"
>
<span className="truncate">{triggerLabel}</span>
{isLoadingPresets ? (
<LoaderCircle className="size-3.5 animate-spin opacity-60" />
) : hasPresetLoadError || !presetsLoaded ? (
<RefreshCcw className="size-3.5 opacity-60" />
) : (
<ChevronsUpDown className="size-3.5 opacity-50" />
)}
</Button>
</PopoverTrigger>
<PopoverContent
align="start"
className="w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-2rem)] p-0"
onOpenAutoFocus={(event) => event.preventDefault()}
>
{draft ? (
<form
onSubmit={(event) => {
event.preventDefault()
void handleSaveDraft()
}}
>
<div className="border-b border-border px-3 py-2 text-xs font-medium text-foreground">
{draft.mode === 'new' ? 'New preset' : 'Edit preset'}
</div>
<div className="space-y-3 px-3 py-3">
<div className="space-y-1">
<label
htmlFor="sparse-preset-name"
className="block text-[11px] font-medium text-muted-foreground"
>
Name
</label>
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 shadow-xs transition focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/30">
<input
id="sparse-preset-name"
ref={nameInputRef}
value={draft.name}
onChange={(event) => setDraft({ ...draft, name: event.target.value })}
placeholder="Renderer UI"
maxLength={80}
autoComplete="off"
spellCheck={false}
className="h-8 w-full bg-transparent text-xs text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
<div className="space-y-1">
<label
htmlFor="sparse-preset-directories"
className="block text-[11px] font-medium text-muted-foreground"
>
Directories
</label>
<div className="rounded-md border border-border/70 bg-muted/20 px-2.5 py-1.5 shadow-xs transition focus-within:border-ring/70 focus-within:ring-1 focus-within:ring-ring/30">
<textarea
id="sparse-preset-directories"
value={draft.directoriesText}
onChange={(event) =>
setDraft({ ...draft, directoriesText: event.target.value })
}
placeholder={`src/renderer\npackages/ui`}
rows={3}
spellCheck={false}
className="max-h-28 w-full min-w-0 resize-none bg-transparent font-mono text-xs leading-5 text-foreground outline-none selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
/>
</div>
</div>
</div>
<div className="flex min-h-11 items-center justify-between gap-3 border-t border-border px-3 py-2">
<div className="min-w-0 text-[10px] text-muted-foreground">
{nameError ? (
<span className="text-destructive">{nameError}</span>
) : parsedDirectories?.error ? (
<span className="text-destructive">{parsedDirectories.error}</span>
) : parsedDirectories?.directories.length === 1 ? (
'1 directory'
) : (
`${parsedDirectories?.directories.length ?? 0} directories`
)}
</div>
<div className="flex shrink-0 justify-end gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="h-7 px-2 text-xs text-muted-foreground"
onClick={() => setDraft(null)}
disabled={submitting}
>
Cancel
</Button>
<Button type="submit" size="sm" className="h-7 px-2 text-xs" disabled={!canSave}>
{submitting ? <LoaderCircle className="size-3 animate-spin" /> : null}
Save
</Button>
</div>
</div>
</form>
) : !presetsLoaded ? (
<div className="p-1">
{hasPresetLoadError ? (
<div className="px-2 py-1.5 text-[11px] text-destructive">
<span className="break-words">{presetsLoadError}</span>
</div>
) : null}
<button
type="button"
className="flex h-9 w-full items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground"
onClick={handleRetryLoadPresets}
>
<RefreshCcw className="size-3.5 text-muted-foreground" />
<span className="truncate">
{hasPresetLoadError ? 'Retry loading presets' : 'Load presets'}
</span>
</button>
</div>
) : (
<div>
<div className="py-1">
<button
type="button"
className="mx-1 flex h-9 w-[calc(100%-0.5rem)] items-center gap-2 rounded-md px-2 text-left text-xs hover:bg-accent hover:text-accent-foreground"
onClick={handleSelectOff}
>
<Check className={cn('size-4', selectedPreset ? 'opacity-0' : 'opacity-100')} />
Off
</button>
</div>
{visiblePresets.length > 0 ? (
<>
<div className="h-px bg-border" />
<div className="space-y-0.5 py-1">
{visiblePresets.map((preset) => (
<div
key={preset.id}
className="mx-1 flex items-center rounded-md hover:bg-accent hover:text-accent-foreground"
>
<button
type="button"
className="flex h-9 min-w-0 flex-1 items-center gap-2 rounded-l-md px-2 text-left text-xs"
onClick={() => handleSelectPreset(preset)}
>
<Check
className={cn(
'size-4 shrink-0',
selectedPreset?.id === preset.id ? 'opacity-100' : 'opacity-0'
)}
/>
<span className="truncate">{preset.name}</span>
</button>
<Button
type="button"
variant="ghost"
size="icon-xs"
aria-label={`Edit ${preset.name}`}
className="mr-1 size-7 shrink-0 rounded-md text-muted-foreground hover:bg-background/35 hover:text-foreground"
onClick={() => startEditPreset(preset)}
>
<Pencil className="size-3.5" />
</Button>
</div>
))}
</div>
</>
) : null}
<div className="border-t border-border">
<Button
type="button"
variant="ghost"
onClick={startNewPreset}
className="mx-1 my-1 h-8 w-[calc(100%-0.5rem)] justify-start rounded-md px-2 text-xs font-normal"
>
<Plus className="size-3.5 text-muted-foreground" />
New preset
</Button>
</div>
</div>
)}
</PopoverContent>
</Popover>
)
}

View File

@ -16,6 +16,7 @@ import type {
OrcaHooks,
SetupDecision,
SetupRunPolicy,
SparsePreset,
TuiAgent
} from '../../../shared/types'
import {
@ -35,6 +36,7 @@ import {
} from '@/lib/new-workspace'
import { getSuggestedCreatureName } from '@/components/sidebar/worktree-name-suggestions'
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
import { normalizeSparseDirectoryLines, sparseDirectoriesMatch } from '@/lib/sparse-paths'
export type UseComposerStateOptions = {
initialRepoId?: string
@ -119,6 +121,13 @@ export type ComposerCardProps = {
shouldWaitForSetupCheck: boolean
resolvedSetupDecision: 'run' | 'skip' | null
createError: string | null
canUseSparseCheckout: boolean
/** Saved presets for the currently-selected repo. Empty array when no
* presets exist or when the repo is remote. */
sparsePresets: SparsePreset[]
/** ID of the selected sparse preset. Null means sparse checkout is off. */
sparseSelectedPresetId: string | null
onSparseSelectPreset: (preset: SparsePreset | null) => void
}
export type UseComposerStateResult = {
@ -142,6 +151,7 @@ export type UseComposerStateResult = {
// modal wins when both are present, and the page takes over once the modal
// closes.
const composerDropStack: symbol[] = []
const EMPTY_SPARSE_PRESETS: SparsePreset[] = []
export function useComposerState(options: UseComposerStateOptions): UseComposerStateResult {
const {
@ -172,7 +182,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
closeModal: s.closeModal,
openSettingsPage: s.openSettingsPage,
openSettingsTarget: s.openSettingsTarget,
prefetchWorkItems: s.prefetchWorkItems
prefetchWorkItems: s.prefetchWorkItems,
fetchSparsePresets: s.fetchSparsePresets
}))
)
const {
@ -186,7 +197,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
closeModal,
openSettingsPage,
openSettingsTarget,
prefetchWorkItems
prefetchWorkItems,
fetchSparsePresets
} = actions
const repos = useAppStore((s) => s.repos)
@ -194,7 +206,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const settings = useAppStore((s) => s.settings)
const newWorkspaceDraft = useAppStore((s) => s.newWorkspaceDraft)
const worktreesByRepo = useAppStore((s) => s.worktreesByRepo)
const sparsePresetsByRepo = useAppStore((s) => s.sparsePresetsByRepo)
const eligibleRepos = useMemo(() => repos.filter((repo) => isGitRepoKind(repo)), [repos])
const draftRepoId = persistDraft ? (newWorkspaceDraft?.repoId ?? null) : null
@ -298,6 +310,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
const [advancedOpen, setAdvancedOpen] = useState(
persistDraft ? Boolean((newWorkspaceDraft?.note ?? '').trim()) : false
)
const [sparseEnabled, setSparseEnabled] = useState(false)
const [sparseDirectories, setSparseDirectories] = useState('')
const [sparseSelectedPresetId, setSparseSelectedPresetId] = useState<string | null>(null)
const [linkPopoverOpen, setLinkPopoverOpen] = useState(false)
const [linkQuery, setLinkQuery] = useState('')
@ -331,6 +346,46 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
agentPromptRef.current = agentPrompt
const selectedRepo = eligibleRepos.find((repo) => repo.id === repoId)
const sparsePresetsForRepo = sparsePresetsByRepo[repoId]
const sparsePresets = sparsePresetsForRepo ?? EMPTY_SPARSE_PRESETS
const normalizedSparseDirectories = useMemo(
() => normalizeSparseDirectoryLines(sparseDirectories),
[sparseDirectories]
)
// Why: a preset attribution should only ride along if what's about to be
// created actually equals the saved preset. If the user picked a preset and
// then edited the textarea, we want the worktree to be a "Custom" sparse
// checkout — not falsely tagged as the original preset.
const effectivePresetId = useMemo(() => {
if (!sparseSelectedPresetId) {
return null
}
const selected = sparsePresets.find((preset) => preset.id === sparseSelectedPresetId)
if (!selected) {
return null
}
return sparseDirectoriesMatch(selected.directories, normalizedSparseDirectories)
? selected.id
: null
}, [normalizedSparseDirectories, sparsePresets, sparseSelectedPresetId])
const sparseError = useMemo(() => {
if (!sparseEnabled) {
return null
}
if (selectedRepo?.connectionId) {
return 'Sparse checkout is only supported for local repos right now.'
}
if (normalizedSparseDirectories.length === 0) {
return 'Enter at least one repo-relative directory.'
}
if (
normalizedSparseDirectories.some((entry) => entry === '.' || entry.split('/').includes('..'))
) {
return 'Use repo-relative directories, not root or parent paths.'
}
return null
}, [normalizedSparseDirectories, selectedRepo?.connectionId, sparseEnabled])
const parsedLinkedIssueNumber = useMemo(
() => (linkedIssue.trim() ? parseGitHubIssueOrPRNumber(linkedIssue) : null),
[linkedIssue]
@ -483,6 +538,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
}
}, [eligibleRepos, repoId, setRepoId])
// Why: the compact sparse dropdown is always visible under Advanced, so
// presets must load before sparse mode is enabled.
useEffect(() => {
if (!repoId || selectedRepo?.connectionId) {
return
}
if (sparsePresetsByRepo[repoId] !== undefined) {
return
}
void fetchSparsePresets(repoId)
}, [fetchSparsePresets, repoId, selectedRepo?.connectionId, sparsePresetsByRepo])
// Why: detect agents for the selected repo. For local repos this runs once
// on mount (deduped by the store). For remote repos it re-runs when the
// selected repo changes so the agent list matches the SSH host.
@ -920,6 +987,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setLinkedIssue('')
setLinkedPR(null)
setLinkedWorkItem(null)
setSparseEnabled(false)
setSparseDirectories('')
// Why: presets are repo-scoped, so a stale selection from the prior
// repo would be meaningless after a repo switch.
setSparseSelectedPresetId(null)
// Why: the Start-from picker is repo-scoped, so any prior branch/PR
// selection is meaningless in the new repo. Resetting to undefined
// makes the field fall back to the new repo's effective base ref.
@ -929,6 +1001,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
[baseBranch, linkedWorkItem, repoId, setRepoId]
)
const handleSparseSelectPreset = useCallback((preset: SparsePreset | null): void => {
if (preset) {
setSparseEnabled(true)
setSparseDirectories(preset.directories.join('\n'))
setSparseSelectedPresetId(preset.id)
} else {
setSparseEnabled(false)
setSparseDirectories('')
setSparseSelectedPresetId(null)
}
}, [])
const handleBaseBranchChange = useCallback((next: string | undefined): void => {
setBaseBranch(next)
setStartFromResetHint(null)
@ -993,7 +1077,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
!selectedRepo ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
(requiresExplicitSetupChoice && !setupDecision)
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
) {
return
}
@ -1015,7 +1100,18 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
: await ensureHooksConfirmed(useAppStore.getState(), repoId, 'issueCommand')
}
const result = await createWorktree(repoId, workspaceName, baseBranch, effectiveSetupDecision)
const result = await createWorktree(
repoId,
workspaceName,
baseBranch,
effectiveSetupDecision,
sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
: undefined
)
const worktree = result.worktree
await applyWorktreeMeta(worktree.id, {
@ -1075,6 +1171,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
issueCommandTemplate,
linkedPR,
linkedWorkItem?.url,
normalizedSparseDirectories,
note,
onCreated,
parsedLinkedIssueNumber,
@ -1089,6 +1186,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setRightSidebarTab,
setSidebarOpen,
setupDecision,
sparseEnabled,
sparseError,
effectivePresetId,
tuiAgent,
shouldRunIssueAutomation,
shouldWaitForIssueAutomationCheck,
@ -1111,7 +1211,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
!workspaceName ||
!selectedRepo ||
shouldWaitForSetupCheck ||
(requiresExplicitSetupChoice && !setupDecision)
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
) {
return
}
@ -1129,7 +1230,13 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
repoId,
workspaceName,
baseBranch,
effectiveSetupDecision
effectiveSetupDecision,
sparseEnabled
? {
directories: normalizedSparseDirectories,
...(effectivePresetId ? { presetId: effectivePresetId } : {})
}
: undefined
)
const worktree = result.worktree
@ -1181,6 +1288,7 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
createWorktree,
fallbackCreatureName,
name,
normalizedSparseDirectories,
note,
onCreated,
persistDraft,
@ -1194,6 +1302,9 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
setRightSidebarTab,
setSidebarOpen,
setupDecision,
sparseEnabled,
sparseError,
effectivePresetId,
shouldWaitForSetupCheck
]
)
@ -1204,7 +1315,8 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
creating ||
shouldWaitForSetupCheck ||
shouldWaitForIssueAutomationCheck ||
(requiresExplicitSetupChoice && !setupDecision)
(requiresExplicitSetupChoice && !setupDecision) ||
sparseError !== null
const cardProps: ComposerCardProps = {
eligibleRepos,
@ -1258,7 +1370,11 @@ export function useComposerState(options: UseComposerStateOptions): UseComposerS
onSetupDecisionChange: setSetupDecision,
shouldWaitForSetupCheck,
resolvedSetupDecision,
createError
createError,
canUseSparseCheckout: !selectedRepo?.connectionId,
sparsePresets,
sparseSelectedPresetId,
onSparseSelectPreset: handleSparseSelectPreset
}
return {

View File

@ -0,0 +1,41 @@
const WINDOWS_DRIVE_PATH_PATTERN = /^[A-Za-z]:/
export function isAbsoluteSparseDirectoryPath(value: string): boolean {
const entry = value.trim()
return entry.startsWith('/') || entry.startsWith('\\') || WINDOWS_DRIVE_PATH_PATTERN.test(entry)
}
/** Normalize the user's free-form textarea input into a clean directory list:
* trim whitespace, convert backslashes to forward slashes, strip leading and
* trailing slashes, drop empty lines, dedupe. Order preserved so the
* textarea round-trips with the user's typing intent intact. */
export function normalizeSparseDirectoryLines(value: string): string[] {
const seen = new Set<string>()
return value
.split('\n')
.map((entry) =>
entry
.trim()
.replace(/\\/g, '/')
.replace(/^\/+|\/+$/g, '')
)
.filter((entry) => entry.length > 0)
.filter((entry) => {
if (seen.has(entry)) {
return false
}
seen.add(entry)
return true
})
}
/** Order-independent set comparison so "shared/ui\npackages/web" matches
* "packages/web\nshared/ui" used by the composer to decide whether the
* current textarea content matches a saved preset. */
export function sparseDirectoriesMatch(left: string[], right: string[]): boolean {
if (left.length !== right.length) {
return false
}
const set = new Set(left)
return right.every((entry) => set.has(entry))
}

View File

@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest'
import { parseSparsePresetDirectories } from './sparse-preset-draft'
describe('parseSparsePresetDirectories', () => {
it('normalizes textarea input into unique repo-relative directories', () => {
expect(
parseSparsePresetDirectories(`
src\\renderer
packages/ui/
src/renderer
`)
).toEqual({
directories: ['src/renderer', 'packages/ui'],
error: null
})
})
it('requires at least one directory', () => {
expect(parseSparsePresetDirectories(' \n ')).toEqual({
directories: [],
error: 'Add at least one directory.'
})
})
it('rejects root and parent path entries', () => {
expect(parseSparsePresetDirectories('.')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
expect(parseSparsePresetDirectories('src/../packages')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
expect(parseSparsePresetDirectories('/')).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
})
it.each(['/Users/me/repo/packages/web', 'C:\\repo\\packages\\web', '\\\\server\\share\\repo'])(
'rejects absolute directory input before normalization: %s',
(entry) => {
expect(parseSparsePresetDirectories(entry)).toEqual({
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
})
}
)
})

View File

@ -0,0 +1,42 @@
import { isAbsoluteSparseDirectoryPath, normalizeSparseDirectoryLines } from '@/lib/sparse-paths'
export type SparsePresetDirectoryParseResult = {
directories: string[]
error: string | null
}
export function parseSparsePresetDirectories(value: string): SparsePresetDirectoryParseResult {
const rawEntries = value
.split('\n')
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
// Why: absolute paths can look repo-relative after slash normalization.
if (rawEntries.some(isAbsoluteSparseDirectoryPath)) {
return {
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
}
}
const directories = normalizeSparseDirectoryLines(value)
if (directories.length === 0) {
return {
directories,
error: 'Add at least one directory.'
}
}
if (directories.some((entry) => entry === '.' || entry.split('/').includes('..'))) {
return {
directories: [],
error: 'Use repo-relative directories, not root, absolute paths, or parent segments.'
}
}
return {
directories,
error: null
}
}

View File

@ -1,6 +1,7 @@
import { create } from 'zustand'
import type { AppState } from './types'
import { createRepoSlice } from './slices/repos'
import { createSparsePresetsSlice } from './slices/sparse-presets'
import { createWorktreeSlice } from './slices/worktrees'
import { createTerminalSlice } from './slices/terminals'
import { createTabsSlice } from './slices/tabs'
@ -25,6 +26,7 @@ import { registerHttpLinkStoreAccessor } from '@/lib/http-link-routing'
export const useAppStore = create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),

View File

@ -0,0 +1,233 @@
import { create } from 'zustand'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { SparsePreset } from '../../../../shared/types'
import type { AppState } from '../types'
import { createSparsePresetsSlice } from './sparse-presets'
vi.mock('sonner', () => ({
toast: {
success: vi.fn(),
error: vi.fn()
}
}))
const mockApi = {
sparsePresets: {
list: vi.fn(),
save: vi.fn(),
remove: vi.fn()
}
}
// @ts-expect-error -- test shim
globalThis.window = { api: mockApi }
function createTestStore() {
return create<AppState>()((...a) => ({ ...createSparsePresetsSlice(...a) }) as AppState)
}
function makePreset(
overrides: Partial<SparsePreset> & { id: string; repoId: string }
): SparsePreset {
return {
name: overrides.id,
directories: ['packages/app'],
createdAt: 1,
updatedAt: 1,
...overrides
}
}
describe('createSparsePresetsSlice', () => {
beforeEach(() => {
vi.clearAllMocks()
mockApi.sparsePresets.list.mockResolvedValue([])
mockApi.sparsePresets.save.mockImplementation((args: Partial<SparsePreset>) =>
Promise.resolve(
makePreset({
id: args.id ?? `preset-${args.name}`,
repoId: args.repoId ?? 'repo-1',
name: args.name ?? 'Preset',
directories: args.directories ?? ['packages/app'],
updatedAt: 2
})
)
)
mockApi.sparsePresets.remove.mockResolvedValue(undefined)
})
it('fetches presets into the requested repo bucket', async () => {
const store = createTestStore()
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
mockApi.sparsePresets.list.mockResolvedValueOnce([preset])
await store.getState().fetchSparsePresets('repo-1')
expect(mockApi.sparsePresets.list).toHaveBeenCalledWith({ repoId: 'repo-1' })
expect(store.getState().sparsePresetsByRepo).toEqual({ 'repo-1': [preset] })
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBeUndefined()
})
it('keeps an unfetched repo bucket missing while presets are loading', async () => {
const store = createTestStore()
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
let resolveList: (presets: SparsePreset[]) => void = () => {}
mockApi.sparsePresets.list.mockReturnValueOnce(
new Promise<SparsePreset[]>((resolve) => {
resolveList = resolve
})
)
const fetchPromise = store.getState().fetchSparsePresets('repo-1')
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(true)
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loading')
resolveList([preset])
await fetchPromise
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
})
it('does not refetch while a repo bucket is loading or already loaded', async () => {
const store = createTestStore()
let resolveList: (presets: SparsePreset[]) => void = () => {}
mockApi.sparsePresets.list.mockReturnValueOnce(
new Promise<SparsePreset[]>((resolve) => {
resolveList = resolve
})
)
const fetchPromise = store.getState().fetchSparsePresets('repo-1')
await store.getState().fetchSparsePresets('repo-1')
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(1)
resolveList([])
await fetchPromise
await store.getState().fetchSparsePresets('repo-1')
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(1)
})
it('clears loading state without marking the repo loaded when fetch fails', async () => {
const store = createTestStore()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed'))
try {
await store.getState().fetchSparsePresets('repo-1')
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
expect(store.getState().sparsePresetsLoadingByRepo['repo-1']).toBe(false)
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error')
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBe('disk failed')
} finally {
consoleError.mockRestore()
}
})
it('clears a failed fetch status when retrying presets succeeds', async () => {
const store = createTestStore()
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockApi.sparsePresets.list
.mockRejectedValueOnce(new Error('disk failed'))
.mockResolvedValueOnce([preset])
try {
await store.getState().fetchSparsePresets('repo-1')
await store.getState().fetchSparsePresets('repo-1')
expect(mockApi.sparsePresets.list).toHaveBeenCalledTimes(2)
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
expect(store.getState().sparsePresetsErrorByRepo['repo-1']).toBeUndefined()
} finally {
consoleError.mockRestore()
}
})
it('saves presets per repo and sorts the repo list by name', async () => {
const store = createTestStore()
store.setState({
sparsePresetsByRepo: {
'repo-1': [makePreset({ id: 'z', repoId: 'repo-1', name: 'Zed' })],
'repo-2': [makePreset({ id: 'other', repoId: 'repo-2', name: 'Other' })]
}
} as Partial<AppState>)
const saved = await store.getState().saveSparsePreset({
repoId: 'repo-1',
name: 'Api',
directories: ['packages/api']
})
expect(saved?.name).toBe('Api')
expect(store.getState().sparsePresetsByRepo['repo-1'].map((preset) => preset.name)).toEqual([
'Api',
'Zed'
])
expect(store.getState().sparsePresetsByRepo['repo-2'].map((preset) => preset.name)).toEqual([
'Other'
])
})
it('loads an unfetched repo bucket before saving so existing presets stay visible', async () => {
const store = createTestStore()
const existing = makePreset({ id: 'existing', repoId: 'repo-1', name: 'Existing' })
mockApi.sparsePresets.list.mockResolvedValueOnce([existing])
const saved = await store.getState().saveSparsePreset({
repoId: 'repo-1',
name: 'Api',
directories: ['packages/api']
})
expect(mockApi.sparsePresets.list).toHaveBeenCalledWith({ repoId: 'repo-1' })
expect(mockApi.sparsePresets.save).toHaveBeenCalledTimes(1)
expect(saved?.name).toBe('Api')
expect(store.getState().sparsePresetsByRepo['repo-1'].map((preset) => preset.name)).toEqual([
'Api',
'Existing'
])
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('loaded')
})
it('does not save or synthesize a repo bucket when presets fail to load first', async () => {
const store = createTestStore()
const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {})
mockApi.sparsePresets.list.mockRejectedValueOnce(new Error('disk failed'))
try {
const saved = await store.getState().saveSparsePreset({
repoId: 'repo-1',
name: 'Api',
directories: ['packages/api']
})
expect(saved).toBeNull()
expect(mockApi.sparsePresets.save).not.toHaveBeenCalled()
expect(store.getState().sparsePresetsByRepo['repo-1']).toBeUndefined()
expect(store.getState().sparsePresetsLoadStatusByRepo['repo-1']).toBe('error')
} finally {
consoleError.mockRestore()
}
})
it('restores the previous repo presets when remove fails', async () => {
const store = createTestStore()
const preset = makePreset({ id: 'preset-1', repoId: 'repo-1', name: 'Web' })
mockApi.sparsePresets.remove.mockRejectedValueOnce(new Error('disk failed'))
store.setState({ sparsePresetsByRepo: { 'repo-1': [preset] } } as Partial<AppState>)
await store.getState().removeSparsePreset({ repoId: 'repo-1', presetId: 'preset-1' })
expect(store.getState().sparsePresetsByRepo['repo-1']).toEqual([preset])
})
})

View File

@ -0,0 +1,135 @@
import type { StateCreator } from 'zustand'
import { toast } from 'sonner'
import type { AppState } from '../types'
import type { SparsePreset } from '../../../../shared/types'
const ERROR_TOAST_DURATION = 60_000
export type SparsePresetsLoadStatus = 'idle' | 'loading' | 'loaded' | 'error'
export type SparsePresetsSlice = {
/** Per-repo preset list. Lazily populated by `fetchSparsePresets`; missing
* key means "not yet fetched", empty array means "fetched, none exist". */
sparsePresetsByRepo: Record<string, SparsePreset[]>
/** Per-repo fetch guard so missing preset buckets keep their loading meaning. */
sparsePresetsLoadingByRepo: Record<string, boolean>
sparsePresetsLoadStatusByRepo: Record<string, SparsePresetsLoadStatus>
sparsePresetsErrorByRepo: Record<string, string | undefined>
fetchSparsePresets: (repoId: string) => Promise<void>
saveSparsePreset: (args: {
repoId: string
id?: string
name: string
directories: string[]
}) => Promise<SparsePreset | null>
removeSparsePreset: (args: { repoId: string; presetId: string }) => Promise<void>
}
export const createSparsePresetsSlice: StateCreator<AppState, [], [], SparsePresetsSlice> = (
set,
get
) => ({
sparsePresetsByRepo: {},
sparsePresetsLoadingByRepo: {},
sparsePresetsLoadStatusByRepo: {},
sparsePresetsErrorByRepo: {},
fetchSparsePresets: async (repoId) => {
const state = get()
if (
state.sparsePresetsByRepo[repoId] !== undefined ||
state.sparsePresetsLoadingByRepo[repoId]
) {
return
}
set((s) => ({
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: true },
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'loading' },
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: undefined }
}))
try {
const presets = await window.api.sparsePresets.list({ repoId })
set((s) => ({
sparsePresetsByRepo: { ...s.sparsePresetsByRepo, [repoId]: presets },
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: false },
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'loaded' },
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: undefined }
}))
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
set((s) => ({
sparsePresetsLoadingByRepo: { ...s.sparsePresetsLoadingByRepo, [repoId]: false },
sparsePresetsLoadStatusByRepo: { ...s.sparsePresetsLoadStatusByRepo, [repoId]: 'error' },
sparsePresetsErrorByRepo: { ...s.sparsePresetsErrorByRepo, [repoId]: message }
}))
console.error(`Failed to fetch sparse presets for repo ${repoId}:`, err)
}
},
saveSparsePreset: async (args) => {
try {
if (get().sparsePresetsByRepo[args.repoId] === undefined) {
// Why: a saved preset alone is not an authoritative repo bucket; load
// existing presets first so we do not hide them behind a one-item cache.
await get().fetchSparsePresets(args.repoId)
if (get().sparsePresetsByRepo[args.repoId] === undefined) {
toast.error(args.id ? 'Failed to update preset' : 'Failed to save preset', {
description: 'Presets must load before saving.',
duration: ERROR_TOAST_DURATION
})
return null
}
}
const saved = await window.api.sparsePresets.save(args)
set((s) => {
const existing = s.sparsePresetsByRepo[args.repoId]
if (existing === undefined) {
return {}
}
const without = existing.filter((preset) => preset.id !== saved.id)
return {
sparsePresetsByRepo: {
...s.sparsePresetsByRepo,
[args.repoId]: [...without, saved].sort((left, right) =>
left.name.localeCompare(right.name)
)
}
}
})
toast.success(args.id ? 'Preset updated' : 'Preset saved', { description: saved.name })
return saved
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
toast.error(args.id ? 'Failed to update preset' : 'Failed to save preset', {
description: message,
duration: ERROR_TOAST_DURATION
})
return null
}
},
removeSparsePreset: async ({ repoId, presetId }) => {
const previous = get().sparsePresetsByRepo[repoId] ?? []
// Why: optimistic local update keeps the popover responsive — toast handles
// the failure path by restoring state.
set((s) => ({
sparsePresetsByRepo: {
...s.sparsePresetsByRepo,
[repoId]: previous.filter((preset) => preset.id !== presetId)
}
}))
try {
await window.api.sparsePresets.remove({ repoId, presetId })
toast.success('Preset removed')
} catch (err) {
set((s) => ({
sparsePresetsByRepo: { ...s.sparsePresetsByRepo, [repoId]: previous }
}))
const message = err instanceof Error ? err.message : String(err)
toast.error('Failed to remove preset', {
description: message,
duration: ERROR_TOAST_DURATION
})
}
}
})

View File

@ -90,6 +90,7 @@ const mockApi = {
globalThis.window = { api: mockApi }
import { createRepoSlice } from './repos'
import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@ -113,6 +114,7 @@ import { createWorktreeNavHistorySlice } from './worktree-nav-history'
function createTestStore() {
return create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),

View File

@ -9,6 +9,7 @@ import type {
} from '../../../../shared/types'
import type { OpenFile } from './editor'
import { createRepoSlice } from './repos'
import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@ -40,6 +41,7 @@ export const TEST_REPO = {
export function createTestStore() {
return create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),

View File

@ -85,6 +85,7 @@ const mockApi = {
globalThis.window = { api: mockApi }
import { createRepoSlice } from './repos'
import { createSparsePresetsSlice } from './sparse-presets'
import { createWorktreeSlice } from './worktrees'
import { createTerminalSlice } from './terminals'
import { createTabsSlice } from './tabs'
@ -110,6 +111,7 @@ const WT = 'repo1::/tmp/feature'
function createTestStore() {
return create<AppState>()((...a) => ({
...createRepoSlice(...a),
...createSparsePresetsSlice(...a),
...createWorktreeSlice(...a),
...createTerminalSlice(...a),
...createTabsSlice(...a),

View File

@ -1,5 +1,6 @@
import type {
CreateWorktreeResult,
CreateSparseCheckoutRequest,
SetupDecision,
Worktree,
WorktreeMeta
@ -40,7 +41,8 @@ export type WorktreeSlice = {
repoId: string,
name: string,
baseBranch?: string,
setupDecision?: SetupDecision
setupDecision?: SetupDecision,
sparseCheckout?: CreateSparseCheckoutRequest
) => Promise<CreateWorktreeResult>
removeWorktree: (
worktreeId: string,

View File

@ -11,6 +11,16 @@ import {
import { ensureHooksConfirmed } from '@/lib/ensure-hooks-confirmed'
export type { WorktreeSlice, WorktreeDeleteState } from './worktree-helpers'
function arraysShallowEqual(a: string[] | undefined, b: string[] | undefined): boolean {
if (a === b) {
return true
}
if (!a || !b || a.length !== b.length) {
return !a?.length && !b?.length
}
return a.every((v, i) => v === b[i])
}
function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): boolean {
if (!current || current.length !== next.length) {
return false
@ -26,6 +36,7 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.branch === candidate.branch &&
worktree.isBare === candidate.isBare &&
worktree.isMainWorktree === candidate.isMainWorktree &&
worktree.isSparse === candidate.isSparse &&
worktree.displayName === candidate.displayName &&
worktree.comment === candidate.comment &&
worktree.linkedIssue === candidate.linkedIssue &&
@ -34,7 +45,9 @@ function areWorktreesEqual(current: Worktree[] | undefined, next: Worktree[]): b
worktree.isUnread === candidate.isUnread &&
worktree.isPinned === candidate.isPinned &&
worktree.sortOrder === candidate.sortOrder &&
worktree.lastActivityAt === candidate.lastActivityAt
worktree.lastActivityAt === candidate.lastActivityAt &&
worktree.sparseBaseRef === candidate.sparseBaseRef &&
arraysShallowEqual(worktree.sparseDirectories, candidate.sparseDirectories)
)
})
}
@ -86,7 +99,7 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
await Promise.all(repos.map((r) => get().fetchWorktrees(r.id)))
},
createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit') => {
createWorktree: async (repoId, name, baseBranch, setupDecision = 'inherit', sparseCheckout) => {
const retryableConflictPatterns = [
/already exists locally/i,
/already exists on a remote/i,
@ -103,7 +116,8 @@ export const createWorktreeSlice: StateCreator<AppState, [], [], WorktreeSlice>
repoId,
name: candidateName,
baseBranch,
setupDecision
setupDecision,
sparseCheckout
})
// Why: a file watcher (worktrees.onChanged) can fire between the
// backend creating the worktree and this callback running, causing

View File

@ -1,4 +1,5 @@
import type { RepoSlice } from './slices/repos'
import type { SparsePresetsSlice } from './slices/sparse-presets'
import type { WorktreeSlice } from './slices/worktrees'
import type { TerminalSlice } from './slices/terminals'
import type { TabsSlice } from './slices/tabs'
@ -20,6 +21,7 @@ import type { DetectedAgentsSlice } from './slices/detected-agents'
import type { WorktreeNavHistorySlice } from './slices/worktree-nav-history'
export type AppState = RepoSlice &
SparsePresetsSlice &
WorktreeSlice &
TerminalSlice &
TabsSlice &

View File

@ -210,6 +210,7 @@ export function getDefaultPersistedState(homedir: string): PersistedState {
return {
schemaVersion: SCHEMA_VERSION,
repos: [],
sparsePresetsByRepo: {},
worktreeMeta: {},
settings: getDefaultSettings(homedir),
ui: getDefaultUIState(),

View File

@ -48,6 +48,7 @@ export type GitWorktreeInfo = {
head: string
branch: string
isBare: boolean
isSparse?: boolean
/** True for the repo's main working tree (the first entry from `git worktree list`).
* Linked worktrees created via `git worktree add` have this set to false. */
isMainWorktree: boolean
@ -67,6 +68,11 @@ export type Worktree = {
isPinned: boolean
sortOrder: number
lastActivityAt: number
sparseDirectories?: string[]
sparseBaseRef?: string
/** ID of the saved preset this worktree was created from, if any. Cleared
* when the worktree is no longer sparse on refresh. */
sparsePresetId?: string
diffComments?: DiffComment[]
} & GitWorktreeInfo
@ -82,6 +88,9 @@ export type WorktreeMeta = {
isPinned: boolean
sortOrder: number
lastActivityAt: number
sparseDirectories?: string[]
sparseBaseRef?: string
sparsePresetId?: string
diffComments?: DiffComment[]
}
@ -690,11 +699,32 @@ export type WorktreeSetupLaunch = {
envVars: Record<string, string>
}
export type CreateSparseCheckoutRequest = {
directories: string[]
/** Set when the directories came from a saved preset and the user did not
* modify them recorded on WorktreeMeta so the worktree can show "from
* preset X" later. Cleared if the user edited the textarea. */
presetId?: string
}
/** A reusable per-repo sparse directory list. Saved by the user from the
* composer; surfaced again the next time they create a worktree in the same
* repo. The MVP scope (no preset) is `presetId === undefined`. */
export type SparsePreset = {
id: string
repoId: string
name: string
directories: string[]
createdAt: number
updatedAt: number
}
export type CreateWorktreeArgs = {
repoId: string
name: string
baseBranch?: string
setupDecision?: SetupDecision
sparseCheckout?: CreateSparseCheckoutRequest
}
export type CreateWorktreeResult = {
@ -1184,6 +1214,9 @@ export type PersistedTrustedOrcaHooks = Record<string, PersistedTrustedOrcaHookR
export type PersistedState = {
schemaVersion: number
repos: Repo[]
/** Sparse-checkout presets keyed by repoId. Empty record on first launch;
* presets are managed from the new-workspace composer and repo settings. */
sparsePresetsByRepo: Record<string, SparsePreset[]>
worktreeMeta: Record<string, WorktreeMeta>
settings: GlobalSettings
ui: PersistedUIState