mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 03:57:35 +00:00
* feat(worktree): show checkout progress while a worktree is created A local create spends most of its time in the checkout `git worktree add` performs, and the card only said "Creating worktree…" for that whole time. Git already writes its "Updating files: N%" meter into our stderr pipe, so the add call reads it passively (same command line and environment) and the create card shows "Checking out files… N%" with a thin bar. All six create labels are translated so a create never switches language mid-flight. Rebuilt on main from the version stacked on the create-gating branch. On main a create can also claim a prepared checkout; that path never runs `git worktree add`, so it keeps today's "Creating worktree…" label with no bar, and a missed claim falls back to the plain add, which shows the bar. * test(worktree): pin that finalizing a prepared checkout reports no checkout progress Claiming a prepared checkout never runs `git worktree add`, so the create card keeps its label with no bar; this pins that at the finalize step, including the retarget `reset --hard`. * test(worktree): keep only the checkout-progress tests that pin a contract Drops unit tests that mirror the implementation; the real-git flow, the parser captures, the cross-version Git contract and the create card test remain. * test(worktree): pin the checkout meter's path from git to the create card * test(worktree): merge the narrow-terminal capture's pipe chunks
331 lines
12 KiB
TypeScript
331 lines
12 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import {
|
|
addWorktreeMock,
|
|
gitExecFileAsyncMock,
|
|
listWorktreesMock,
|
|
setPlatform
|
|
} from './worktrees-test-module-mocks'
|
|
import { handlers, mainWindow, setupWorktreeHandlers, store } from './worktrees-test-harness'
|
|
import { beginPreparationWork } from '../worktree-create-concurrency'
|
|
import { addWorktree } from '../git/worktree'
|
|
|
|
const { trackMock, probeHookMock, telemetryEnabledMock } = vi.hoisted(() => ({
|
|
trackMock: vi.fn<(name: string, props: Record<string, unknown>) => void>(),
|
|
probeHookMock: vi.fn(),
|
|
telemetryEnabledMock: vi.fn(() => 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('../providers/ssh-filesystem-dispatch', async () =>
|
|
(await import('./worktrees-test-module-mocks')).sshFilesystemDispatchModuleMock()
|
|
)
|
|
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<Record<string, unknown>>()
|
|
)
|
|
)
|
|
vi.mock('../worktree-runner-script', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).worktreeRunnerScriptModuleMock(
|
|
await importOriginal<Record<string, unknown>>()
|
|
)
|
|
)
|
|
vi.mock('../effective-hook-config', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).effectiveHookConfigModuleMock(
|
|
await importOriginal<Record<string, unknown>>()
|
|
)
|
|
)
|
|
vi.mock('../setup-hook-env-vars', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).setupHookEnvVarsModuleMock(
|
|
await importOriginal<Record<string, unknown>>()
|
|
)
|
|
)
|
|
vi.mock('./worktree-logic', async (importOriginal) =>
|
|
(await import('./worktrees-test-module-mocks')).worktreeLogicModuleMock(
|
|
await importOriginal<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()
|
|
)
|
|
|
|
vi.mock('../telemetry/client', () => ({
|
|
track: trackMock,
|
|
isTelemetryEnabled: telemetryEnabledMock
|
|
}))
|
|
vi.mock('../git/create-event-repo-probe', () => ({
|
|
probeCreateEventRepoFacts: probeHookMock
|
|
}))
|
|
|
|
function makeRepo(fields: Record<string, unknown>) {
|
|
return {
|
|
id: 'repo-1',
|
|
path: '/workspace/repo',
|
|
displayName: 'repo',
|
|
badgeColor: '#000',
|
|
addedAt: 0,
|
|
worktreeBaseRef: 'origin/main',
|
|
...fields
|
|
}
|
|
}
|
|
|
|
function useRepo(repo: ReturnType<typeof makeRepo>): void {
|
|
store.getRepos.mockReturnValue([repo])
|
|
store.getRepo.mockReturnValue(repo)
|
|
store.setWorktreeMeta.mockImplementation((_worktreeId: string, meta: unknown) => meta)
|
|
}
|
|
|
|
function useLocalListing(): void {
|
|
listWorktreesMock.mockResolvedValue([
|
|
{ path: '/workspace/repo', head: 'abc', branch: 'main', isBare: false, isMainWorktree: true },
|
|
{ path: '/workspace/wt', head: 'abc123', branch: 'wt', isBare: false, isMainWorktree: false }
|
|
])
|
|
}
|
|
|
|
function trackedEvents(name: string): Record<string, unknown>[] {
|
|
return trackMock.mock.calls.filter(([eventName]) => eventName === name).map(([, props]) => props)
|
|
}
|
|
|
|
function trackedEvent(name: string): Record<string, unknown> | undefined {
|
|
const call = trackMock.mock.calls.find(([eventName]) => eventName === name)
|
|
return call?.[1]
|
|
}
|
|
|
|
function gitWorkCallCount(): number {
|
|
return (
|
|
gitExecFileAsyncMock.mock.calls.length +
|
|
addWorktreeMock.mock.calls.length +
|
|
listWorktreesMock.mock.calls.length
|
|
)
|
|
}
|
|
|
|
describe('worktrees:create event timing fields', () => {
|
|
beforeEach(() => {
|
|
setupWorktreeHandlers()
|
|
trackMock.mockReset()
|
|
probeHookMock.mockReset()
|
|
telemetryEnabledMock.mockReset()
|
|
telemetryEnabledMock.mockReturnValue(true)
|
|
})
|
|
|
|
it('sends timing after the create returns, without any further git work', async () => {
|
|
useRepo(makeRepo({}))
|
|
useLocalListing()
|
|
let resolveProbe: (value: {
|
|
postCheckoutHook: string
|
|
indexEntryCount?: number
|
|
}) => void = () => {}
|
|
probeHookMock.mockReturnValue(
|
|
new Promise((resolve) => {
|
|
resolveProbe = resolve
|
|
})
|
|
)
|
|
|
|
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
|
|
|
|
// The create already answered; the hook probe has not, so the event is not sent yet.
|
|
expect(trackedEvent('workspace_created')).toBeUndefined()
|
|
const gitCallsAtReturn = gitWorkCallCount()
|
|
|
|
resolveProbe({ postCheckoutHook: 'present', indexEntryCount: 6_000 })
|
|
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
|
|
|
|
expect(gitWorkCallCount()).toBe(gitCallsAtReturn)
|
|
expect(probeHookMock).toHaveBeenCalledWith('/workspace/repo')
|
|
const props = trackedEvent('workspace_created')
|
|
expect(props).toMatchObject({
|
|
source: 'unknown',
|
|
from_existing_branch: false,
|
|
create_entry_point: 'app',
|
|
execution_host: 'local',
|
|
worktree_count_bucket: '2-5',
|
|
concurrent_creates: 0,
|
|
concurrent_preparations: 0,
|
|
repo_file_count_bucket: '1k-10k',
|
|
post_checkout_hook: 'present'
|
|
})
|
|
// One create, one event: the runtime entry point never runs for an app create.
|
|
expect(trackedEvents('workspace_created')).toHaveLength(1)
|
|
expect(trackedEvents('workspace_create_failed')).toHaveLength(0)
|
|
expect(typeof props?.total_ms).toBe('number')
|
|
expect(typeof props?.git_worktree_add_ms).toBe('number')
|
|
expect(props).toHaveProperty('prepared_checkout')
|
|
// Nothing that names the repo, the branch or a path rides along.
|
|
expect(JSON.stringify(props)).not.toMatch(/workspace|wt|repo-1/)
|
|
})
|
|
|
|
it('counts prepared-checkout work that ran alongside the create', async () => {
|
|
useRepo(makeRepo({}))
|
|
useLocalListing()
|
|
probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' })
|
|
const build = beginPreparationWork()
|
|
|
|
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
|
|
build.end()
|
|
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
|
|
|
|
expect(trackedEvent('workspace_created')).toMatchObject({ concurrent_preparations: 1 })
|
|
})
|
|
|
|
it('still answers the create, or its error, when sending the event throws', async () => {
|
|
vi.spyOn(console, 'warn').mockImplementation(() => {})
|
|
useRepo(makeRepo({}))
|
|
useLocalListing()
|
|
probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' })
|
|
trackMock.mockImplementation(() => {
|
|
throw new Error('telemetry broke')
|
|
})
|
|
|
|
await expect(
|
|
handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
|
|
).resolves.toHaveProperty('worktree')
|
|
|
|
addWorktreeMock.mockRejectedValue(new Error('fatal: could not create work tree dir'))
|
|
await expect(
|
|
handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
|
|
).rejects.toThrow('could not create work tree dir')
|
|
})
|
|
|
|
it('does not read the repo for hooks when telemetry is off', async () => {
|
|
useRepo(makeRepo({}))
|
|
useLocalListing()
|
|
telemetryEnabledMock.mockReturnValue(false)
|
|
|
|
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
|
|
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
|
|
|
|
expect(probeHookMock).not.toHaveBeenCalled()
|
|
expect(trackedEvent('workspace_created')).not.toHaveProperty('post_checkout_hook')
|
|
})
|
|
|
|
it.each([
|
|
{ repoPath: '\\\\wsl.localhost\\Ubuntu\\home\\me\\repo', host: 'wsl' },
|
|
{ repoPath: '/workspace/repo', host: 'local' }
|
|
])(
|
|
'labels $repoPath as $host from where Git runs, with no WSL project runtime',
|
|
async ({ repoPath, host }) => {
|
|
setPlatform('win32')
|
|
useRepo(makeRepo({ path: repoPath }))
|
|
useLocalListing()
|
|
probeHookMock.mockResolvedValue({ postCheckoutHook: 'absent' })
|
|
|
|
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
|
|
await vi.waitFor(() => expect(trackedEvent('workspace_created')).toBeDefined())
|
|
|
|
expect(trackedEvent('workspace_created')).toMatchObject({ execution_host: host })
|
|
}
|
|
)
|
|
|
|
it('refuses an SSH create and still names its host on the failed event', async () => {
|
|
useRepo(makeRepo({ path: '/remote/repo', executionHostId: 'ssh:target-a' }))
|
|
|
|
await expect(
|
|
handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
|
|
).rejects.toThrow('Remote connection dropped')
|
|
|
|
expect(addWorktreeMock).not.toHaveBeenCalled()
|
|
expect(probeHookMock).not.toHaveBeenCalled()
|
|
expect(trackedEvent('workspace_create_failed')).toMatchObject({ execution_host: 'ssh' })
|
|
})
|
|
|
|
it('names the phase a failed create died in', async () => {
|
|
useRepo(makeRepo({}))
|
|
useLocalListing()
|
|
addWorktreeMock.mockRejectedValue(new Error('fatal: could not create work tree dir'))
|
|
|
|
await expect(
|
|
handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt' })
|
|
).rejects.toThrow()
|
|
|
|
const props = trackedEvent('workspace_create_failed')
|
|
expect(props).toMatchObject({
|
|
failed_phase: 'git_worktree_add',
|
|
create_entry_point: 'app',
|
|
execution_host: 'local',
|
|
concurrent_creates: 0,
|
|
concurrent_preparations: 0,
|
|
prepared_checkout: 'miss',
|
|
prepared_checkout_miss_reason: 'none_armed'
|
|
})
|
|
expect(trackedEvents('workspace_create_failed')).toHaveLength(1)
|
|
expect(trackedEvents('workspace_created')).toHaveLength(0)
|
|
expect(typeof props?.total_ms).toBe('number')
|
|
expect(JSON.stringify(props)).not.toMatch(/fatal|work tree/)
|
|
})
|
|
})
|
|
|
|
describe('worktrees:create checkout progress event', () => {
|
|
beforeEach(() => {
|
|
setupWorktreeHandlers()
|
|
})
|
|
|
|
it("forwards git's checkout meter to the pending create it belongs to", async () => {
|
|
useRepo(makeRepo({}))
|
|
useLocalListing()
|
|
await handlers['worktrees:create'](null, { repoId: 'repo-1', name: 'wt', creationId: 'c-7' })
|
|
const onCheckoutProgress = vi.mocked(addWorktree).mock.calls.at(-1)?.[6]?.onCheckoutProgress
|
|
vi.mocked(mainWindow.webContents.send).mockClear()
|
|
|
|
onCheckoutProgress?.({ percent: 42, completed: 420, total: 1000 })
|
|
onCheckoutProgress?.(null)
|
|
|
|
expect(vi.mocked(mainWindow.webContents.send).mock.calls).toEqual([
|
|
[
|
|
'createWorktree:progress',
|
|
{
|
|
creationId: 'c-7',
|
|
phase: 'creating',
|
|
checkout: { percent: 42, completed: 420, total: 1000 }
|
|
}
|
|
],
|
|
['createWorktree:progress', { creationId: 'c-7', phase: 'creating', checkout: null }]
|
|
])
|
|
})
|
|
})
|