mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
fix(ci): unblock main: split over-long files and pin scanner test file times (#34072)
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { mkdtemp, rm, utimes } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
@@ -109,6 +109,15 @@ async function writeSharedRootTranscripts(sessionsDir: string): Promise<void> {
|
||||
runtime
|
||||
}
|
||||
])
|
||||
// Linux mtimes tick coarsely, so back-to-back writes can tie and change which row the cap keeps.
|
||||
for (const [path, lastWrite] of [
|
||||
[join(piDir, '2026-05-01T09-00-00-000Z_pi.jsonl'), '2026-05-01T09:00:01.000Z'],
|
||||
[join(ompDir, `${OMP_STEM}.jsonl`), '2026-05-01T10:00:02.000Z'],
|
||||
[join(ompDir, '2026-04-01T10-00-00-000Z_legacy.jsonl'), '2026-04-01T10:00:01.000Z'],
|
||||
[join(ompDir, OMP_STEM, 'Worker.jsonl'), '2026-05-01T10:00:06.000Z']
|
||||
]) {
|
||||
await utimes(path, new Date(lastWrite), new Date(lastWrite))
|
||||
}
|
||||
}
|
||||
|
||||
describe('scanAiVaultSessions with overlapping Pi and OMP roots', () => {
|
||||
|
||||
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
labelOnlyCreateRequests,
|
||||
renameCreatedWorktreeOnFirstWork
|
||||
} from '../worktree-create-first-work-test-harness'
|
||||
import { isAutoGeneratedCreatureBranchName } from '../../shared/branch-name-from-work'
|
||||
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { describeCreatedWorktreeMock, gitExecFileAsyncMock } from './worktrees-test-module-mocks'
|
||||
import { handlers, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
|
||||
vi.mock('../text-generation/commit-message-text-generation', () => ({
|
||||
resolveTextGenerationParams: () => ({
|
||||
ok: true,
|
||||
params: { agentId: 'claude', model: 'fixture' }
|
||||
}),
|
||||
generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' })
|
||||
}))
|
||||
vi.mock('../text-generation/commit-message-agent-environment', () => ({
|
||||
prepareLocalCommitMessageAgentEnv: async () => ({ ok: true })
|
||||
}))
|
||||
|
||||
vi.mock('electron', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).electronModuleMock()
|
||||
)
|
||||
vi.mock('../git/worktree', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitWorktreeModuleMock()
|
||||
)
|
||||
vi.mock('../git/runner', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRunnerModuleMock()
|
||||
)
|
||||
vi.mock('../git/repo', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).gitRepoModuleMock()
|
||||
)
|
||||
vi.mock('../git/git-username', async (importOriginal) => ({
|
||||
...(await importOriginal<Record<string, unknown>>()),
|
||||
resolveLocalGitUsername: (await import('./worktrees-test-module-mocks'))
|
||||
.resolveLocalGitUsernameMock
|
||||
}))
|
||||
vi.mock('../github/client', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).githubClientModuleMock()
|
||||
)
|
||||
vi.mock('../source-control/hosted-review', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).hostedReviewModuleMock()
|
||||
)
|
||||
vi.mock('./worktree-symlinks', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeSymlinksModuleMock()
|
||||
)
|
||||
vi.mock('../hooks', async () => (await import('./worktrees-test-module-mocks')).hooksModuleMock())
|
||||
vi.mock('../setup-runner-script-text', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).setupRunnerScriptTextModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('../worktree-runner-script', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('../effective-hook-config', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('./worktree-logic', async (importOriginal) =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
|
||||
(await importOriginal()) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
vi.mock('../terminal-history-deletion', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).terminalHistoryDeletionModuleMock()
|
||||
)
|
||||
vi.mock('../ports/advertised-url-watcher', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).advertisedUrlWatcherModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-cleanup-scan-snapshot', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceCleanupScanSnapshotModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-space-analysis-snapshot', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceSpaceAnalysisSnapshotModuleMock()
|
||||
)
|
||||
vi.mock('../workspace-cleanup-removal-snapshot-prune', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).workspaceCleanupRemovalSnapshotPruneModuleMock()
|
||||
)
|
||||
vi.mock('../runtime/worktree-teardown', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).worktreeTeardownModuleMock()
|
||||
)
|
||||
vi.mock('./pty/provider/registry', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).ptyRegistryModuleMock()
|
||||
)
|
||||
vi.mock('./pty/provider/state-cleanup', async () =>
|
||||
(await import('./worktrees-test-module-mocks')).ptyStateCleanupModuleMock()
|
||||
)
|
||||
|
||||
describe('desktop label-only creation through first work', () => {
|
||||
function expectCreatedWorktree(result: unknown): asserts result is CreateWorktreeResult {
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
worktree: expect.objectContaining({
|
||||
id: expect.any(String),
|
||||
repoId: 'repo-1',
|
||||
path: expect.any(String),
|
||||
head: expect.any(String),
|
||||
branch: expect.stringMatching(/^refs\/heads\//),
|
||||
isBare: false,
|
||||
isMainWorktree: false,
|
||||
displayName: expect.any(String),
|
||||
displayNameMode: expect.stringMatching(/^(fixed|automatic)$/),
|
||||
comment: '',
|
||||
linkedIssue: null,
|
||||
linkedPR: null,
|
||||
linkedLinearIssue: null,
|
||||
isArchived: false,
|
||||
isUnread: false,
|
||||
isPinned: false,
|
||||
sortOrder: expect.any(Number),
|
||||
lastActivityAt: expect.any(Number)
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
let savedMeta: Partial<WorktreeMeta>
|
||||
beforeEach(() => {
|
||||
setupWorktreeHandlers()
|
||||
savedMeta = {}
|
||||
store.getSettings.mockReturnValue({
|
||||
branchPrefix: 'none',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
workspaceDir: '/workspace',
|
||||
autoRenameBranchFromWork: true
|
||||
})
|
||||
store.setWorktreeMeta.mockImplementation((_target, updates) => {
|
||||
savedMeta = { ...savedMeta, ...updates }
|
||||
return savedMeta
|
||||
})
|
||||
describeCreatedWorktreeMock.mockImplementation(async (_repoPath, path, branch) => ({
|
||||
path,
|
||||
branch: `refs/heads/${branch}`,
|
||||
head: 'abc123',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}))
|
||||
})
|
||||
|
||||
it.each(labelOnlyCreateRequests)(
|
||||
'creates and renames a creature branch for $name while projecting its label',
|
||||
async (request) => {
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
...request
|
||||
})
|
||||
expectCreatedWorktree(result)
|
||||
const { worktree } = result
|
||||
const branch = worktree.branch.replace(/^refs\/heads\//, '')
|
||||
expect(isAutoGeneratedCreatureBranchName(branch)).toBe(true)
|
||||
expect(worktree.displayName).toBe(request.name)
|
||||
expect(worktree.displayNameMode).toBe(request.nameWasGenerated ? 'automatic' : 'fixed')
|
||||
const renamed = await renameCreatedWorktreeOnFirstWork(
|
||||
worktree,
|
||||
savedMeta,
|
||||
gitExecFileAsyncMock
|
||||
)
|
||||
expect(renamed).toMatchObject({
|
||||
branch: 'refs/heads/fix-auth',
|
||||
displayName: request.nameWasGenerated ? 'Fix auth' : request.name,
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
{ branchNameOverride: 'feature/exact', enabled: true, branch: 'feature/exact' },
|
||||
{ branchNameOverride: undefined, enabled: false, branch: 'typed-label' }
|
||||
])(
|
||||
'preserves branch choice $branch (automatic=$enabled)',
|
||||
async ({ branchNameOverride, enabled, branch }) => {
|
||||
store.getSettings.mockReturnValue({
|
||||
branchPrefix: 'none',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
workspaceDir: '/workspace',
|
||||
autoRenameBranchFromWork: enabled
|
||||
})
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'typed-label',
|
||||
branchNameOverride
|
||||
})
|
||||
expectCreatedWorktree(result)
|
||||
const { worktree } = result
|
||||
expect(worktree).toMatchObject({
|
||||
branch: `refs/heads/${branch}`,
|
||||
displayName: 'typed-label',
|
||||
displayNameMode: 'fixed'
|
||||
})
|
||||
}
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
|
||||
const {
|
||||
runtimeCreateMocks: mocks,
|
||||
worktreePath,
|
||||
pickedBranch,
|
||||
createWorktree,
|
||||
resetRuntimeCreateMocks
|
||||
} = await vi.hoisted(async () => await import('./runtime-local-worktree-create-test-harness'))
|
||||
|
||||
vi.mock('../text-generation/commit-message-text-generation', () => ({
|
||||
resolveTextGenerationParams: () => ({
|
||||
ok: true,
|
||||
params: { agentId: 'claude', model: 'fixture' }
|
||||
}),
|
||||
generateBranchNameFromContext: async () => ({ success: true, slug: 'fix-auth' })
|
||||
}))
|
||||
vi.mock('../text-generation/commit-message-agent-environment', () => ({
|
||||
prepareLocalCommitMessageAgentEnv: async () => ({ ok: true })
|
||||
}))
|
||||
|
||||
vi.mock('../git/runner', () => ({ gitExecFileAsync: mocks.git }))
|
||||
vi.mock('../project-runtime-git-options', () => ({
|
||||
getLocalProjectGitExecOptions: () => ({ cwd: '/repo', ...mocks.routing() }),
|
||||
getLocalProjectWorktreeGitOptions: mocks.routing,
|
||||
getWorktreeMirrorDistro: () => undefined
|
||||
}))
|
||||
vi.mock('../git/repo', () => ({
|
||||
getBaseRefDefault: mocks.defaultBase,
|
||||
resolveDefaultBaseRefWithLocalGit: mocks.defaultBase,
|
||||
getBranchConflictKind: mocks.branchConflict
|
||||
}))
|
||||
vi.mock('../git/git-username', () => ({ resolveLocalGitUsername: async () => '' }))
|
||||
vi.mock('../hooks', () => ({ getEffectiveHooks: mocks.effectiveHooks }))
|
||||
vi.mock('../git/worktree-base-ref-probe', () => ({ hasLocalWorktreeBaseRef: mocks.hasBase }))
|
||||
vi.mock('./runtime-worktree-create-git', () => ({
|
||||
resolveCreateBranchName: mocks.branchName,
|
||||
canCheckoutExistingLocalBranch: mocks.canCheckout,
|
||||
getLocalGitHubPrForBranch: mocks.githubPr,
|
||||
getSelectedHostedReviewForBranch: vi.fn()
|
||||
}))
|
||||
vi.mock('./runtime-worktree-filesystem', () => ({ runtimePathExists: mocks.pathExists }))
|
||||
vi.mock('../worktree-create-preparation', () => ({ consumePreparedWorktreeCreate: mocks.consume }))
|
||||
vi.mock('../git/worktree', () => ({ addWorktree: mocks.add, addSparseWorktree: mocks.addSparse }))
|
||||
vi.mock('../ipc/worktree-remote', () => ({ configureCreatedWorktreePushTarget: mocks.pushTarget }))
|
||||
vi.mock('../ipc/created-worktree-reconciliation', () => ({ resolveCreatedWorktree: mocks.listing }))
|
||||
vi.mock('../worktree-name-retirement', () => ({
|
||||
failedWorktreeCreationNeedsRetirement: vi.fn(),
|
||||
retireGeneratedWorktreeName: vi.fn()
|
||||
}))
|
||||
vi.mock('../git/worktree-shared-directories', () => ({
|
||||
resolveWorktreeSharedDirectories: mocks.resolveShared
|
||||
}))
|
||||
vi.mock('../git/worktree-include-file', () => ({
|
||||
resolveWorktreeIncludePaths: mocks.resolveInclude
|
||||
}))
|
||||
vi.mock('../ipc/worktree-symlinks', () => ({
|
||||
createWorktreeCopiedPaths: mocks.copyPaths,
|
||||
createWorktreeLinkedPaths: vi.fn(),
|
||||
createWorktreeSharedPaths: vi.fn()
|
||||
}))
|
||||
|
||||
beforeEach(resetRuntimeCreateMocks)
|
||||
|
||||
describe('runtime create PR conflict probe', () => {
|
||||
const remoteTrackingBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
base: 'origin/main',
|
||||
ref: 'refs/remotes/origin/main'
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name)
|
||||
})
|
||||
|
||||
it.each([1, 10])(
|
||||
'keeps the selected name and source through %i occupied paths, without PR eligibility',
|
||||
async (occupied) => {
|
||||
const options = { wslDistro: 'Ubuntu' }
|
||||
mocks.routing.mockReturnValue(options)
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
mocks.githubPr.mockResolvedValue({ number: 42 })
|
||||
let pathChecks = 0
|
||||
mocks.pathExists.mockImplementation(async () => pathChecks++ < occupied)
|
||||
|
||||
await createWorktree({ ...pickedBranch, sparseCheckout: { directories: ['src'] } })
|
||||
|
||||
expect(mocks.remoteBase.mock.calls).toEqual([['/repo', 'origin/main', options]])
|
||||
expect(mocks.branchName).toHaveBeenCalledOnce()
|
||||
expect(mocks.branchConflict.mock.calls).toEqual([['/repo', 'main', 'origin/main', options]])
|
||||
expect(mocks.pathExists).toHaveBeenCalledTimes(occupied + 1)
|
||||
expect(mocks.githubPr).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
expect(mocks.addSparse).toHaveBeenCalledExactlyOnceWith(
|
||||
'/repo',
|
||||
resolve('/worktrees', `app-${occupied + 1}`),
|
||||
'main',
|
||||
['src'],
|
||||
remoteTrackingBase.base,
|
||||
false,
|
||||
expect.objectContaining({ remoteTrackingBase, ...options })
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([true, false])(
|
||||
'keeps local eligibility with picker intent (reusable: %s)',
|
||||
async (reusable) => {
|
||||
mocks.branchConflict.mockImplementation(async (_path, branch: string) =>
|
||||
branch === 'main' ? 'local' : null
|
||||
)
|
||||
mocks.canCheckout.mockImplementation(
|
||||
async (_path, branch: string) => reusable && branch === 'main'
|
||||
)
|
||||
mocks.pathExists.mockImplementation(async (path) => path === worktreePath)
|
||||
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
|
||||
|
||||
await createWorktree(pickedBranch)
|
||||
|
||||
expect(mocks.add).toHaveBeenCalledWith(
|
||||
'/repo',
|
||||
resolve('/worktrees', 'app-2'),
|
||||
reusable ? 'main' : 'main-2',
|
||||
'origin/main',
|
||||
false,
|
||||
false,
|
||||
expect.objectContaining(reusable ? { checkoutExistingBranch: true } : {})
|
||||
)
|
||||
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(
|
||||
reusable ? [] : ['main-2']
|
||||
)
|
||||
expect(mocks.add.mock.calls[0][6].checkoutExistingBranch ?? false).toBe(reusable)
|
||||
}
|
||||
)
|
||||
|
||||
it('keeps unflagged legacy, mobile New branch and Advanced collisions on baseline allocation', async () => {
|
||||
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'not_prepared' })
|
||||
mocks.branchName.mockImplementation(async (_path, override, name: string) => override ?? name)
|
||||
mocks.branchConflict.mockImplementation(async (_path, branch: string) =>
|
||||
branch === 'feature/test' ? 'remote' : null
|
||||
)
|
||||
|
||||
await createWorktree({ branchNameOverride: 'feature/test', baseBranch: 'origin/main' })
|
||||
|
||||
expect(mocks.add.mock.calls[0][2]).toBe('feature/test-2')
|
||||
})
|
||||
|
||||
it('runs the first name PR lookup while the base fetch is still in flight', async () => {
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
let finishFetch!: () => void
|
||||
mocks.refresh.mockReturnValue(
|
||||
new Promise((resolve) => {
|
||||
finishFetch = () => resolve({ ok: true })
|
||||
})
|
||||
)
|
||||
let fetchPendingAtLookup = false
|
||||
mocks.githubPr.mockImplementation(async () => {
|
||||
fetchPendingAtLookup = mocks.refresh.mock.calls.length === 1
|
||||
finishFetch?.()
|
||||
return null
|
||||
})
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(fetchPendingAtLookup).toBe(true)
|
||||
expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app', {})
|
||||
expect(mocks.consume).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
branch: 'app',
|
||||
options: expect.objectContaining({ remoteTrackingBase })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('suffixes past a PR on the first name', async () => {
|
||||
mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app', 'app-2'])
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-2' }))
|
||||
})
|
||||
|
||||
it.each([undefined, 'Ubuntu'])(
|
||||
'probes the next name for a PR once the first name collides, routed for %s',
|
||||
async (wslDistro) => {
|
||||
const routing = wslDistro ? { wslDistro } : {}
|
||||
mocks.routing.mockReturnValue(routing)
|
||||
mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr).toHaveBeenCalledOnce()
|
||||
expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app-2', routing)
|
||||
}
|
||||
)
|
||||
|
||||
it('skips a later name that already has a PR', async () => {
|
||||
mocks.branchConflict.mockResolvedValueOnce('local').mockResolvedValue(null)
|
||||
mocks.githubPr.mockResolvedValueOnce({ number: 7, state: 'closed' }).mockResolvedValue(null)
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(mocks.githubPr.mock.calls.map(([, branch]) => branch)).toEqual(['app-2', 'app-3'])
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app-3' }))
|
||||
})
|
||||
|
||||
it('reports the naming failure when the started base fetch also fails', async () => {
|
||||
mocks.remoteBase.mockResolvedValue(remoteTrackingBase)
|
||||
mocks.refresh.mockRejectedValue(new Error('offline'))
|
||||
mocks.branchConflict.mockResolvedValue('local')
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
try {
|
||||
await expect(createWorktree()).rejects.toThrow('Branch "app-')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(mocks.refresh).toHaveBeenCalledOnce()
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.off('unhandledRejection', unhandled)
|
||||
}
|
||||
})
|
||||
|
||||
it('runs the first name PR lookup while the origin fetch is still in flight', async () => {
|
||||
mocks.hasBase.mockResolvedValue(false)
|
||||
let finishFetch!: () => void
|
||||
mocks.fetch.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
finishFetch = resolve
|
||||
})
|
||||
)
|
||||
let fetchPendingAtLookup = false
|
||||
mocks.githubPr.mockImplementation(async () => {
|
||||
fetchPendingAtLookup = mocks.fetch.mock.calls.length === 1
|
||||
finishFetch?.()
|
||||
return null
|
||||
})
|
||||
|
||||
await createWorktree()
|
||||
|
||||
expect(fetchPendingAtLookup).toBe(true)
|
||||
expect(mocks.fetch).toHaveBeenCalledWith('/repo', 'origin', {})
|
||||
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ branch: 'app' }))
|
||||
})
|
||||
|
||||
it('reports the naming failure when the started origin fetch also fails', async () => {
|
||||
mocks.hasBase.mockResolvedValue(false)
|
||||
mocks.fetch.mockRejectedValue(new Error('offline'))
|
||||
mocks.branchConflict.mockResolvedValue('local')
|
||||
const unhandled = vi.fn()
|
||||
process.on('unhandledRejection', unhandled)
|
||||
try {
|
||||
await expect(createWorktree()).rejects.toThrow('Branch "app-')
|
||||
await new Promise((resolve) => setImmediate(resolve))
|
||||
expect(mocks.fetch).toHaveBeenCalledOnce()
|
||||
expect(unhandled).not.toHaveBeenCalled()
|
||||
expect(mocks.consume).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
process.off('unhandledRejection', unhandled)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import { expect, vi, type Mock } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
|
||||
import { resolveGitAdmissionTier } from '../git/command-runner/git-operation-executor'
|
||||
import type { PreparationRearmHolder } from '../worktree-create-preparation'
|
||||
import {
|
||||
createWorktreeCreateTimingRecorder,
|
||||
type WorktreeCreateTimingRecorder
|
||||
} from '../worktree-create-timing'
|
||||
import type { SparsePreset } from '../../shared/worktree/create-types'
|
||||
|
||||
type RuntimeCreateMocks = {
|
||||
git: Mock
|
||||
rearm: Mock
|
||||
routing: Mock<() => { wslDistro?: string }>
|
||||
defaultBase: Mock
|
||||
hasBase: Mock
|
||||
branchName: Mock
|
||||
canCheckout: Mock
|
||||
pathExists: Mock
|
||||
branchConflict: Mock
|
||||
githubPr: Mock
|
||||
consume: Mock
|
||||
add: Mock
|
||||
addSparse: Mock
|
||||
pushTarget: Mock
|
||||
listing: Mock
|
||||
remoteBase: Mock
|
||||
hasRemoteRef: Mock
|
||||
refresh: Mock
|
||||
fetch: Mock
|
||||
resolveShared: Mock<() => Promise<string[]>>
|
||||
resolveInclude: Mock<() => Promise<string[]>>
|
||||
copyPaths: Mock<() => Promise<string[]>>
|
||||
effectiveHooks: Mock
|
||||
created: {
|
||||
path: string
|
||||
head: string
|
||||
branch: string
|
||||
isBare: boolean
|
||||
isMainWorktree: boolean
|
||||
}
|
||||
}
|
||||
|
||||
export const runtimeCreateMocks: RuntimeCreateMocks = {
|
||||
git: vi.fn(),
|
||||
rearm: vi.fn(),
|
||||
routing: vi.fn<() => { wslDistro?: string }>(),
|
||||
defaultBase: vi.fn(),
|
||||
hasBase: vi.fn(),
|
||||
branchName: vi.fn(),
|
||||
canCheckout: vi.fn(),
|
||||
pathExists: vi.fn(),
|
||||
branchConflict: vi.fn(),
|
||||
githubPr: vi.fn(),
|
||||
consume: vi.fn(),
|
||||
add: vi.fn(),
|
||||
addSparse: vi.fn(),
|
||||
pushTarget: vi.fn(),
|
||||
listing: vi.fn(),
|
||||
remoteBase: vi.fn(),
|
||||
hasRemoteRef: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
resolveShared: vi.fn<() => Promise<string[]>>(),
|
||||
resolveInclude: vi.fn<() => Promise<string[]>>(),
|
||||
copyPaths: vi.fn<() => Promise<string[]>>(),
|
||||
effectiveHooks: vi.fn(),
|
||||
created: {
|
||||
path: '',
|
||||
head: 'abc123',
|
||||
branch: 'app',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
}
|
||||
|
||||
export const worktreePath = resolve('/worktrees', 'app')
|
||||
|
||||
export const pickedBranch = {
|
||||
baseBranch: 'origin/main',
|
||||
branchNameOverride: 'main',
|
||||
branchIntent: 'open-existing'
|
||||
} as const
|
||||
|
||||
export const writtenMeta: Partial<WorktreeMeta>[] = []
|
||||
const WEB_PRESET: SparsePreset = {
|
||||
id: 'preset-web',
|
||||
repoId: 'repo-1',
|
||||
name: 'web',
|
||||
directories: ['apps/web', 'packages/ui'],
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
export const readSparsePresets = vi.fn<(repoId: string) => SparsePreset[]>()
|
||||
|
||||
export async function createWorktree(
|
||||
request: Partial<RuntimeManagedWorktreeCreateArgs> = {},
|
||||
rearm: PreparationRearmHolder = { fire: () => {} },
|
||||
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(),
|
||||
repoOverrides: Partial<Repo> = {},
|
||||
autoRenameBranchFromWork = false
|
||||
) {
|
||||
const store = {
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/worktrees',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: '',
|
||||
autoRenameBranchFromWork
|
||||
}),
|
||||
setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial<WorktreeMeta>) => {
|
||||
writtenMeta.push(updates)
|
||||
return updates
|
||||
},
|
||||
getSparsePresets: (repoId: string): SparsePreset[] => readSparsePresets(repoId)
|
||||
}
|
||||
// Loaded on call so module mock factories can import this harness without a cycle.
|
||||
const { createRuntimeLocalManagedWorktree } = await import('./runtime-local-worktree-create')
|
||||
return createRuntimeLocalManagedWorktree({
|
||||
request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request },
|
||||
repo: {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
...repoOverrides
|
||||
},
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All store methods reached by this isolated create path are supplied above.
|
||||
store: store as Store,
|
||||
createdWithAgent: undefined,
|
||||
resolveRemoteTrackingBase: runtimeCreateMocks.remoteBase,
|
||||
hasRemoteTrackingRef: runtimeCreateMocks.hasRemoteRef,
|
||||
// vi.fn marks returned promises handled; a fresh promise exposes unawaited fetch rejection.
|
||||
refreshRemoteTrackingBase: async (...refreshArgs) =>
|
||||
await runtimeCreateMocks.refresh(...refreshArgs),
|
||||
fetchRemote: async (...fetchArgs) => await runtimeCreateMocks.fetch(...fetchArgs),
|
||||
onWorktreeMetadataPersisted: () => undefined,
|
||||
rearm,
|
||||
timing
|
||||
})
|
||||
}
|
||||
|
||||
export function resetRuntimeCreateMocks(): void {
|
||||
vi.resetAllMocks()
|
||||
writtenMeta.length = 0
|
||||
runtimeCreateMocks.created.branch = 'app'
|
||||
runtimeCreateMocks.git.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
runtimeCreateMocks.created.path = worktreePath
|
||||
runtimeCreateMocks.routing.mockReturnValue({})
|
||||
runtimeCreateMocks.defaultBase.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return 'main'
|
||||
})
|
||||
runtimeCreateMocks.hasBase.mockResolvedValue(true)
|
||||
runtimeCreateMocks.branchName.mockResolvedValue('app')
|
||||
runtimeCreateMocks.canCheckout.mockResolvedValue(false)
|
||||
runtimeCreateMocks.pathExists.mockResolvedValue(false)
|
||||
runtimeCreateMocks.branchConflict.mockResolvedValue(null)
|
||||
runtimeCreateMocks.githubPr.mockResolvedValue(null)
|
||||
runtimeCreateMocks.consume.mockResolvedValue({
|
||||
status: 'hit',
|
||||
result: {},
|
||||
rearm: runtimeCreateMocks.rearm
|
||||
})
|
||||
runtimeCreateMocks.add.mockResolvedValue({})
|
||||
runtimeCreateMocks.addSparse.mockResolvedValue({})
|
||||
runtimeCreateMocks.listing.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return { created: runtimeCreateMocks.created }
|
||||
})
|
||||
runtimeCreateMocks.remoteBase.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return null
|
||||
})
|
||||
runtimeCreateMocks.hasRemoteRef.mockResolvedValue(true)
|
||||
runtimeCreateMocks.refresh.mockResolvedValue({ ok: true })
|
||||
runtimeCreateMocks.fetch.mockResolvedValue(undefined)
|
||||
runtimeCreateMocks.resolveShared.mockResolvedValue([])
|
||||
runtimeCreateMocks.resolveInclude.mockResolvedValue(['.env'])
|
||||
runtimeCreateMocks.copyPaths.mockResolvedValue([])
|
||||
runtimeCreateMocks.effectiveHooks.mockReturnValue(null)
|
||||
readSparsePresets.mockReturnValue([WEB_PRESET])
|
||||
}
|
||||
@@ -6,52 +6,24 @@ import {
|
||||
import { isAutoGeneratedCreatureBranchName } from '../../shared/branch-name-from-work'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolve } from 'node:path'
|
||||
import type { Store } from '../persistence'
|
||||
import type { WorktreeMeta } from '../../shared/worktree/meta-types'
|
||||
import type { HostWorktreeTarget } from '../../shared/worktree/host-qualified-identity'
|
||||
import type { Repo } from '../../shared/repo-types'
|
||||
import { getDefaultRepoHookSettings } from '../../shared/constants'
|
||||
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
|
||||
import type { AddWorktreeOptions } from '../git/worktree'
|
||||
import {
|
||||
acquireGitAdmission,
|
||||
GitAdmissionScheduler,
|
||||
_resetGitAdmissionForTests
|
||||
} from '../git/command-runner/git-subprocess-admission'
|
||||
import { resolveGitAdmissionTier } from '../git/command-runner/git-operation-executor'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
git: vi.fn(),
|
||||
rearm: vi.fn(),
|
||||
routing: vi.fn<() => { wslDistro?: string }>(),
|
||||
defaultBase: vi.fn(),
|
||||
hasBase: vi.fn(),
|
||||
branchName: vi.fn(),
|
||||
canCheckout: vi.fn(),
|
||||
pathExists: vi.fn(),
|
||||
branchConflict: vi.fn(),
|
||||
githubPr: vi.fn(),
|
||||
consume: vi.fn(),
|
||||
add: vi.fn(),
|
||||
addSparse: vi.fn(),
|
||||
pushTarget: vi.fn(),
|
||||
listing: vi.fn(),
|
||||
remoteBase: vi.fn(),
|
||||
hasRemoteRef: vi.fn(),
|
||||
refresh: vi.fn(),
|
||||
fetch: vi.fn(),
|
||||
resolveShared: vi.fn<() => Promise<string[]>>(),
|
||||
resolveInclude: vi.fn<() => Promise<string[]>>(),
|
||||
copyPaths: vi.fn<() => Promise<string[]>>(),
|
||||
effectiveHooks: vi.fn(),
|
||||
created: {
|
||||
path: '',
|
||||
head: 'abc123',
|
||||
branch: 'app',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
}))
|
||||
const {
|
||||
runtimeCreateMocks: mocks,
|
||||
worktreePath,
|
||||
pickedBranch,
|
||||
writtenMeta,
|
||||
readSparsePresets,
|
||||
createWorktree,
|
||||
resetRuntimeCreateMocks
|
||||
} = await vi.hoisted(async () => await import('./runtime-local-worktree-create-test-harness'))
|
||||
|
||||
vi.mock('../text-generation/commit-message-text-generation', () => ({
|
||||
resolveTextGenerationParams: () => ({
|
||||
@@ -105,117 +77,12 @@ vi.mock('../ipc/worktree-symlinks', () => ({
|
||||
createWorktreeSharedPaths: vi.fn()
|
||||
}))
|
||||
|
||||
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
|
||||
import { WorktreeCreateSetupChoiceRefusedError } from './worktree-create-setup-choice-refusal'
|
||||
import type { PreparationRearmHolder } from '../worktree-create-preparation'
|
||||
import {
|
||||
createWorktreeCreateTimingRecorder,
|
||||
type WorktreeCreateTimingRecorder
|
||||
} from '../worktree-create-timing'
|
||||
import type { SparsePreset } from '../../shared/worktree/create-types'
|
||||
import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing'
|
||||
import { withoutInFlightWorktreeCreates } from '../worktree-create-in-flight'
|
||||
|
||||
const worktreePath = resolve('/worktrees', 'app')
|
||||
|
||||
const pickedBranch = {
|
||||
baseBranch: 'origin/main',
|
||||
branchNameOverride: 'main',
|
||||
branchIntent: 'open-existing'
|
||||
} as const
|
||||
|
||||
const writtenMeta: Partial<WorktreeMeta>[] = []
|
||||
const WEB_PRESET: SparsePreset = {
|
||||
id: 'preset-web',
|
||||
repoId: 'repo-1',
|
||||
name: 'web',
|
||||
directories: ['apps/web', 'packages/ui'],
|
||||
createdAt: 0,
|
||||
updatedAt: 0
|
||||
}
|
||||
const readSparsePresets = vi.fn<(repoId: string) => SparsePreset[]>()
|
||||
|
||||
function createWorktree(
|
||||
request: Partial<RuntimeManagedWorktreeCreateArgs> = {},
|
||||
rearm: PreparationRearmHolder = { fire: () => {} },
|
||||
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(),
|
||||
repoOverrides: Partial<Repo> = {},
|
||||
autoRenameBranchFromWork = false
|
||||
) {
|
||||
const store = {
|
||||
getSettings: () => ({
|
||||
workspaceDir: '/worktrees',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
branchPrefix: '',
|
||||
autoRenameBranchFromWork
|
||||
}),
|
||||
setWorktreeMeta: (_target: HostWorktreeTarget, updates: Partial<WorktreeMeta>) => {
|
||||
writtenMeta.push(updates)
|
||||
return updates
|
||||
},
|
||||
getSparsePresets: (repoId: string): SparsePreset[] => readSparsePresets(repoId)
|
||||
}
|
||||
return createRuntimeLocalManagedWorktree({
|
||||
request: { repoSelector: 'repo-1', name: 'app', baseBranch: 'main', ...request },
|
||||
repo: {
|
||||
id: 'repo-1',
|
||||
path: '/repo',
|
||||
displayName: 'Repo',
|
||||
badgeColor: '#000000',
|
||||
addedAt: 0,
|
||||
...repoOverrides
|
||||
},
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: All store methods reached by this isolated create path are supplied above.
|
||||
store: store as Store,
|
||||
createdWithAgent: undefined,
|
||||
resolveRemoteTrackingBase: mocks.remoteBase,
|
||||
hasRemoteTrackingRef: mocks.hasRemoteRef,
|
||||
// vi.fn marks returned promises handled; a fresh promise exposes unawaited fetch rejection.
|
||||
refreshRemoteTrackingBase: async (...refreshArgs) => await mocks.refresh(...refreshArgs),
|
||||
fetchRemote: async (...fetchArgs) => await mocks.fetch(...fetchArgs),
|
||||
onWorktreeMetadataPersisted: () => undefined,
|
||||
rearm,
|
||||
timing
|
||||
})
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetAllMocks()
|
||||
writtenMeta.length = 0
|
||||
mocks.created.branch = 'app'
|
||||
mocks.git.mockResolvedValue({ stdout: '', stderr: '' })
|
||||
mocks.created.path = worktreePath
|
||||
mocks.routing.mockReturnValue({})
|
||||
mocks.defaultBase.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return 'main'
|
||||
})
|
||||
mocks.hasBase.mockResolvedValue(true)
|
||||
mocks.branchName.mockResolvedValue('app')
|
||||
mocks.canCheckout.mockResolvedValue(false)
|
||||
mocks.pathExists.mockResolvedValue(false)
|
||||
mocks.branchConflict.mockResolvedValue(null)
|
||||
mocks.githubPr.mockResolvedValue(null)
|
||||
mocks.consume.mockResolvedValue({ status: 'hit', result: {}, rearm: mocks.rearm })
|
||||
mocks.add.mockResolvedValue({})
|
||||
mocks.addSparse.mockResolvedValue({})
|
||||
mocks.listing.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return { created: mocks.created }
|
||||
})
|
||||
mocks.remoteBase.mockImplementation(async () => {
|
||||
expect(resolveGitAdmissionTier()).toBe('interactive')
|
||||
return null
|
||||
})
|
||||
mocks.hasRemoteRef.mockResolvedValue(true)
|
||||
mocks.refresh.mockResolvedValue({ ok: true })
|
||||
mocks.fetch.mockResolvedValue(undefined)
|
||||
mocks.resolveShared.mockResolvedValue([])
|
||||
mocks.resolveInclude.mockResolvedValue(['.env'])
|
||||
mocks.copyPaths.mockResolvedValue([])
|
||||
mocks.effectiveHooks.mockReturnValue(null)
|
||||
readSparsePresets.mockReturnValue([WEB_PRESET])
|
||||
})
|
||||
beforeEach(resetRuntimeCreateMocks)
|
||||
|
||||
describe('runtime create listing hold', () => {
|
||||
const rows = [{ path: worktreePath }]
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { PanelBottomClose, PanelRightClose, X } from 'lucide-react'
|
||||
import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuShortcut
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { useAppStore } from '@/store'
|
||||
import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSessionIdMenuItem'
|
||||
import { useStructuredChatPaneActions } from './use-structured-chat-pane-actions'
|
||||
|
||||
export function NativeChatStructuredPaneMenuItems({
|
||||
pane,
|
||||
leadingSeparator,
|
||||
splitShortcutLabels,
|
||||
resolveOrcaSessionId
|
||||
}: {
|
||||
pane: { tabId: string; worktreeId: string; groupId: string }
|
||||
leadingSeparator: boolean
|
||||
splitShortcutLabels?: { right: string; down: string }
|
||||
resolveOrcaSessionId?: () => Promise<string | null>
|
||||
}): React.JSX.Element {
|
||||
const actions = useStructuredChatPaneActions(pane.tabId, pane.worktreeId, pane.groupId)
|
||||
const hasSplitPanes = useAppStore(
|
||||
(state) => (state.groupsByWorktree[pane.worktreeId]?.length ?? 0) > 1
|
||||
)
|
||||
return (
|
||||
<>
|
||||
{leadingSeparator ? <DropdownMenuSeparator /> : null}
|
||||
<DropdownMenuItem onSelect={actions.onSplitRight}>
|
||||
<PanelRightClose />
|
||||
{translate('auto.components.cmd.j.quick.actions.splitChatRight', 'Split Chat Right')}
|
||||
<DropdownMenuShortcut>{splitShortcutLabels?.right}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onSelect={actions.onSplitDown}>
|
||||
<PanelBottomClose />
|
||||
{translate('auto.components.cmd.j.quick.actions.splitChatDown', 'Split Chat Down')}
|
||||
<DropdownMenuShortcut>{splitShortcutLabels?.down}</DropdownMenuShortcut>
|
||||
</DropdownMenuItem>
|
||||
{resolveOrcaSessionId ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<NativeChatCopyOrcaSessionIdMenuItem resolveOrcaSessionId={resolveOrcaSessionId} />
|
||||
</>
|
||||
) : null}
|
||||
{hasSplitPanes && actions.canClosePane ? (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem variant="destructive" onSelect={actions.onClosePane}>
|
||||
<X />
|
||||
{actions.closeLabel}
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -32,6 +32,7 @@ import { NativeChatCopyOrcaSessionIdMenuItem } from './NativeChatCopyOrcaSession
|
||||
import { NativeChatSelectionQuote } from './NativeChatSelectionQuote'
|
||||
import { StructuredChatPaneMenuItems } from './StructuredChatPaneMenuItems'
|
||||
import { NativeChatPaneMenuItems } from './NativeChatPaneMenuItems'
|
||||
import { NativeChatStructuredPaneMenuItems } from './NativeChatStructuredPaneMenuItems'
|
||||
import type { NativeChatComposerHandle } from './native-chat-composer-types'
|
||||
import { AgentSessionContinuationMenuItem } from '@/components/terminal-pane/AgentSessionContinuationMenuItem'
|
||||
import { AgentSessionContextCopyMenuItem } from '@/components/terminal-pane/AgentSessionContextCopyMenuItem'
|
||||
@@ -313,7 +314,7 @@ export function useNativeChatContextMenu({
|
||||
</>
|
||||
) : null}
|
||||
{chatPane ? (
|
||||
<StructuredChatPaneMenuItems
|
||||
<NativeChatStructuredPaneMenuItems
|
||||
pane={chatPane}
|
||||
leadingSeparator={hasEditItems || hasSessionItems}
|
||||
splitShortcutLabels={splitShortcutLabels}
|
||||
|
||||
Reference in New Issue
Block a user