Files
orca/src/main/ipc/worktrees.test.ts
T

1019 lines
31 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* eslint-disable max-lines */
import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
handleMock,
removeHandlerMock,
listWorktreesMock,
addWorktreeMock,
addSparseWorktreeMock,
removeWorktreeMock,
getGitUsernameMock,
getDefaultBaseRefMock,
getBranchConflictKindMock,
getPRForBranchMock,
getEffectiveHooksMock,
createIssueCommandRunnerScriptMock,
createSetupRunnerScriptMock,
shouldRunSetupForCreateMock,
runHookMock,
hasHooksFileMock,
loadHooksMock,
computeWorktreePathMock,
ensurePathWithinWorkspaceMock,
gitExecFileAsyncMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
removeHandlerMock: vi.fn(),
listWorktreesMock: vi.fn(),
addWorktreeMock: vi.fn(),
addSparseWorktreeMock: vi.fn(),
removeWorktreeMock: vi.fn(),
getGitUsernameMock: vi.fn(),
getDefaultBaseRefMock: vi.fn(),
getBranchConflictKindMock: vi.fn(),
getPRForBranchMock: vi.fn(),
getEffectiveHooksMock: vi.fn(),
createIssueCommandRunnerScriptMock: vi.fn(),
createSetupRunnerScriptMock: vi.fn(),
shouldRunSetupForCreateMock: vi.fn(),
runHookMock: vi.fn(),
hasHooksFileMock: vi.fn(),
loadHooksMock: vi.fn(),
computeWorktreePathMock: vi.fn(),
ensurePathWithinWorkspaceMock: vi.fn(),
gitExecFileAsyncMock: vi.fn()
}))
vi.mock('electron', () => ({
ipcMain: {
handle: handleMock,
removeHandler: removeHandlerMock
}
}))
vi.mock('../git/worktree', () => ({
listWorktrees: listWorktreesMock,
addWorktree: addWorktreeMock,
addSparseWorktree: addSparseWorktreeMock,
removeWorktree: removeWorktreeMock
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
gitExecFileSync: vi.fn()
}))
vi.mock('../git/repo', () => ({
getGitUsername: getGitUsernameMock,
getDefaultBaseRef: getDefaultBaseRefMock,
getBranchConflictKind: getBranchConflictKindMock
}))
vi.mock('../github/client', () => ({
getPRForBranch: getPRForBranchMock
}))
vi.mock('../hooks', () => ({
createIssueCommandRunnerScript: createIssueCommandRunnerScriptMock,
createSetupRunnerScript: createSetupRunnerScriptMock,
getEffectiveHooks: getEffectiveHooksMock,
loadHooks: loadHooksMock,
runHook: runHookMock,
hasHooksFile: hasHooksFileMock,
shouldRunSetupForCreate: shouldRunSetupForCreateMock
}))
vi.mock('./worktree-logic', async (importOriginal) => {
const actual = (await importOriginal()) as Record<string, unknown>
return {
...actual,
computeWorktreePath: computeWorktreePathMock,
ensurePathWithinWorkspace: ensurePathWithinWorkspaceMock
}
})
const { deleteWorktreeHistoryDirMock } = vi.hoisted(() => ({
deleteWorktreeHistoryDirMock: vi.fn()
}))
vi.mock('../terminal-history', () => ({
deleteWorktreeHistoryDir: deleteWorktreeHistoryDirMock
}))
const { killAllProcessesForWorktreeMock, getLocalPtyProviderMock } = vi.hoisted(() => ({
killAllProcessesForWorktreeMock: vi.fn(),
getLocalPtyProviderMock: vi.fn()
}))
vi.mock('../runtime/worktree-teardown', () => ({
killAllProcessesForWorktree: killAllProcessesForWorktreeMock
}))
vi.mock('./pty', () => ({
getLocalPtyProvider: getLocalPtyProviderMock
}))
import { registerWorktreeHandlers } from './worktrees'
type HandlerMap = Record<string, (_event: unknown, args: unknown) => unknown>
describe('registerWorktreeHandlers', () => {
const handlers: HandlerMap = {}
const mainWindow = {
isDestroyed: () => false,
webContents: {
send: vi.fn()
}
}
const store = {
getRepos: vi.fn(),
getRepo: vi.fn(),
getSparsePresets: vi.fn(),
getSettings: vi.fn(),
getWorktreeMeta: vi.fn(),
setWorktreeMeta: vi.fn(),
removeWorktreeMeta: vi.fn()
}
beforeEach(() => {
for (const m of [
handleMock,
removeHandlerMock,
listWorktreesMock,
addWorktreeMock,
addSparseWorktreeMock,
removeWorktreeMock,
getGitUsernameMock,
getDefaultBaseRefMock,
getBranchConflictKindMock,
getPRForBranchMock,
getEffectiveHooksMock,
createIssueCommandRunnerScriptMock,
createSetupRunnerScriptMock,
shouldRunSetupForCreateMock,
runHookMock,
hasHooksFileMock,
loadHooksMock,
computeWorktreePathMock,
ensurePathWithinWorkspaceMock,
gitExecFileAsyncMock,
mainWindow.webContents.send,
store.getRepos,
store.getRepo,
store.getSparsePresets,
store.getSettings,
store.getWorktreeMeta,
store.setWorktreeMeta,
store.removeWorktreeMeta,
killAllProcessesForWorktreeMock,
getLocalPtyProviderMock
]) {
m.mockReset()
}
killAllProcessesForWorktreeMock.mockResolvedValue({
runtimeStopped: 0,
providerStopped: 0,
registryStopped: 0
})
getLocalPtyProviderMock.mockReturnValue({} as never)
for (const key of Object.keys(handlers)) {
delete handlers[key]
}
handleMock.mockImplementation((channel, handler) => {
handlers[channel] = handler
})
const repo = {
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue({ ...repo, worktreeBaseRef: null })
store.getSparsePresets.mockReturnValue([])
store.getSettings.mockReturnValue({
branchPrefix: 'none',
nestWorkspaces: false,
refreshLocalBaseRefOnWorktreeCreate: false,
workspaceDir: '/workspace'
})
store.getWorktreeMeta.mockReturnValue(undefined)
store.setWorktreeMeta.mockReturnValue({})
getGitUsernameMock.mockReturnValue('')
getDefaultBaseRefMock.mockReturnValue('origin/main')
getBranchConflictKindMock.mockResolvedValue(null)
getPRForBranchMock.mockResolvedValue(null)
// Why: createLocalWorktree now fires `git fetch` in the background via
// gitExecFileAsync. The default mock must return a resolved promise so
// the fire-and-forget `.catch()` chain doesn't trip on undefined.
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
getEffectiveHooksMock.mockReturnValue(null)
shouldRunSetupForCreateMock.mockReturnValue(false)
createSetupRunnerScriptMock.mockReturnValue({
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
}
})
createIssueCommandRunnerScriptMock.mockReturnValue({
runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
}
})
computeWorktreePathMock.mockImplementation(
(
sanitizedName: string,
repoPath: string,
settings: { nestWorkspaces: boolean; workspaceDir: string }
) => {
if (settings.nestWorkspaces) {
const repoName =
repoPath
.split(/[\\/]/)
.at(-1)
?.replace(/\.git$/, '') ?? 'repo'
return `${settings.workspaceDir}/${repoName}/${sanitizedName}`
}
return `${settings.workspaceDir}/${sanitizedName}`
}
)
ensurePathWithinWorkspaceMock.mockImplementation((targetPath: string) => targetPath)
listWorktreesMock.mockResolvedValue([])
// Why: createLocalWorktree routes `git fetch` through
// `runtime.fetchRemoteWithCache` (§3.3 Lifecycle). A minimal stub
// keeps these tests focused on create-flow semantics; the full
// cache behavior is covered by fetch-remote-cache.test.ts.
const runtimeStub = {
fetchRemoteWithCache: async () => {
/* noop — fetch mocked at gitExecFileAsync level via gitExecFileAsyncMock */
}
}
registerWorktreeHandlers(mainWindow as never, store as never, runtimeStub as never)
})
it('auto-suffixes the branch name when the first choice collides with a remote branch', async () => {
// Why: new-workspace flow should silently try improve-dashboard-2, -3, ...
// rather than failing and forcing the user back to the name picker.
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'improve-dashboard' ? 'remote' : null
)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard-2',
head: 'abc123',
branch: 'improve-dashboard-2',
isBare: false,
isMainWorktree: false
}
])
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/improve-dashboard-2',
'improve-dashboard-2',
'origin/main',
false
)
expect(result).toEqual({
worktree: expect.objectContaining({
path: '/workspace/improve-dashboard-2',
branch: 'improve-dashboard-2'
})
})
})
it('throws a clear error when no default base ref can be resolved', async () => {
// Why: guard against regressing to a silent 'origin/main' fallback. When
// getDefaultBaseRef returns null (e.g. a fresh repo with no origin/HEAD,
// no origin/main, no origin/master, and no local main/master), we must
// fail loudly with a message that prompts the user to pick a base
// branch, not hand a non-existent ref to `git worktree add`.
getDefaultBaseRefMock.mockReturnValue(null)
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/repo',
displayName: 'repo',
badgeColor: '#000',
addedAt: 0,
worktreeBaseRef: null
})
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
).rejects.toThrow(/Could not resolve a default base ref/)
expect(addWorktreeMock).not.toHaveBeenCalled()
})
it('creates an issue-command runner for an existing repo/worktree pair', async () => {
const result = await handlers['hooks:createIssueCommandRunner'](null, {
repoId: 'repo-1',
worktreePath: '/workspace/improve-dashboard',
command: 'codex exec "long command"'
})
expect(createIssueCommandRunnerScriptMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-1' }),
'/workspace/improve-dashboard',
'codex exec "long command"'
)
expect(result).toEqual({
runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
}
})
})
it('lists a synthetic worktree for folder-mode repos', async () => {
store.getRepos.mockReturnValue([
{
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
}
])
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
const listed = await handlers['worktrees:list'](null, { repoId: 'repo-1' })
expect(listed).toEqual([
expect.objectContaining({
id: 'repo-1::/workspace/folder',
repoId: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
branch: '',
head: '',
isMainWorktree: true
})
])
expect(listWorktreesMock).not.toHaveBeenCalled()
})
it('stamps lastActivityAt on first discovery so newly-added worktrees sort to the top of Recent', async () => {
// Why: a worktree that exists on disk but has no persisted WorktreeMeta
// (e.g. a folder repo just added, or a pre-existing worktree in a
// newly-added git repo) would otherwise fall back to `lastActivityAt: 0`
// and rank dead last in the Recent sort.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/discovered-wt',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
store.getWorktreeMeta.mockReturnValue(undefined)
const stampedMeta = { lastActivityAt: 1_700_000_000_000 }
store.setWorktreeMeta.mockReturnValue(stampedMeta)
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
id: string
lastActivityAt: number
}[]
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/discovered-wt',
expect.objectContaining({ lastActivityAt: expect.any(Number) })
)
expect(listed[0]).toMatchObject({
id: 'repo-1::/workspace/discovered-wt',
lastActivityAt: 1_700_000_000_000
})
})
it('does not re-stamp lastActivityAt when a worktree already has persisted meta', async () => {
// Why: only the *first* discovery should stamp. Re-stamping on every list
// would overwrite real activity and reshuffle the sidebar on refresh.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/existing-wt',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
store.getWorktreeMeta.mockReturnValue({
displayName: '',
comment: '',
linkedIssue: null,
linkedPR: null,
isArchived: false,
isUnread: false,
isPinned: false,
sortOrder: 0,
lastActivityAt: 42
})
const listed = (await handlers['worktrees:list'](null, { repoId: 'repo-1' })) as {
id: string
lastActivityAt: number
}[]
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
expect(listed[0].lastActivityAt).toBe(42)
})
it('stamps lastActivityAt on first discovery for folder-mode repos', async () => {
// Why: folder repos produce a synthetic worktree that flows through the
// same list path. Without the stamp, adding a folder puts its card at the
// bottom of Recent even though the user just added it.
store.getRepos.mockReturnValue([
{
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
}
])
store.getRepo.mockReturnValue({
id: 'repo-1',
path: '/workspace/folder',
displayName: 'folder',
badgeColor: '#000',
addedAt: 0,
kind: 'folder'
})
store.getWorktreeMeta.mockReturnValue(undefined)
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
await handlers['worktrees:list'](null, { repoId: 'repo-1' })
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/folder',
expect.objectContaining({ lastActivityAt: expect.any(Number) })
)
})
it('stamps lastActivityAt on first discovery via worktrees:listAll', async () => {
// Why: the stamping logic lives in both worktrees:list and worktrees:listAll.
// Without a dedicated test, a regression in the listAll loop would silently
// bury newly-discovered worktrees from the multi-repo sidebar view.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/discovered-wt',
head: 'abc123',
branch: 'refs/heads/feature',
isBare: false,
isMainWorktree: false
}
])
store.getWorktreeMeta.mockReturnValue(undefined)
store.setWorktreeMeta.mockReturnValue({ lastActivityAt: 1_700_000_000_000 })
const listed = (await handlers['worktrees:listAll'](null, undefined)) as {
id: string
lastActivityAt: number
}[]
expect(store.setWorktreeMeta).toHaveBeenCalledWith(
'repo-1::/workspace/discovered-wt',
expect.objectContaining({ lastActivityAt: expect.any(Number) })
)
expect(listed[0]).toMatchObject({
id: 'repo-1::/workspace/discovered-wt',
lastActivityAt: 1_700_000_000_000
})
})
it('skips past a suffix that already belongs to a PR after an initial branch conflict', async () => {
// Why: `gh pr list` is network-bound and previously fired on every single
// create, adding 13s to the happy path. We now only probe PR conflicts
// from suffix=2 onward — once a local/remote branch collision has already
// forced us past the first candidate and uniqueness matters enough to
// justify the GitHub round-trip. This test covers that delayed path:
// suffix=1 is a branch conflict, suffix=2 is owned by an old PR, so the
// loop lands on suffix=3.
getBranchConflictKindMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'improve-dashboard' ? 'remote' : null
)
getPRForBranchMock.mockImplementation(async (_repoPath: string, branch: string) =>
branch === 'improve-dashboard-2'
? {
number: 3127,
title: 'Existing PR',
state: 'merged',
url: 'https://example.com/pr/3127',
checksStatus: 'success',
updatedAt: '2026-04-01T00:00:00Z',
mergeable: 'UNKNOWN'
}
: null
)
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard-3',
head: 'abc123',
branch: 'improve-dashboard-3',
isBare: false,
isMainWorktree: false
}
])
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/improve-dashboard-3',
'improve-dashboard-3',
'origin/main',
false
)
expect(result).toEqual({
worktree: expect.objectContaining({
path: '/workspace/improve-dashboard-3',
branch: 'improve-dashboard-3'
})
})
})
it('does not call `gh pr list` on the happy path (no branch conflict)', async () => {
// Why: guards the speed optimization. If a future refactor accidentally
// reintroduces the PR probe on the first iteration, the happy path will
// silently regain a 13s GitHub round-trip per click; this test fails
// loudly instead.
listWorktreesMock.mockResolvedValue([
{
path: '/workspace/improve-dashboard',
head: 'abc123',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
expect(getPRForBranchMock).not.toHaveBeenCalled()
})
const createdWorktreeList = [
{
path: '/workspace/improve-dashboard',
head: 'abc123',
branch: 'improve-dashboard',
isBare: false,
isMainWorktree: false
}
]
it('returns a setup launch payload when setup should run', async () => {
listWorktreesMock.mockResolvedValue(createdWorktreeList)
getEffectiveHooksMock.mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
shouldRunSetupForCreateMock.mockReturnValue(true)
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
setupDecision: 'run'
})
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-1' }),
'/workspace/improve-dashboard',
'pnpm worktree:setup'
)
expect(result).toEqual({
worktree: expect.objectContaining({
repoId: 'repo-1',
path: '/workspace/improve-dashboard',
branch: 'improve-dashboard'
}),
setup: {
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
envVars: {
ORCA_ROOT_PATH: '/workspace/repo',
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
}
}
})
expect(addWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/improve-dashboard',
'improve-dashboard',
'origin/main',
false
)
})
it('launches setup even when primary and worktree orca.yaml scripts diverge', async () => {
// Why: regression for a silent skip introduced by the #1280 content-equality
// gate. Benign divergence (whitespace, comments, or any setup edit that
// landed on the base branch but not yet in the primary checkout) must not
// disable setup — repo-level trust already gates execution.
listWorktreesMock.mockResolvedValue(createdWorktreeList)
getEffectiveHooksMock.mockImplementation((_repo, worktreePath?: string) => ({
scripts: {
setup: worktreePath ? 'pnpm worktree:setup # worktree' : 'pnpm worktree:setup'
}
}))
shouldRunSetupForCreateMock.mockReturnValue(true)
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
setupDecision: 'run'
})
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
expect.objectContaining({ id: 'repo-1' }),
'/workspace/improve-dashboard',
'pnpm worktree:setup # worktree'
)
expect(result).toEqual(
expect.objectContaining({
setup: expect.objectContaining({
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh'
})
})
)
})
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({
scripts: {
setup: 'pnpm worktree:setup'
}
})
shouldRunSetupForCreateMock.mockReturnValue(true)
createSetupRunnerScriptMock.mockImplementation(() => {
throw new Error('disk full')
})
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard',
setupDecision: 'run'
})
expect(result).toEqual({
worktree: expect.objectContaining({
repoId: 'repo-1',
path: '/workspace/improve-dashboard',
branch: 'improve-dashboard'
})
})
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
})
it('prunes git worktree tracking when removing an orphaned worktree', async () => {
const orphanError = Object.assign(new Error('git worktree remove failed'), {
stderr: "fatal: '/workspace/feature-wt' is not a working tree"
})
removeWorktreeMock.mockRejectedValue(orphanError)
getEffectiveHooksMock.mockReturnValue(null)
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
// Should have called git worktree prune to clean up stale tracking
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['worktree', 'prune'], {
cwd: '/workspace/repo'
})
expect(store.removeWorktreeMeta).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
expect(deleteWorktreeHistoryDirMock).toHaveBeenCalledWith('repo-1::/workspace/feature-wt')
expect(mainWindow.webContents.send).toHaveBeenCalledWith('worktrees:changed', {
repoId: 'repo-1'
})
})
it('runs the archive hook on remove when skipArchive is not set', async () => {
listWorktreesMock.mockResolvedValue([])
removeWorktreeMock.mockResolvedValue(undefined)
getEffectiveHooksMock.mockReturnValue({
scripts: {
archive: 'echo archived'
}
})
runHookMock.mockResolvedValue({ success: true, output: '' })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(runHookMock).toHaveBeenCalledWith(
'archive',
'/workspace/feature-wt',
expect.objectContaining({ id: 'repo-1' })
)
expect(removeWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-wt',
false
)
})
it('skips the archive hook on remove when skipArchive is true', async () => {
listWorktreesMock.mockResolvedValue([])
removeWorktreeMock.mockResolvedValue(undefined)
getEffectiveHooksMock.mockReturnValue({
scripts: {
archive: 'echo archived'
}
})
runHookMock.mockResolvedValue({ success: true, output: '' })
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt',
skipArchive: true
})
expect(runHookMock).not.toHaveBeenCalled()
expect(removeWorktreeMock).toHaveBeenCalledWith(
'/workspace/repo',
'/workspace/feature-wt',
false
)
})
it('IPC-initiated delete kills PTYs BEFORE git-level removal (design §4.3)', async () => {
listWorktreesMock.mockResolvedValue([])
getEffectiveHooksMock.mockReturnValue(null)
const callOrder: string[] = []
killAllProcessesForWorktreeMock.mockImplementation(async () => {
callOrder.push('kill')
return { runtimeStopped: 1, providerStopped: 0, registryStopped: 0 }
})
removeWorktreeMock.mockImplementation(async () => {
callOrder.push('git')
})
await handlers['worktrees:remove'](null, {
worktreeId: 'repo-1::/workspace/feature-wt'
})
expect(killAllProcessesForWorktreeMock).toHaveBeenCalledWith(
'repo-1::/workspace/feature-wt',
expect.objectContaining({
localProvider: expect.anything()
})
)
expect(removeWorktreeMock).toHaveBeenCalled()
expect(callOrder).toEqual(['kill', 'git'])
})
it('skips the PTY teardown for SSH-backed repos (design §6 out-of-scope)', async () => {
// Why: SSH-backed PTYs live on the remote host and are handled by the
// remote provider's own teardown. The local-host helper must not run for
// SSH repos, because it would sweep registry entries for other worktrees.
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: null
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
// The test can't easily mock the SSH provider without more plumbing — the
// call will throw about 'no git provider for connection'. What matters
// here is that the kill helper was NOT called for the SSH branch.
await (
handlers['worktrees:remove'](null, {
worktreeId: 'repo-ssh::/remote/feature-wt'
}) as Promise<unknown>
).catch(() => {})
expect(killAllProcessesForWorktreeMock).not.toHaveBeenCalled()
})
it('rejects ask-policy creates before mutating git state when setup decision is missing', async () => {
getEffectiveHooksMock.mockReturnValue({
scripts: {
setup: 'pnpm worktree:setup'
}
})
shouldRunSetupForCreateMock.mockImplementation(() => {
throw new Error('Setup decision required for this repository')
})
await expect(
handlers['worktrees:create'](null, {
repoId: 'repo-1',
name: 'improve-dashboard'
})
).rejects.toThrow('Setup decision required for this repository')
expect(addWorktreeMock).not.toHaveBeenCalled()
expect(store.setWorktreeMeta).not.toHaveBeenCalled()
expect(createSetupRunnerScriptMock).not.toHaveBeenCalled()
})
})