mirror of
https://github.com/stablyai/orca.git
synced 2026-10-12 16:03:03 +00:00
* fix(worktrees): decide setup before git worktree add on the host's create, so an undecided ask repo leaves nothing behind The host's create (agent.launch, worktree.create, the phone and the CLI) only checked the setup policy after adding the worktree. An ask repo with no setup decision then threw with the worktree already on disk and no worktrees-changed event. It now refuses before the add, as the desktop create does, and a setup hook the new branch adds (one nobody decided on) is skipped with a warning instead of failing a create that already exists. * fix(worktrees): read the host create's setup decision in one place, after recording its host The pre-add refusal now runs after the create records its execution host, so a refused create's failure telemetry still says where it ran; it still runs before any git work. Both setup checks read the decision from one helper so they cannot disagree, and the post-add skip logs once. The refusal test now also proves no base resolution, fetch or branch naming ran and that the hooks came from the main checkout. * test(worktrees): cover a setup hook only the new branch adds through the managed create An ask repo with no decision, whose setup hook exists only in the new worktree's orca.yaml, now creates, refreshes the worktree list, reports setup as skipped and returns the warning. * fix(cli): name the --setup flag when a worktree create needs a setup decision The host refuses an undecided create in an ask repo with "Setup decision required for this repository", which desktop and phone answer in their own UI. The CLI now adds the next step: pass --setup run or --setup skip.
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
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 { 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(() => ({
|
|
rearm: vi.fn(),
|
|
routing: vi.fn<() => { wslDistro?: string }>(),
|
|
defaultBase: vi.fn(),
|
|
hasBase: vi.fn(),
|
|
branchName: vi.fn(),
|
|
canCheckout: 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
|
|
}
|
|
}))
|
|
|
|
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: async () => false }))
|
|
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()
|
|
}))
|
|
|
|
import { createRuntimeLocalManagedWorktree } from './runtime-local-worktree-create'
|
|
import type { PreparationRearmHolder } from '../worktree-create-preparation'
|
|
import {
|
|
createWorktreeCreateTimingRecorder,
|
|
type WorktreeCreateTimingRecorder
|
|
} from '../worktree-create-timing'
|
|
|
|
const worktreePath = resolve('/worktrees', 'app')
|
|
|
|
function createWorktree(
|
|
request: Partial<RuntimeManagedWorktreeCreateArgs> = {},
|
|
rearm: PreparationRearmHolder = { fire: () => {} },
|
|
timing: WorktreeCreateTimingRecorder = createWorktreeCreateTimingRecorder(),
|
|
repoOverrides: Partial<Repo> = {}
|
|
) {
|
|
const store = {
|
|
getSettings: () => ({
|
|
workspaceDir: '/worktrees',
|
|
nestWorkspaces: false,
|
|
refreshLocalBaseRefOnWorktreeCreate: false,
|
|
branchPrefix: ''
|
|
}),
|
|
setWorktreeMeta: (_id: string, updates: Partial<WorktreeMeta>) => updates
|
|
}
|
|
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,
|
|
refreshRemoteTrackingBase: mocks.refresh,
|
|
fetchRemote: mocks.fetch,
|
|
onWorktreeMetadataPersisted: () => undefined,
|
|
rearm,
|
|
timing
|
|
})
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks()
|
|
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.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)
|
|
})
|
|
|
|
describe('runtime prepared-worktree replenishment', () => {
|
|
it('keeps a failed claim reserved through the normal-add fallback', async () => {
|
|
mocks.consume.mockResolvedValue({
|
|
status: 'miss',
|
|
reason: 'finalize_failed',
|
|
rearm: mocks.rearm
|
|
})
|
|
const rearm: PreparationRearmHolder = { fire: () => {} }
|
|
await createWorktree({}, rearm)
|
|
|
|
expect(mocks.add).toHaveBeenCalledOnce()
|
|
expect(mocks.rearm).not.toHaveBeenCalled()
|
|
rearm.fire()
|
|
expect(mocks.rearm).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('keeps the failed claim release available when the fallback also fails', async () => {
|
|
mocks.consume.mockResolvedValue({
|
|
status: 'miss',
|
|
reason: 'prepare_failed',
|
|
rearm: mocks.rearm
|
|
})
|
|
mocks.add.mockRejectedValue(new Error('normal add failed'))
|
|
const rearm: PreparationRearmHolder = { fire: () => {} }
|
|
await expect(createWorktree({}, rearm)).rejects.toThrow('normal add failed')
|
|
|
|
expect(mocks.rearm).not.toHaveBeenCalled()
|
|
rearm.fire()
|
|
expect(mocks.rearm).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('leaves the re-arm holder armed but unfired once probes and include copies finish', async () => {
|
|
const rearm: PreparationRearmHolder = { fire: () => {} }
|
|
let finishProbe!: (paths: string[]) => void
|
|
mocks.resolveShared.mockImplementation(
|
|
() =>
|
|
new Promise<string[]>((resolve) => {
|
|
finishProbe = resolve
|
|
})
|
|
)
|
|
let finishCopy!: (paths: string[]) => void
|
|
mocks.copyPaths.mockImplementation(
|
|
() =>
|
|
new Promise<string[]>((resolve) => {
|
|
finishCopy = resolve
|
|
})
|
|
)
|
|
const creation = createWorktree({}, rearm)
|
|
await vi.waitFor(() => expect(mocks.resolveShared).toHaveBeenCalledOnce())
|
|
expect(mocks.rearm).not.toHaveBeenCalled()
|
|
finishProbe([])
|
|
await vi.waitFor(() => expect(mocks.copyPaths).toHaveBeenCalledOnce())
|
|
expect(mocks.rearm).not.toHaveBeenCalled()
|
|
finishCopy([])
|
|
await creation
|
|
// The caller launches terminals before arming, so create must not fire it itself.
|
|
expect(mocks.rearm).not.toHaveBeenCalled()
|
|
rearm.fire()
|
|
expect(mocks.rearm).toHaveBeenCalledOnce()
|
|
})
|
|
|
|
it('arms the holder even when materialization fails', async () => {
|
|
mocks.copyPaths.mockRejectedValue(new Error('copy failed'))
|
|
const rearm: PreparationRearmHolder = { fire: () => {} }
|
|
await expect(createWorktree({}, rearm)).rejects.toThrow('copy failed')
|
|
// The slot was consumed before the failure, so the caller's `finally` must find a real thunk.
|
|
rearm.fire()
|
|
expect(mocks.rearm).toHaveBeenCalledOnce()
|
|
})
|
|
})
|
|
|
|
describe('runtime create timing', () => {
|
|
it('times the add with the prepared-checkout consume inside it, and lists the worktrees', async () => {
|
|
mocks.listing.mockResolvedValue({
|
|
created: mocks.created,
|
|
worktrees: [mocks.created, mocks.created, mocks.created],
|
|
listingComplete: true
|
|
})
|
|
const timing = createWorktreeCreateTimingRecorder()
|
|
|
|
await createWorktree({}, undefined, timing)
|
|
|
|
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ timing }))
|
|
const finished = timing.finish()
|
|
expect(finished.phases.map((phase) => phase.phase)).toEqual(
|
|
expect.arrayContaining([
|
|
'resolve_name',
|
|
'git_worktree_add',
|
|
'list_created_worktree',
|
|
'persist_metadata',
|
|
'copy_worktreeinclude'
|
|
])
|
|
)
|
|
expect(finished).toMatchObject({ executionHost: 'local', worktreeCount: 3 })
|
|
})
|
|
|
|
it('records a sparse create as a miss without consulting the prepared checkout', async () => {
|
|
const timing = createWorktreeCreateTimingRecorder()
|
|
|
|
await createWorktree({ sparseCheckout: { directories: ['src'] } }, undefined, timing)
|
|
|
|
expect(mocks.consume).not.toHaveBeenCalled()
|
|
expect(timing.finish().preparedCheckout).toEqual({ status: 'miss', reason: 'sparse_checkout' })
|
|
})
|
|
})
|
|
|
|
describe('runtime create Git priority', () => {
|
|
it.each([undefined, 'Ubuntu'])(
|
|
'preserves interactive priority and routing on %s',
|
|
async (wslDistro) => {
|
|
const routing = wslDistro ? { wslDistro } : {}
|
|
mocks.routing.mockReturnValue(routing)
|
|
const options = routing
|
|
const target = { remoteName: 'origin', branchName: 'app' }
|
|
await createWorktree({ baseBranch: undefined, branchNameOverride: 'app', pushTarget: target })
|
|
|
|
expect(mocks.defaultBase).toHaveBeenCalledWith({ cwd: '/repo', ...options })
|
|
expect(mocks.branchName).toHaveBeenCalledWith(
|
|
'/repo',
|
|
'app',
|
|
'app',
|
|
expect.anything(),
|
|
'',
|
|
options
|
|
)
|
|
expect(mocks.canCheckout).toHaveBeenCalledWith('/repo', 'app', 'main', options)
|
|
expect(mocks.branchConflict).toHaveBeenCalledWith('/repo', 'app', 'main', options, undefined)
|
|
expect(mocks.githubPr).toHaveBeenCalledWith('/repo', 'app', routing)
|
|
expect(mocks.remoteBase).toHaveBeenCalledWith('/repo', 'main', options)
|
|
expect(mocks.hasBase).toHaveBeenCalledWith('/repo', 'main', options)
|
|
expect(mocks.consume).toHaveBeenCalledWith(expect.objectContaining({ options }))
|
|
expect(mocks.pushTarget).toHaveBeenCalledWith(worktreePath, 'app', target, options)
|
|
expect(mocks.listing).toHaveBeenCalledWith('/repo', worktreePath, 'app', options)
|
|
expect(mocks.resolveShared).toHaveBeenCalledWith('/repo', options)
|
|
expect(mocks.resolveInclude).toHaveBeenCalledWith('/repo', options)
|
|
}
|
|
)
|
|
|
|
it('creates through interactive headroom when regular Git capacity is occupied', async () => {
|
|
mocks.consume.mockResolvedValue({ status: 'miss', reason: 'none_armed' })
|
|
const scheduler = new GitAdmissionScheduler({ generalCap: 1, generalHeadroom: 1 })
|
|
_resetGitAdmissionForTests(scheduler)
|
|
const blocker = await acquireGitAdmission({ args: ['status'], cwd: '/repo' })
|
|
mocks.add.mockImplementation(
|
|
async (
|
|
_repo: string,
|
|
_path: string,
|
|
_branch: string,
|
|
_base: string,
|
|
_refresh: boolean,
|
|
_existing: boolean,
|
|
options?: AddWorktreeOptions
|
|
) => {
|
|
const grant = await acquireGitAdmission({
|
|
args: ['worktree', 'add'],
|
|
cwd: '/repo',
|
|
tier: options?.admissionTier,
|
|
signal: AbortSignal.timeout(200)
|
|
})
|
|
grant.release()
|
|
return {}
|
|
}
|
|
)
|
|
try {
|
|
await expect(createWorktree()).resolves.toHaveProperty('worktreePath', worktreePath)
|
|
expect(mocks.add).toHaveBeenCalledOnce()
|
|
} finally {
|
|
blocker.release()
|
|
_resetGitAdmissionForTests()
|
|
}
|
|
})
|
|
|
|
it('preserves priority for sparse creates and remote base refreshes', async () => {
|
|
const base = {
|
|
remote: 'origin',
|
|
branch: 'main',
|
|
ref: 'refs/remotes/origin/main',
|
|
base: 'origin/main'
|
|
}
|
|
mocks.remoteBase.mockResolvedValue(base)
|
|
await createWorktree({ baseBranch: 'origin/main', sparseCheckout: { directories: ['src'] } })
|
|
const options = {}
|
|
expect(mocks.hasRemoteRef).toHaveBeenCalledWith('/repo', base, options)
|
|
expect(mocks.refresh).toHaveBeenCalledWith('/repo', base, options)
|
|
expect(mocks.addSparse).toHaveBeenCalledWith(
|
|
'/repo',
|
|
worktreePath,
|
|
'app',
|
|
['src'],
|
|
'origin/main',
|
|
false,
|
|
expect.objectContaining(options)
|
|
)
|
|
expect(mocks.consume).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('runtime create setup decision', () => {
|
|
const askRepo: Partial<Repo> = {
|
|
hookSettings: { ...getDefaultRepoHookSettings(), setupRunPolicy: 'ask' }
|
|
}
|
|
|
|
beforeEach(() => {
|
|
mocks.effectiveHooks.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
|
})
|
|
|
|
it('refuses an ask repo with no decision before any git work', async () => {
|
|
const timing = createWorktreeCreateTimingRecorder()
|
|
// No requested base, so a create that got past the check would resolve the default base.
|
|
const request = { baseBranch: undefined }
|
|
await expect(createWorktree(request, undefined, timing, askRepo)).rejects.toThrow(
|
|
'Setup decision required for this repository'
|
|
)
|
|
// Hooks come from the main checkout (no worktree path): the new worktree doesn't exist yet.
|
|
expect(mocks.effectiveHooks.mock.calls).toEqual([[expect.objectContaining({ path: '/repo' })]])
|
|
for (const gitWork of [mocks.defaultBase, mocks.remoteBase, mocks.hasBase, mocks.refresh]) {
|
|
expect(gitWork).not.toHaveBeenCalled()
|
|
}
|
|
expect(mocks.fetch).not.toHaveBeenCalled()
|
|
expect(mocks.branchName).not.toHaveBeenCalled()
|
|
expect(mocks.consume).not.toHaveBeenCalled()
|
|
expect(mocks.add).not.toHaveBeenCalled()
|
|
// The refusal's failure telemetry still says where the create ran.
|
|
expect(timing.finish().executionHost).toBe('local')
|
|
})
|
|
|
|
it.each([
|
|
['an explicit decision', { setupDecision: 'skip' as const }],
|
|
['runHooks', { runHooks: true }]
|
|
])('creates an ask repo once the request carries %s', async (_label, request) => {
|
|
await createWorktree(request, undefined, undefined, askRepo)
|
|
expect(mocks.consume).toHaveBeenCalledOnce()
|
|
})
|
|
})
|