(args: {
const metadataResult = args.onMetadataPersisted(worktree)
if ((repo.symlinkPaths ?? []).length > 0) {
- await createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths ?? [])
+ await timing.time('create_symlinks', () =>
+ createWorktreeLinkedPaths(repo.path, created.path, repo.symlinkPaths ?? [])
+ )
}
// These discoveries are read-only; overlap them, but keep the shared-path
// mutation ahead of include copies below.
const [sharedDirectories, worktreeIncludePaths] = await Promise.all([
- resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions),
- resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions)
+ timing.time('resolve_shared_directories', () =>
+ resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions)
+ ),
+ timing.time('resolve_worktreeinclude', () =>
+ resolveWorktreeIncludePaths(repo.path, localWorktreeGitOptions)
+ )
])
if (sharedDirectories.length > 0) {
- await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories)
+ await timing.time('create_shared_directories', () =>
+ createWorktreeSharedPaths(repo.path, created.path, sharedDirectories)
+ )
}
if (worktreeIncludePaths.length === 0) {
return { worktree, metadataResult }
}
- const skippedIncludePaths = await createWorktreeCopiedPaths(
- repo.path,
- created.path,
- worktreeIncludePaths
+ const skippedIncludePaths = await timing.time('copy_worktreeinclude', () =>
+ createWorktreeCopiedPaths(repo.path, created.path, worktreeIncludePaths)
)
const includeCopyWarning = formatWorktreeIncludeCopyWarning(skippedIncludePaths)
if (includeCopyWarning) {
diff --git a/src/main/runtime/runtime-remote-worktree-create-request.ts b/src/main/runtime/runtime-remote-worktree-create-request.ts
index ac40ec64b8d..0f12caff28c 100644
--- a/src/main/runtime/runtime-remote-worktree-create-request.ts
+++ b/src/main/runtime/runtime-remote-worktree-create-request.ts
@@ -2,6 +2,7 @@ import type { BrowserWindow } from 'electron'
import type { CreateWorktreeResult } from '../../shared/worktree/create-types'
import type { Repo } from '../../shared/repo-types'
import { createRemoteWorktree } from '../ipc/worktree-remote'
+import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing'
import type { Store } from '../persistence'
import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
@@ -12,6 +13,8 @@ export type RuntimeRemoteWorktreeCreateArgs = Omit<
'repoSelector'
> & {
startupFollowup?: WorktreeStartupFollowup
+ /** The create's timing recorder, owned by the runtime's create events. */
+ timing?: WorktreeCreateTimingRecorder
}
export async function requestRuntimeRemoteWorktree(
@@ -66,7 +69,8 @@ export async function requestRuntimeRemoteWorktree(
},
repo,
store as unknown as Store,
- headlessWindow
+ headlessWindow,
+ args.timing
)
if (args.comment !== undefined) {
store.setWorktreeMeta(result.worktree.id, { comment: args.comment })
diff --git a/src/main/workspace-create-event-fields.test.ts b/src/main/workspace-create-event-fields.test.ts
new file mode 100644
index 00000000000..eab8bb78a37
--- /dev/null
+++ b/src/main/workspace-create-event-fields.test.ts
@@ -0,0 +1,243 @@
+import { describe, expect, it } from 'vitest'
+import { eventSchemas } from '../shared/telemetry-event-registry'
+import { createWorktreeCreateTimingRecorder } from './worktree-create-timing'
+import {
+ bucketRepoFileCount,
+ bucketWorktreeCount,
+ workspaceCreateFailureFields,
+ workspaceCreateTimingFields
+} from './workspace-create-event-fields'
+
+const quiet = { otherCreates: 0, preparations: 0 }
+
+describe('workspaceCreateTimingFields', () => {
+ it('maps a local create with a prepared-checkout hit', () => {
+ const fields = workspaceCreateTimingFields(
+ {
+ totalDurationMs: 1_000.4,
+ phases: [
+ { phase: 'refresh_base_ref', startedAtMs: 0, durationMs: 200.6 },
+ { phase: 'git_worktree_add', startedAtMs: 200, durationMs: 500 },
+ { phase: 'prepared_checkout_claim', startedAtMs: 200, durationMs: 4 },
+ { phase: 'prepared_checkout_wait', startedAtMs: 204, durationMs: 300 },
+ { phase: 'list_created_worktree', startedAtMs: 700, durationMs: 100 }
+ ],
+ preparedCheckout: {
+ status: 'hit',
+ reset: 'base_moved',
+ origin: 'rearm',
+ buildMs: 30_000.4,
+ idleMs: 2_500
+ },
+ executionHost: 'local',
+ worktreeCount: 812
+ },
+ {
+ entryPoint: 'runtime',
+ concurrency: { otherCreates: 1, preparations: 2 },
+ repoFacts: { postCheckoutHook: 'absent', indexEntryCount: 42_000 }
+ }
+ )
+
+ expect(fields).toEqual({
+ total_ms: 1_000,
+ unattributed_ms: 200,
+ refresh_base_ref_ms: 201,
+ git_worktree_add_ms: 500,
+ prepared_checkout_claim_ms: 4,
+ prepared_checkout_wait_ms: 300,
+ list_created_worktree_ms: 100,
+ prepared_checkout: 'hit',
+ prepared_checkout_reset: 'base_moved',
+ prepared_checkout_origin: 'rearm',
+ prepared_checkout_build_ms: 30_000,
+ prepared_checkout_idle_ms: 2_500,
+ create_entry_point: 'runtime',
+ execution_host: 'local',
+ worktree_count_bucket: '301-1000',
+ concurrent_creates: 1,
+ concurrent_preparations: 2,
+ repo_file_count_bucket: '10k-100k',
+ post_checkout_hook: 'absent'
+ })
+ expect(
+ eventSchemas.workspace_created.safeParse({
+ source: 'sidebar',
+ from_existing_branch: false,
+ ...fields
+ }).success
+ ).toBe(true)
+ })
+
+ it('maps a miss with its reason and omits what the create did not learn', () => {
+ const fields = workspaceCreateTimingFields(
+ {
+ totalDurationMs: 50,
+ phases: [],
+ preparedCheckout: { status: 'miss', reason: 'none_armed' }
+ },
+ { entryPoint: 'app', concurrency: quiet }
+ )
+
+ expect(fields).toEqual({
+ total_ms: 50,
+ unattributed_ms: 50,
+ prepared_checkout: 'miss',
+ prepared_checkout_miss_reason: 'none_armed',
+ create_entry_point: 'app',
+ concurrent_creates: 0,
+ concurrent_preparations: 0
+ })
+ })
+
+ it('sends the hook but no file count when the repo has no usable index', () => {
+ const fields = workspaceCreateTimingFields(
+ { totalDurationMs: 1, phases: [] },
+ { entryPoint: 'app', concurrency: quiet, repoFacts: { postCheckoutHook: 'unknown' } }
+ )
+ expect(fields.post_checkout_hook).toBe('unknown')
+ expect(fields).not.toHaveProperty('repo_file_count_bucket')
+ })
+
+ it('takes the worktree count from the repo read when the create did not list worktrees', () => {
+ const fields = workspaceCreateTimingFields(
+ { totalDurationMs: 1, phases: [] },
+ {
+ entryPoint: 'app',
+ concurrency: quiet,
+ repoFacts: { postCheckoutHook: 'absent', worktreeCount: 7 }
+ }
+ )
+ expect(fields.worktree_count_bucket).toBe('6-20')
+ })
+
+ it('sums a repeated phase and drops names outside the closed vocabulary', () => {
+ const fields = workspaceCreateTimingFields(
+ {
+ totalDurationMs: 100,
+ phases: [
+ { phase: 'refresh_base_ref', startedAtMs: 0, durationMs: 10 },
+ { phase: 'refresh_base_ref', startedAtMs: 10, durationMs: 15 },
+ { phase: '/Users/alice/repo', startedAtMs: 25, durationMs: 5 }
+ ]
+ },
+ { entryPoint: 'app', concurrency: quiet }
+ )
+
+ expect(fields.refresh_base_ref_ms).toBe(25)
+ expect(Object.keys(fields).some((key) => key.includes('alice'))).toBe(false)
+ })
+})
+
+describe('workspaceCreateFailureFields', () => {
+ it('names the failing phase and the elapsed time', async () => {
+ let now = 0
+ const recorder = createWorktreeCreateTimingRecorder(() => now)
+ recorder.recordExecutionHost('ssh')
+ const error = await recorder
+ .time('git_worktree_add', async () => {
+ now = 4_200
+ throw new Error('fatal: could not create work tree dir /Users/alice/x')
+ })
+ .catch((caught: unknown) => caught)
+
+ const fields = workspaceCreateFailureFields(recorder, {
+ entryPoint: 'app',
+ concurrency: { otherCreates: 3, preparations: 1 },
+ error
+ })
+
+ expect(fields).toEqual({
+ failed_phase: 'git_worktree_add',
+ total_ms: 4_200,
+ create_entry_point: 'app',
+ execution_host: 'ssh',
+ concurrent_creates: 3,
+ concurrent_preparations: 1
+ })
+ expect(
+ eventSchemas.workspace_create_failed.safeParse({
+ source: 'sidebar',
+ error_class: 'git_failed',
+ ...fields
+ }).success
+ ).toBe(true)
+ })
+
+ it('carries the prepared-checkout outcome and wait of a create that failed after it', async () => {
+ let now = 0
+ const recorder = createWorktreeCreateTimingRecorder(() => now)
+ recorder.recordExecutionHost('local')
+ await recorder.time('prepared_checkout_wait', async () => {
+ now = 200_400
+ })
+ recorder.recordPreparedCheckout({ status: 'miss', reason: 'finalize_failed' })
+ const error = await recorder
+ .time('git_worktree_add', async () => {
+ throw new Error('fatal: a branch named feature already exists')
+ })
+ .catch((caught: unknown) => caught)
+
+ const fields = workspaceCreateFailureFields(recorder, {
+ entryPoint: 'runtime',
+ concurrency: quiet,
+ error
+ })
+
+ expect(fields).toMatchObject({
+ failed_phase: 'git_worktree_add',
+ prepared_checkout: 'miss',
+ prepared_checkout_miss_reason: 'finalize_failed',
+ prepared_checkout_wait_ms: 200_400,
+ create_entry_point: 'runtime'
+ })
+ expect(
+ eventSchemas.workspace_create_failed.safeParse({
+ source: 'unknown',
+ error_class: 'git_failed',
+ ...fields
+ }).success
+ ).toBe(true)
+ })
+
+ it('reports untimed when the create failed outside every phase', () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ expect(
+ workspaceCreateFailureFields(recorder, {
+ entryPoint: 'app',
+ concurrency: quiet,
+ error: new Error('x')
+ }).failed_phase
+ ).toBe('untimed')
+ })
+})
+
+describe('bucketWorktreeCount', () => {
+ it.each([
+ [0, '1'],
+ [1, '1'],
+ [2, '2-5'],
+ [5, '2-5'],
+ [6, '6-20'],
+ [21, '21-100'],
+ [101, '101-300'],
+ [301, '301-1000'],
+ [1000, '301-1000'],
+ [1001, '1001+']
+ ] as const)('%i worktrees -> %s', (count, bucket) => {
+ expect(bucketWorktreeCount(count)).toBe(bucket)
+ })
+})
+
+describe('bucketRepoFileCount', () => {
+ it.each([
+ [0, '<1k'],
+ [999, '<1k'],
+ [1_000, '1k-10k'],
+ [10_000, '10k-100k'],
+ [100_000, '100k-500k'],
+ [500_000, '500k+']
+ ] as const)('%i files -> %s', (count, bucket) => {
+ expect(bucketRepoFileCount(count)).toBe(bucket)
+ })
+})
diff --git a/src/main/workspace-create-event-fields.ts b/src/main/workspace-create-event-fields.ts
new file mode 100644
index 00000000000..921ff9e2c77
--- /dev/null
+++ b/src/main/workspace-create-event-fields.ts
@@ -0,0 +1,160 @@
+import type { z } from 'zod'
+import type { WorktreeCreateTiming } from '../shared/worktree/create-types'
+import {
+ WORKTREE_CREATE_PHASES,
+ type WorkspaceCreateEntryPoint,
+ type WorktreeCreatePhase
+} from '../shared/worktree/create-timing-vocabulary'
+import type {
+ REPO_FILE_COUNT_BUCKETS,
+ WORKTREE_COUNT_BUCKETS,
+ workspaceCreateFailedProperties,
+ workspaceCreatedTimingProperties
+} from '../shared/telemetry-workspace-create-schemas'
+import type { WorktreeCreateTimingRecorder } from './worktree-create-timing'
+import type { WorktreeCreateConcurrency } from './worktree-create-concurrency'
+import type { CreateEventRepoFacts } from './git/create-event-repo-probe'
+import { worktreeCreateUnattributedMs } from './observability/instrumentation'
+
+type OptionalEventFields> = { [K in keyof P]?: z.infer
}
+export type WorkspaceCreateTimingFields = OptionalEventFields<
+ typeof workspaceCreatedTimingProperties
+>
+export type WorkspaceCreateFailureFields = OptionalEventFields<
+ typeof workspaceCreateFailedProperties
+>
+type WorktreeCountBucket = (typeof WORKTREE_COUNT_BUCKETS)[number]
+type RepoFileCountBucket = (typeof REPO_FILE_COUNT_BUCKETS)[number]
+
+/** What the create's owner knows beyond the timing the create recorded. */
+export type WorkspaceCreateEventContext = {
+ entryPoint: WorkspaceCreateEntryPoint
+ concurrency: WorktreeCreateConcurrency
+}
+
+const KNOWN_PHASES: ReadonlySet = new Set(WORKTREE_CREATE_PHASES)
+
+function isKnownPhase(phase: string): phase is WorktreeCreatePhase {
+ return KNOWN_PHASES.has(phase)
+}
+
+export function bucketWorktreeCount(count: number): WorktreeCountBucket {
+ if (count <= 1) {
+ return '1'
+ }
+ if (count <= 5) {
+ return '2-5'
+ }
+ if (count <= 20) {
+ return '6-20'
+ }
+ if (count <= 100) {
+ return '21-100'
+ }
+ if (count <= 300) {
+ return '101-300'
+ }
+ return count <= 1000 ? '301-1000' : '1001+'
+}
+
+export function bucketRepoFileCount(count: number): RepoFileCountBucket {
+ if (count < 1_000) {
+ return '<1k'
+ }
+ if (count < 10_000) {
+ return '1k-10k'
+ }
+ if (count < 100_000) {
+ return '10k-100k'
+ }
+ return count < 500_000 ? '100k-500k' : '500k+'
+}
+
+function phaseTotals(phases: WorktreeCreateTiming['phases']): Map {
+ const totals = new Map()
+ for (const phase of phases) {
+ // Unknown names are dropped rather than forwarded: only the closed vocabulary may leave.
+ if (isKnownPhase(phase.phase)) {
+ totals.set(phase.phase, (totals.get(phase.phase) ?? 0) + phase.durationMs)
+ }
+ }
+ return totals
+}
+
+function phaseDurationFields(phases: WorktreeCreateTiming['phases']): WorkspaceCreateTimingFields {
+ const fields: WorkspaceCreateTimingFields = {}
+ for (const [phase, durationMs] of phaseTotals(phases)) {
+ fields[`${phase}_ms`] = Math.round(durationMs)
+ }
+ return fields
+}
+
+function preparedCheckoutFields(
+ prepared: WorktreeCreateTiming['preparedCheckout']
+): WorkspaceCreateFailureFields {
+ if (prepared?.status === 'hit') {
+ return {
+ prepared_checkout: 'hit',
+ prepared_checkout_reset: prepared.reset,
+ prepared_checkout_origin: prepared.origin,
+ prepared_checkout_build_ms: Math.round(prepared.buildMs),
+ prepared_checkout_idle_ms: Math.round(prepared.idleMs)
+ }
+ }
+ return prepared
+ ? { prepared_checkout: 'miss', prepared_checkout_miss_reason: prepared.reason }
+ : {}
+}
+
+function contextFields(
+ timing: WorktreeCreateTiming,
+ context: WorkspaceCreateEventContext
+): WorkspaceCreateFailureFields {
+ return {
+ create_entry_point: context.entryPoint,
+ ...(timing.executionHost ? { execution_host: timing.executionHost } : {}),
+ concurrent_creates: context.concurrency.otherCreates,
+ concurrent_preparations: context.concurrency.preparations
+ }
+}
+
+function worktreeCountFields(count: number | undefined): WorkspaceCreateTimingFields {
+ return count === undefined ? {} : { worktree_count_bucket: bucketWorktreeCount(count) }
+}
+
+/** Event fields for a finished create, built only from what the create already measured. */
+export function workspaceCreateTimingFields(
+ timing: WorktreeCreateTiming,
+ context: WorkspaceCreateEventContext & { repoFacts?: CreateEventRepoFacts }
+): WorkspaceCreateTimingFields {
+ const { repoFacts } = context
+ return {
+ total_ms: Math.round(timing.totalDurationMs),
+ unattributed_ms: worktreeCreateUnattributedMs(timing),
+ ...phaseDurationFields(timing.phases),
+ ...preparedCheckoutFields(timing.preparedCheckout),
+ ...contextFields(timing, context),
+ ...worktreeCountFields(repoFacts?.worktreeCount ?? timing.worktreeCount),
+ ...(repoFacts?.indexEntryCount !== undefined
+ ? { repo_file_count_bucket: bucketRepoFileCount(repoFacts.indexEntryCount) }
+ : {}),
+ ...(repoFacts ? { post_checkout_hook: repoFacts.postCheckoutHook } : {})
+ }
+}
+
+/** Event fields for a failed create: where it died, how long it had run, and what the prepared
+ * checkout did before it. */
+export function workspaceCreateFailureFields(
+ recorder: Pick,
+ context: WorkspaceCreateEventContext & { error: unknown }
+): WorkspaceCreateFailureFields {
+ const timing = recorder.finish()
+ const waitMs = phaseTotals(timing.phases).get('prepared_checkout_wait')
+ return {
+ failed_phase: recorder.failedPhase(context.error) ?? 'untimed',
+ total_ms: Math.round(timing.totalDurationMs),
+ ...preparedCheckoutFields(timing.preparedCheckout),
+ ...(waitMs !== undefined ? { prepared_checkout_wait_ms: Math.round(waitMs) } : {}),
+ ...contextFields(timing, context)
+ }
+}
diff --git a/src/main/workspace-create-telemetry.ts b/src/main/workspace-create-telemetry.ts
new file mode 100644
index 00000000000..a1dcc4d9f7d
--- /dev/null
+++ b/src/main/workspace-create-telemetry.ts
@@ -0,0 +1,160 @@
+import type { WorktreeCreateTiming } from '../shared/worktree/create-types'
+import type { WorkspaceCreateEntryPoint } from '../shared/worktree/create-timing-vocabulary'
+import { workspaceSourceSchema } from '../shared/telemetry-events'
+import type { EventProps, WorkspaceSource } from '../shared/telemetry-events'
+import { isTelemetryEnabled, track } from './telemetry/client'
+import { getCohortAtEmit } from './telemetry/cohort-classifier'
+import { classifyWorkspaceCreateError } from './ipc/workspace-create-error-classifier'
+import { probeCreateEventRepoFacts } from './git/create-event-repo-probe'
+import {
+ createWorktreeCreateTimingRecorder,
+ type WorktreeCreateTimingRecorder
+} from './worktree-create-timing'
+import { beginWorktreeCreate } from './worktree-create-concurrency'
+import {
+ workspaceCreateFailureFields,
+ workspaceCreateTimingFields,
+ type WorkspaceCreateEventContext
+} from './workspace-create-event-fields'
+
+/** One create's `workspace_created` / `workspace_create_failed` event: exactly one is sent. */
+export type WorkspaceCreateTelemetry = {
+ /** The recorder the create times its phases into. */
+ readonly timing: WorktreeCreateTimingRecorder
+ /** `timing` is the create's own finished timing when it returned one. */
+ succeeded(timing?: WorktreeCreateTiming): void
+ failed(error: unknown): void
+}
+
+export type WorkspaceCreateTelemetryArgs = {
+ /** The caller-supplied value, validated here; anything else is sent as `unknown`. */
+ source: unknown
+ entryPoint: WorkspaceCreateEntryPoint
+ repoPath: string
+ fromExistingBranch: boolean
+ /** Folder workspaces register a directory: no timing, no disk competition, event unchanged. */
+ isFolder: boolean
+}
+
+/**
+ * Starts the events for one create. Both create entry points (the app's IPC handler and the
+ * runtime API) begin here, so a create is counted once and both send the same fields.
+ */
+export function beginWorkspaceCreateTelemetry(
+ args: WorkspaceCreateTelemetryArgs
+): WorkspaceCreateTelemetry {
+ const sourceParse = workspaceSourceSchema.safeParse(args.source)
+ const source: WorkspaceSource = sourceParse.success ? sourceParse.data : 'unknown'
+ const inFlight = args.isFolder ? null : beginWorktreeCreate()
+ const timing = createWorktreeCreateTimingRecorder(undefined, inFlight ?? undefined)
+ let settled = false
+ const settleOnce = (send: () => void): void => {
+ if (settled) {
+ return
+ }
+ settled = true
+ try {
+ send()
+ } catch (error) {
+ // Bookkeeping must never change the outcome of the create it describes.
+ console.warn('[worktree-create] create event could not be sent', error)
+ } finally {
+ inFlight?.end()
+ }
+ }
+
+ return {
+ timing,
+ succeeded(createTiming) {
+ settleOnce(() => {
+ const props: EventProps<'workspace_created'> = {
+ source,
+ from_existing_branch: !args.isFolder && args.fromExistingBranch,
+ ...getCohortAtEmit()
+ }
+ if (!inFlight) {
+ track('workspace_created', props)
+ return
+ }
+ void trackWorkspaceCreated(props, args.repoPath, createTiming ?? timing.finish(), {
+ entryPoint: args.entryPoint,
+ concurrency: inFlight.end()
+ }).catch((error: unknown) => {
+ console.warn('[worktree-create] create event could not be sent', error)
+ })
+ })
+ },
+ failed(error) {
+ settleOnce(() => {
+ track('workspace_create_failed', {
+ source,
+ error_class: classifyWorkspaceCreateError(error),
+ ...getCohortAtEmit(),
+ ...(inFlight
+ ? workspaceCreateFailureFields(timing, {
+ entryPoint: args.entryPoint,
+ concurrency: inFlight.end(),
+ error
+ })
+ : {})
+ })
+ })
+ }
+ }
+}
+
+/** Lets a runtime create start its events once it is known to be a Git create. */
+export type RuntimeWorkspaceCreateEvents = {
+ begin(repoPath: string): WorktreeCreateTimingRecorder
+}
+
+/**
+ * Runs a runtime create and settles its events before returning, so a re-arm the caller fires
+ * afterwards is not counted against it. Folder workspaces and requests rejected before `begin`
+ * send nothing, as before.
+ */
+export async function trackRuntimeWorkspaceCreate(
+ request: { telemetrySource?: unknown; baseBranch?: string },
+ perform: (events: RuntimeWorkspaceCreateEvents) => Promise
+): Promise {
+ const session: { current?: WorkspaceCreateTelemetry } = {}
+ try {
+ const result = await perform({
+ begin(repoPath) {
+ session.current = beginWorkspaceCreateTelemetry({
+ source: request.telemetrySource,
+ entryPoint: 'runtime',
+ repoPath,
+ fromExistingBranch:
+ typeof request.baseBranch === 'string' && request.baseBranch.length > 0,
+ isFolder: false
+ })
+ return session.current.timing
+ }
+ })
+ session.current?.succeeded()
+ return result
+ } catch (error) {
+ session.current?.failed(error)
+ throw error
+ }
+}
+
+/** Runs after the create has returned, so the repo probe never adds to create latency. */
+async function trackWorkspaceCreated(
+ props: EventProps<'workspace_created'>,
+ repoPath: string,
+ timing: WorktreeCreateTiming,
+ context: WorkspaceCreateEventContext
+): Promise {
+ // SSH would need a remote round trip, so only local and WSL repos are probed; with telemetry
+ // off the repo's .git is not read at all.
+ const repoFacts =
+ isTelemetryEnabled() && (timing.executionHost === 'local' || timing.executionHost === 'wsl')
+ ? await probeCreateEventRepoFacts(repoPath)
+ : undefined
+ track('workspace_created', {
+ ...props,
+ ...workspaceCreateTimingFields(timing, { ...context, ...(repoFacts ? { repoFacts } : {}) })
+ })
+}
diff --git a/src/main/worktree-create-concurrency.test.ts b/src/main/worktree-create-concurrency.test.ts
new file mode 100644
index 00000000000..0df09b9552d
--- /dev/null
+++ b/src/main/worktree-create-concurrency.test.ts
@@ -0,0 +1,87 @@
+import { afterEach, describe, expect, it } from 'vitest'
+import {
+ _resetWorktreeCreateConcurrencyForTests,
+ beginPreparationWork,
+ beginWorktreeCreate,
+ trackPreparationWork
+} from './worktree-create-concurrency'
+
+describe('worktree create concurrency', () => {
+ afterEach(() => {
+ _resetWorktreeCreateConcurrencyForTests()
+ })
+
+ it('reports zero for a create that ran alone', () => {
+ expect(beginWorktreeCreate().end()).toEqual({ otherCreates: 0, preparations: 0 })
+ })
+
+ it('reports the most other creates seen at once, including ones that started later', () => {
+ const first = beginWorktreeCreate()
+ const second = beginWorktreeCreate()
+ const third = beginWorktreeCreate()
+ expect(second.end().otherCreates).toBe(2)
+ expect(third.end().otherCreates).toBe(2)
+ // Peak, not count at the end: `first` once overlapped two others.
+ expect(first.end().otherCreates).toBe(2)
+ })
+
+ it('stops counting a create once it ends, and a later end returns the same counts', () => {
+ const first = beginWorktreeCreate()
+ const counts = first.end()
+ beginPreparationWork()
+ expect(first.end()).toBe(counts)
+ expect(beginWorktreeCreate().end().otherCreates).toBe(0)
+ })
+
+ it('counts preparation work running at the start and work that starts later, as a peak', () => {
+ const running = beginPreparationWork()
+ const create = beginWorktreeCreate()
+ running.end()
+ const later = beginPreparationWork()
+ const another = beginPreparationWork()
+ later.end()
+ another.end()
+ expect(create.end().preparations).toBe(2)
+ })
+
+ it('leaves out the prepared checkout the create adopted, wherever its peak fell', () => {
+ const adopted = beginPreparationWork()
+ const create = beginWorktreeCreate()
+ const other = beginPreparationWork()
+ other.end()
+ create.adoptPreparation(adopted)
+ adopted.end()
+ expect(create.end().preparations).toBe(1)
+ })
+
+ it('reports zero when the only work seen was the adopted prepared checkout', () => {
+ const create = beginWorktreeCreate()
+ const adopted = beginPreparationWork()
+ create.adoptPreparation(adopted)
+ expect(create.end().preparations).toBe(0)
+ })
+
+ it('still counts work that ran after the adopted prepared checkout finished', () => {
+ const create = beginWorktreeCreate()
+ const adopted = beginPreparationWork()
+ create.adoptPreparation(adopted)
+ adopted.end()
+ beginPreparationWork().end()
+ expect(create.end().preparations).toBe(1)
+ })
+
+ it('ignores work that starts after the create ended', () => {
+ const create = beginWorktreeCreate()
+ create.end()
+ beginPreparationWork()
+ expect(create.end().preparations).toBe(0)
+ })
+
+ it('counts tracked work until it settles, including on failure', async () => {
+ const observer = beginWorktreeCreate()
+ await trackPreparationWork(Promise.reject(new Error('discard failed'))).catch(() => undefined)
+ const create = beginWorktreeCreate()
+ expect(create.end().preparations).toBe(0)
+ expect(observer.end().preparations).toBe(1)
+ })
+})
diff --git a/src/main/worktree-create-concurrency.ts b/src/main/worktree-create-concurrency.ts
new file mode 100644
index 00000000000..da3b88b7711
--- /dev/null
+++ b/src/main/worktree-create-concurrency.ts
@@ -0,0 +1,109 @@
+// In-memory counts of worktree creates and prepared-checkout disk work running in this process, so
+// a slow create can be read against what competed with it for the disk.
+
+/** One prepared-checkout build or discard; its identity lets the create that used it leave it out. */
+export type PreparationWork = { end(): void }
+
+type InFlightCreate = {
+ peakOtherCreates: number
+ peakPreparations: number
+ /** Per work item seen: the peak with that item left out. */
+ peakPreparationsWithout: Map
+ adopted: PreparationWork | undefined
+}
+
+export type WorktreeCreateConcurrency = {
+ /** Most other creates seen running alongside this one. */
+ otherCreates: number
+ /** Most preparation work seen running at once, excluding the prepared checkout this create used. */
+ preparations: number
+}
+
+export type WorktreeCreateInFlightHandle = {
+ /** Leaves `work` out of this create's count: it is the prepared checkout this create waits on. */
+ adoptPreparation(work: PreparationWork): void
+ /** Ends this create's membership; later calls return the same counts. */
+ end(): WorktreeCreateConcurrency
+}
+
+const creates = new Set()
+const preparations = new Set()
+
+function observePreparations(create: InFlightCreate): void {
+ const count = preparations.size
+ for (const [work, peak] of create.peakPreparationsWithout) {
+ create.peakPreparationsWithout.set(
+ work,
+ Math.max(peak, count - (preparations.has(work) ? 1 : 0))
+ )
+ }
+ for (const work of preparations) {
+ if (!create.peakPreparationsWithout.has(work)) {
+ // Before it appeared, leaving it out changed nothing, so the plain peak so far applies.
+ create.peakPreparationsWithout.set(work, Math.max(create.peakPreparations, count - 1))
+ }
+ }
+ create.peakPreparations = Math.max(create.peakPreparations, count)
+}
+
+export function beginWorktreeCreate(): WorktreeCreateInFlightHandle {
+ const entry: InFlightCreate = {
+ peakOtherCreates: creates.size,
+ peakPreparations: 0,
+ peakPreparationsWithout: new Map(),
+ adopted: undefined
+ }
+ for (const other of creates) {
+ other.peakOtherCreates = Math.max(other.peakOtherCreates, creates.size)
+ }
+ observePreparations(entry)
+ creates.add(entry)
+ let result: WorktreeCreateConcurrency | undefined
+ return {
+ adoptPreparation(work) {
+ entry.adopted = work
+ },
+ end() {
+ if (!result) {
+ creates.delete(entry)
+ result = {
+ otherCreates: entry.peakOtherCreates,
+ preparations: entry.adopted
+ ? (entry.peakPreparationsWithout.get(entry.adopted) ?? entry.peakPreparations)
+ : entry.peakPreparations
+ }
+ }
+ return result
+ }
+ }
+}
+
+export function beginPreparationWork(): PreparationWork {
+ let ended = false
+ const work: PreparationWork = {
+ end() {
+ if (ended) {
+ return
+ }
+ ended = true
+ // Peaks only rise when work starts, so ending needs no observation.
+ preparations.delete(work)
+ }
+ }
+ preparations.add(work)
+ for (const create of creates) {
+ observePreparations(create)
+ }
+ return work
+}
+
+/** Counts `operation` as preparation work until it settles. */
+export function trackPreparationWork(operation: Promise): Promise {
+ const work = beginPreparationWork()
+ return operation.finally(() => work.end())
+}
+
+export function _resetWorktreeCreateConcurrencyForTests(): void {
+ creates.clear()
+ preparations.clear()
+}
diff --git a/src/main/worktree-create-preparation-activity.test.ts b/src/main/worktree-create-preparation-activity.test.ts
new file mode 100644
index 00000000000..b896767d6b8
--- /dev/null
+++ b/src/main/worktree-create-preparation-activity.test.ts
@@ -0,0 +1,91 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+import { createPreparationActivity } from './worktree-create-preparation-activity'
+import {
+ _resetWorktreeCreateConcurrencyForTests,
+ beginWorktreeCreate
+} from './worktree-create-concurrency'
+
+let now = 0
+
+beforeEach(() => {
+ now = 1_000
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+})
+
+afterEach(() => {
+ _resetWorktreeCreateConcurrencyForTests()
+ vi.restoreAllMocks()
+})
+
+describe('createPreparationActivity', () => {
+ it('reports the origin, including a re-arm the prefetch then asked for', () => {
+ expect(createPreparationActivity('explicit').origin()).toBe('prefetch')
+ expect(createPreparationActivity('automatic').origin()).toBe('rearm')
+ const asked = createPreparationActivity('automatic')
+ asked.requestedByPrefetch()
+ expect(asked.origin()).toBe('rearm_then_prefetch')
+ })
+
+ it('keeps one piece of work for a tip refresh queued before the build finished', async () => {
+ const activity = createPreparationActivity('explicit')
+ const build = Promise.withResolvers()
+ activity.track(build.promise)
+ const refresh = Promise.withResolvers()
+ const chained = build.promise.then(() => refresh.promise)
+ activity.track(chained)
+ const observer = beginWorktreeCreate()
+
+ now = 3_000
+ build.resolve()
+ await build.promise
+ now = 5_000
+ refresh.resolve()
+ await chained
+
+ // Build is the build alone; idle runs from the refreshed checkout's ready.
+ expect(activity.timesAt(9_000)).toEqual({ buildMs: 2_000, idleMs: 4_000 })
+ expect(observer.end().preparations).toBe(1)
+ })
+
+ it('keeps the build time of the first build when a later refresh runs as new work', async () => {
+ const activity = createPreparationActivity('automatic')
+ const build = Promise.withResolvers()
+ activity.track(build.promise)
+ now = 41_000
+ build.resolve()
+ await build.promise
+ const observer = beginWorktreeCreate()
+
+ now = 101_000
+ const refresh = Promise.withResolvers()
+ activity.track(refresh.promise)
+ now = 103_000
+ refresh.resolve()
+ await refresh.promise
+
+ // A 40 s build stays 40 s; idle runs from the refresh's ready, not the build's.
+ expect(activity.timesAt(113_000)).toEqual({ buildMs: 40_000, idleMs: 10_000 })
+ expect(observer.end().preparations).toBe(1)
+ })
+
+ it('reports no idle time while a refresh is still running at the claim', async () => {
+ const activity = createPreparationActivity('explicit')
+ const build = Promise.resolve()
+ activity.track(build)
+ now = 2_000
+ await build
+ const refresh = Promise.withResolvers()
+ activity.track(refresh.promise)
+ expect(activity.timesAt(5_000)).toEqual({ buildMs: 1_000, idleMs: 0 })
+ })
+
+ it('reports no idle time when the claim came before the work finished', async () => {
+ const activity = createPreparationActivity('automatic')
+ const build = Promise.withResolvers()
+ activity.track(build.promise)
+ now = 4_000
+ build.resolve()
+ await build.promise
+ expect(activity.timesAt(2_000)).toEqual({ buildMs: 3_000, idleMs: 0 })
+ })
+})
diff --git a/src/main/worktree-create-preparation-activity.ts b/src/main/worktree-create-preparation-activity.ts
new file mode 100644
index 00000000000..de17ff6cd32
--- /dev/null
+++ b/src/main/worktree-create-preparation-activity.ts
@@ -0,0 +1,80 @@
+import type { PreparedCheckoutOrigin } from '../shared/worktree/create-types'
+import { beginPreparationWork, type PreparationWork } from './worktree-create-concurrency'
+
+/** What a create's event reports about the prepared checkout it used: who asked for it, and the
+ * disk work of its build and tip refreshes, and their timing. Recording only; never gates the pool. */
+export type PreparationActivity = {
+ /** Counts the latest build or refresh as disk work competing with creates. */
+ readonly work: PreparationWork
+ /** An explicit prefetch asked for this checkout too. */
+ requestedByPrefetch(): void
+ /** Covers `ready`, the checkout's latest build or tip refresh, until it settles. */
+ track(ready: Promise): void
+ origin(): PreparedCheckoutOrigin
+ /** First build's duration, and how long the checkout sat ready before `claimedAt`. */
+ timesAt(claimedAt: number): { buildMs: number; idleMs: number }
+}
+
+export function createPreparationActivity(kind: 'explicit' | 'automatic'): PreparationActivity {
+ let work = beginPreparationWork()
+ let workSettled = false
+ // Build: from the first build's start (including any wait for the base fetch it is built on)
+ // to its first ready; a later tip refresh does not restart it.
+ const buildStartedAt = performance.now()
+ let buildFinishedAt: number | undefined
+ // Idle: measured from the latest ready, build or refresh, to the claim.
+ let latestReadyAt: number | undefined
+ let latest: Promise | undefined
+ let prefetchRequested = false
+
+ return {
+ get work() {
+ return work
+ },
+ requestedByPrefetch() {
+ prefetchRequested = true
+ },
+ track(ready) {
+ // A refresh queued after the previous work settled is new disk work.
+ if (workSettled) {
+ work = beginPreparationWork()
+ workSettled = false
+ }
+ latest = ready
+ latestReadyAt = undefined
+ const current = work
+ const settle = (succeeded: boolean): void => {
+ const now = performance.now()
+ if (succeeded) {
+ buildFinishedAt ??= now
+ }
+ // A refresh chained onto this work extends it; only the latest settle ends it.
+ if (latest !== ready) {
+ return
+ }
+ if (succeeded) {
+ latestReadyAt = now
+ }
+ workSettled = true
+ current.end()
+ }
+ void ready.then(
+ () => settle(true),
+ () => settle(false)
+ )
+ },
+ origin() {
+ if (kind === 'explicit') {
+ return 'prefetch'
+ }
+ return prefetchRequested ? 'rearm_then_prefetch' : 'rearm'
+ },
+ timesAt(claimedAt) {
+ // A create that waited for the work reports no idle time.
+ return {
+ buildMs: Math.max(0, (buildFinishedAt ?? claimedAt) - buildStartedAt),
+ idleMs: Math.max(0, claimedAt - (latestReadyAt ?? claimedAt))
+ }
+ }
+ }
+}
diff --git a/src/main/worktree-create-preparation-claim-registry.ts b/src/main/worktree-create-preparation-claim-registry.ts
new file mode 100644
index 00000000000..90067b31764
--- /dev/null
+++ b/src/main/worktree-create-preparation-claim-registry.ts
@@ -0,0 +1,78 @@
+// Creates that hold a prepared checkout, and the preparations deferred until each one releases it.
+import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim'
+import type { PreparationEntry, StartPreparationArgs } from './worktree-create-preparation-pool'
+
+export type DeferredPreparation = {
+ args: StartPreparationArgs
+ kind: 'explicit' | 'automatic'
+}
+
+export type PreparationClaim = {
+ entry: PreparationEntry
+ requestedKey: string
+ pendingPreparations: Map
+}
+
+const claims = new Set()
+
+export function registerClaim(entry: PreparationEntry, requestedKey: string): PreparationClaim {
+ const claim = { entry, requestedKey, pendingPreparations: new Map() }
+ claims.add(claim)
+ return claim
+}
+
+export function hasClaims(): boolean {
+ return claims.size > 0
+}
+
+export function isClaimed(entry: PreparationEntry): boolean {
+ return [...claims].some((claim) => claim.entry === entry)
+}
+
+function matchingClaim(args: StartPreparationArgs): PreparationClaim | undefined {
+ const key = preparationEntryKey(
+ preparationPathKey(args.repoPath),
+ preparationPathKey(args.workspaceRoot),
+ args.canonicalBase,
+ args.options.wslDistro ?? ''
+ )
+ return [...claims]
+ .toReversed()
+ .find((claim) => claim.entry.key === key || claim.requestedKey === key)
+}
+
+/** Preserve one request per canonical key, with explicit prefetch taking precedence. */
+export function deferPreparationForClaim(
+ args: StartPreparationArgs,
+ kind: DeferredPreparation['kind']
+): boolean {
+ const matching = matchingClaim(args)
+ if (!matching) {
+ return false
+ }
+ const key = preparationEntryKey(
+ preparationPathKey(args.repoPath),
+ preparationPathKey(args.workspaceRoot),
+ args.canonicalBase,
+ args.options.wslDistro ?? ''
+ )
+ if (kind === 'explicit' || !matching.pendingPreparations.has(key)) {
+ matching.pendingPreparations.set(key, { args, kind })
+ }
+ return true
+}
+
+/** A second release is inert, including after a test reset. */
+export function releasePreparationClaim(claim: PreparationClaim): {
+ released: boolean
+ pendingPreparations: DeferredPreparation[]
+} {
+ if (!claims.delete(claim)) {
+ return { released: false, pendingPreparations: [] }
+ }
+ return { released: true, pendingPreparations: [...claim.pendingPreparations.values()] }
+}
+
+export function resetClaimsForTests(): void {
+ claims.clear()
+}
diff --git a/src/main/worktree-create-preparation-pool.ts b/src/main/worktree-create-preparation-pool.ts
index 280be5af895..d84834d3ff6 100644
--- a/src/main/worktree-create-preparation-pool.ts
+++ b/src/main/worktree-create-preparation-pool.ts
@@ -12,6 +12,17 @@ import { prepareWorktreeCreateCheckout } from './git/worktree-create-preparation
import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock'
import { queuePreparedWorktreeTipRefresh } from './worktree-preparation-refresh-queue'
import { toHostFilesystemPath } from './host-tree-removal'
+import { createPreparationActivity } from './worktree-create-preparation-activity'
+import type { PreparationActivity } from './worktree-create-preparation-activity'
+import {
+ deferPreparationForClaim,
+ hasClaims,
+ isClaimed,
+ registerClaim,
+ resetClaimsForTests,
+ type DeferredPreparation,
+ type PreparationClaim
+} from './worktree-create-preparation-claim-registry'
import { preparationEntryKey, preparationPathKey } from './worktree-create-preparation-claim'
import {
startStalePreparationCleanup,
@@ -25,6 +36,12 @@ import {
trackPreparationDiscard
} from './worktree-preparation-discard-retry'
+export {
+ releasePreparationClaim,
+ type DeferredPreparation,
+ type PreparationClaim
+} from './worktree-create-preparation-claim-registry'
+
export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000
export const WORKTREE_CREATE_PREPARATION_LIMIT = 3
@@ -45,6 +62,8 @@ export type PreparationEntry = {
expiration: NodeJS.Timeout
controller: AbortController
checkoutStarted: boolean
+ /** Origin, disk work and timing reported by the create that uses it. */
+ activity: PreparationActivity
}
export type StartPreparationArgs = {
@@ -56,22 +75,11 @@ export type StartPreparationArgs = {
beforeMaterialization?: Promise
}
-export type DeferredPreparation = {
- args: StartPreparationArgs
- kind: 'explicit' | 'automatic'
-}
-
const preparations = new Map()
-export type PreparationClaim = {
- entry: PreparationEntry
- requestedKey: string
- pendingPreparations: Map
-}
-const claims = new Set()
/** A prepared checkout is a create that is either in flight or imminent. */
export function hasPendingPreparations(): boolean {
- return preparations.size > 0 || claims.size > 0 || hasPendingStalePreparationCleanup()
+ return preparations.size > 0 || hasClaims() || hasPendingStalePreparationCleanup()
}
function discardEntryInBackground(entry: PreparationEntry): void {
@@ -142,53 +150,7 @@ export function takePreparation(
requestedCanonicalBase,
entry.wslDistro
)
- const claim = { entry, requestedKey, pendingPreparations: new Map() }
- claims.add(claim)
- return claim
-}
-
-function matchingClaim(args: StartPreparationArgs): PreparationClaim | undefined {
- const key = preparationEntryKey(
- preparationPathKey(args.repoPath),
- preparationPathKey(args.workspaceRoot),
- args.canonicalBase,
- args.options.wslDistro ?? ''
- )
- return [...claims]
- .toReversed()
- .find((claim) => claim.entry.key === key || claim.requestedKey === key)
-}
-
-/** Preserve one request per canonical key, with explicit prefetch taking precedence. */
-function deferPreparationForClaim(
- args: StartPreparationArgs,
- kind: DeferredPreparation['kind']
-): boolean {
- const matching = matchingClaim(args)
- if (!matching) {
- return false
- }
- const key = preparationEntryKey(
- preparationPathKey(args.repoPath),
- preparationPathKey(args.workspaceRoot),
- args.canonicalBase,
- args.options.wslDistro ?? ''
- )
- if (kind === 'explicit' || !matching.pendingPreparations.has(key)) {
- matching.pendingPreparations.set(key, { args, kind })
- }
- return true
-}
-
-/** A second release is inert, including after a test reset. */
-export function releasePreparationClaim(claim: PreparationClaim): {
- released: boolean
- pendingPreparations: DeferredPreparation[]
-} {
- if (!claims.delete(claim)) {
- return { released: false, pendingPreparations: [] }
- }
- return { released: true, pendingPreparations: [...claim.pendingPreparations.values()] }
+ return registerClaim(entry, requestedKey)
}
export function startPreparation(
@@ -202,6 +164,9 @@ export function startPreparation(
args.options.wslDistro ?? ''
)
if (existing) {
+ if (kind === 'explicit') {
+ existing.activity.requestedByPrefetch()
+ }
return args.beforeMaterialization
? refreshPreparationTip(existing, args.beforeMaterialization)
: existing.ready
@@ -209,18 +174,18 @@ export function startPreparation(
if (deferPreparationForClaim(args, kind)) {
return Promise.resolve()
}
- return worktreePreparationGit.run(() => startBackgroundPreparation(args))
+ return worktreePreparationGit.run(() => startBackgroundPreparation({ ...args, kind }))
}
function refreshPreparationTip(
entry: PreparationEntry,
beforeMaterialization: Promise
): Promise {
- return queuePreparedWorktreeTipRefresh(
+ const ready = queuePreparedWorktreeTipRefresh(
entry,
() => {
const available = preparations.get(entry.key) === entry
- if (!available && ![...claims].some((claim) => claim.entry === entry)) {
+ if (!available && !isClaimed(entry)) {
return
}
if (available) {
@@ -231,6 +196,8 @@ function refreshPreparationTip(
},
beforeMaterialization
)
+ entry.activity.track(ready)
+ return ready
}
function startBackgroundPreparation({
@@ -239,8 +206,9 @@ function startBackgroundPreparation({
baseBranch,
canonicalBase,
options,
- beforeMaterialization
-}: StartPreparationArgs): Promise {
+ beforeMaterialization,
+ kind
+}: StartPreparationArgs & { kind: DeferredPreparation['kind'] }): Promise {
const repoPathKey = preparationPathKey(repoPath)
const workspaceRootKey = preparationPathKey(workspaceRoot)
const wslDistro = options.wslDistro ?? ''
@@ -273,6 +241,7 @@ function startBackgroundPreparation({
expiration,
controller,
checkoutStarted: false,
+ activity: createPreparationActivity(kind),
ready: Promise.resolve()
}
entry.ready = (async () => {
@@ -303,6 +272,7 @@ function startBackgroundPreparation({
}
})()
preparations.set(key, entry)
+ entry.activity.track(entry.ready)
void entry.ready.catch(() => {
if (preparations.get(key) === entry) {
preparations.delete(key)
@@ -315,7 +285,7 @@ function startBackgroundPreparation({
export async function _resetPreparationPoolForTests(): Promise {
const entries = [...preparations.values()]
preparations.clear()
- claims.clear()
+ resetClaimsForTests()
await resetStalePreparationCleanupForTests()
await Promise.all(
entries.map(async (entry) => {
diff --git a/src/main/worktree-create-preparation-reservation.ts b/src/main/worktree-create-preparation-reservation.ts
new file mode 100644
index 00000000000..137e5f31477
--- /dev/null
+++ b/src/main/worktree-create-preparation-reservation.ts
@@ -0,0 +1,96 @@
+import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types'
+import type { AddWorktreeOptions } from './git/worktree'
+import { measureRetargetDivergence } from './git/worktree-base-divergence'
+import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe'
+import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim'
+import {
+ listPreparations,
+ takePreparation,
+ type PreparationClaim,
+ type PreparationEntry
+} from './worktree-create-preparation-pool'
+
+export function canonicalBaseRef(
+ repoPath: string,
+ baseBranch: string,
+ options: AddWorktreeOptions
+): Promise {
+ return resolveLocalWorktreeBaseRef(repoPath, baseBranch, {
+ ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
+ ...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
+ })
+}
+
+export type ReservedPreparation = {
+ entry: PreparationEntry
+ reservation: PreparationClaim
+ retargeted: boolean
+ canonicalBase: string
+}
+
+/** Selection through take, so the take stays in the same synchronous run as the last selection. */
+export async function reservePreparation(
+ request: { repoPath: string; workspaceRoot: string; baseBranch: string },
+ options: AddWorktreeOptions
+): Promise<
+ | ({ status: 'reserved' } & ReservedPreparation)
+ | { status: 'miss'; reason: PreparedCheckoutMissReason }
+> {
+ const key = {
+ repoPathKey: preparationPathKey(request.repoPath),
+ workspaceRootKey: preparationPathKey(request.workspaceRoot),
+ wslDistro: options.wslDistro ?? '',
+ baseBranch: request.baseBranch
+ }
+ let selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase: null })
+ if (selection.kind === 'needs-canonical-base') {
+ // The probe is the only await here, and the pool is re-read after it, so the select-and-take
+ // below stays one synchronous run and no other create can hold the same entry.
+ const canonicalBase = await canonicalBaseRef(request.repoPath, request.baseBranch, options)
+ selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase })
+ }
+ if (selection.kind !== 'exact' && selection.kind !== 'retarget') {
+ return {
+ status: 'miss',
+ reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch'
+ }
+ }
+ if (selection.kind === 'retarget') {
+ const candidate = selection.candidate
+ const { canonicalBase } = selection
+ const divergence = await measureRetargetDivergence(
+ request.repoPath,
+ candidate.canonicalBase,
+ canonicalBase,
+ {
+ ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
+ ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}),
+ // Why forward it: a cancelled create must stop these probes now, not at the deadline.
+ ...(options.signal ? { signal: options.signal } : {})
+ }
+ )
+ if (divergence !== 'within') {
+ return {
+ status: 'miss',
+ reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable'
+ }
+ }
+ // Re-select after the walk: the pool may have gained an exact match or lost this entry. A
+ // different retarget candidate is left for the next create rather than claimed unverified.
+ selection = selectPreparationForCreate(listPreparations(), { ...key, canonicalBase })
+ if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') {
+ return { status: 'miss', reason: 'base_mismatch' }
+ }
+ if (selection.kind === 'retarget' && selection.candidate !== candidate) {
+ return { status: 'miss', reason: 'base_mismatch' }
+ }
+ }
+ const entry = selection.candidate
+ return {
+ status: 'reserved',
+ entry,
+ reservation: takePreparation(entry, selection.canonicalBase),
+ retargeted: selection.kind === 'retarget',
+ canonicalBase: selection.canonicalBase
+ }
+}
diff --git a/src/main/worktree-create-preparation-timing.test.ts b/src/main/worktree-create-preparation-timing.test.ts
index bd78f506201..3de0e64ceab 100644
--- a/src/main/worktree-create-preparation-timing.test.ts
+++ b/src/main/worktree-create-preparation-timing.test.ts
@@ -6,7 +6,8 @@ const mocks = vi.hoisted(() => ({
prepare: vi.fn(),
finalize: vi.fn(),
discard: vi.fn(),
- listWorktreeGraph: vi.fn()
+ listWorktreeGraph: vi.fn(),
+ resolveBaseRef: vi.fn()
}))
vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir }))
@@ -17,7 +18,9 @@ vi.mock('./git/worktree-create-preparation', () => ({
discardPreparedWorktree: mocks.discard,
unlockPreparedWorktree: vi.fn()
}))
-vi.mock('./git/worktree-base-ref-probe', () => ({ resolveLocalWorktreeBaseRef: vi.fn() }))
+vi.mock('./git/worktree-base-ref-probe', () => ({
+ resolveLocalWorktreeBaseRef: mocks.resolveBaseRef
+}))
vi.mock('./git/worktree-base-divergence', () => ({ measureRetargetDivergence: vi.fn() }))
vi.mock('./project-runtime-git-options', () => ({
getLocalProjectWorktreeGitOptions: vi.fn(),
@@ -34,6 +37,10 @@ import {
} from './worktree-create-preparation'
import { startPreparation } from './worktree-create-preparation-pool'
import { createWorktreeCreateTimingRecorder } from './worktree-create-timing'
+import {
+ _resetWorktreeCreateConcurrencyForTests,
+ beginWorktreeCreate
+} from './worktree-create-concurrency'
const request = {
repoPath: '/repo',
@@ -53,16 +60,23 @@ function prepare() {
})
}
+/** Lets the claim's own phase settle before the test clock moves on to the wait. */
+function settleClaim(): Promise {
+ return new Promise((resolve) => setImmediate(resolve))
+}
+
beforeEach(() => {
mocks.mkdir.mockReset().mockResolvedValue(undefined)
mocks.prepare.mockReset().mockResolvedValue(undefined)
mocks.finalize.mockReset().mockResolvedValue({})
mocks.discard.mockReset().mockResolvedValue(undefined)
mocks.listWorktreeGraph.mockReset().mockResolvedValue([])
+ mocks.resolveBaseRef.mockReset()
})
afterEach(async () => {
await _resetWorktreeCreatePreparationsForTests()
+ _resetWorktreeCreateConcurrencyForTests()
vi.restoreAllMocks()
})
@@ -88,17 +102,26 @@ describe('prepared checkout create timing', () => {
const create = timing.time('git_worktree_add', () =>
consumePreparedWorktreeCreate({ ...request, timing })
)
+ await settleClaim()
now = 150
checkout.resolve()
await finalizeStarted.promise
now = 180
finalize.resolve({})
- expect(await create).toMatchObject({ status: 'hit', retargeted: false })
+ expect(await create).toMatchObject({ status: 'hit', retargeted: false, reset: 'none' })
await preparation
expect(timing.finish()).toEqual({
totalDurationMs: 140,
+ preparedCheckout: {
+ status: 'hit',
+ reset: 'none',
+ origin: 'prefetch',
+ buildMs: expect.any(Number),
+ idleMs: 0
+ },
phases: [
+ { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 },
{ phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 110 },
{ phase: 'prepared_checkout_finalize', startedAtMs: 110, durationMs: 30 },
{ phase: 'git_worktree_add', startedAtMs: 0, durationMs: 140 }
@@ -118,6 +141,7 @@ describe('prepared checkout create timing', () => {
let now = 0
const timing = createWorktreeCreateTimingRecorder(() => now)
const create = consumePreparedWorktreeCreate({ ...request, timing })
+ await settleClaim()
now = 250
checkout.reject(new Error('checkout failed'))
@@ -128,12 +152,13 @@ describe('prepared checkout create timing', () => {
})
await preparation
expect(timing.finish().phases).toEqual([
+ { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 },
{ phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 250 }
])
expect(mocks.finalize).not.toHaveBeenCalled()
})
- it('records a failed finalization before its fallback cleanup', async () => {
+ it('times the cleanup of a failed finalization apart from the finalization', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
await prepare()
let now = 0
@@ -152,17 +177,152 @@ describe('prepared checkout create timing', () => {
rearm: expect.any(Function)
})
expect(timing.finish().phases).toEqual([
+ { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 },
{ phase: 'prepared_checkout_wait', startedAtMs: 0, durationMs: 0 },
- { phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 }
+ { phase: 'prepared_checkout_finalize', startedAtMs: 0, durationMs: 80 },
+ { phase: 'prepared_checkout_discard', startedAtMs: 80, durationMs: 20 }
])
})
- it('does not report a preparation wait when no checkout was armed', async () => {
+ it('reports only the claim, and the miss, when no checkout was armed', async () => {
const timing = createWorktreeCreateTimingRecorder(() => 0)
expect(await consumePreparedWorktreeCreate({ ...request, timing })).toEqual({
status: 'miss',
reason: 'none_armed'
})
- expect(timing.finish().phases).toEqual([])
+ expect(timing.finish()).toMatchObject({
+ phases: [{ phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 0 }],
+ preparedCheckout: { status: 'miss', reason: 'none_armed' }
+ })
+ })
+
+ it('times a miss probe as the claim, not as the plain add after it', async () => {
+ await startPreparation({
+ repoPath: request.repoPath,
+ workspaceRoot: request.workspaceRoot,
+ baseBranch: 'origin/release',
+ canonicalBase: 'refs/remotes/origin/release',
+ options: {}
+ })
+ let now = 0
+ const timing = createWorktreeCreateTimingRecorder(() => now)
+ mocks.resolveBaseRef.mockImplementation(async () => {
+ now = 400
+ return 'refs/remotes/origin/main'
+ })
+
+ const attempt = await timing.time('git_worktree_add', async () => {
+ const result = await consumePreparedWorktreeCreate({ ...request, timing })
+ now = 500
+ return result
+ })
+
+ expect(attempt).toMatchObject({ status: 'miss', reason: 'base_mismatch' })
+ expect(timing.finish().phases).toEqual([
+ { phase: 'prepared_checkout_claim', startedAtMs: 0, durationMs: 400 },
+ { phase: 'git_worktree_add', startedAtMs: 0, durationMs: 500 }
+ ])
+ })
+
+ it('reports the reset a hit needed and who armed it', async () => {
+ await startPreparation(
+ {
+ repoPath: request.repoPath,
+ workspaceRoot: request.workspaceRoot,
+ baseBranch: request.baseBranch,
+ canonicalBase: 'refs/remotes/origin/main',
+ options: {}
+ },
+ 'automatic'
+ )
+ mocks.finalize.mockResolvedValue({ preparedHeadReset: true })
+ const timing = createWorktreeCreateTimingRecorder(() => 0)
+
+ const attempt = await consumePreparedWorktreeCreate({ ...request, timing })
+
+ expect(attempt).toMatchObject({ status: 'hit', reset: 'base_moved', origin: 'rearm' })
+ expect(attempt.status === 'hit' && attempt.result).toEqual({})
+ expect(timing.finish().preparedCheckout).toMatchObject({
+ status: 'hit',
+ reset: 'base_moved',
+ origin: 'rearm'
+ })
+ })
+
+ it('reports a re-arm the new-worktree UI then asked for as well', async () => {
+ const args = {
+ repoPath: request.repoPath,
+ workspaceRoot: request.workspaceRoot,
+ baseBranch: request.baseBranch,
+ canonicalBase: 'refs/remotes/origin/main',
+ options: {}
+ }
+ await startPreparation(args, 'automatic')
+ await startPreparation(args, 'explicit')
+ expect(mocks.prepare).toHaveBeenCalledOnce()
+
+ const attempt = await consumePreparedWorktreeCreate({ ...request })
+
+ expect(attempt).toMatchObject({ status: 'hit', origin: 'rearm_then_prefetch' })
+ })
+
+ it('reports how long the build took and how long it sat ready before the claim', async () => {
+ let now = 1_000
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+ const checkout = Promise.withResolvers()
+ mocks.prepare.mockReturnValue(checkout.promise)
+ const preparation = prepare()
+ now = 4_000
+ checkout.resolve()
+ await preparation
+ now = 9_000
+
+ const attempt = await consumePreparedWorktreeCreate({ ...request })
+
+ expect(attempt).toMatchObject({ status: 'hit', buildMs: 3_000, idleMs: 5_000 })
+ })
+
+ it('reports no idle time when the create waited for the build', async () => {
+ let now = 1_000
+ vi.spyOn(performance, 'now').mockImplementation(() => now)
+ const checkoutStarted = Promise.withResolvers()
+ const checkout = Promise.withResolvers()
+ mocks.prepare.mockImplementation(() => {
+ checkoutStarted.resolve()
+ return checkout.promise
+ })
+ const preparation = prepare()
+ await checkoutStarted.promise
+ now = 2_000
+ const create = consumePreparedWorktreeCreate({ ...request })
+ await settleClaim()
+ now = 6_000
+ checkout.resolve()
+ await preparation
+
+ expect(await create).toMatchObject({ status: 'hit', buildMs: 5_000, idleMs: 0 })
+ })
+
+ it('counts a building prepared checkout against other creates but not the one that used it', async () => {
+ const checkoutStarted = Promise.withResolvers()
+ const checkout = Promise.withResolvers()
+ mocks.prepare.mockImplementation(() => {
+ checkoutStarted.resolve()
+ return checkout.promise
+ })
+ const bystander = beginWorktreeCreate()
+ const preparation = prepare()
+ await checkoutStarted.promise
+ const inFlight = beginWorktreeCreate()
+ const timing = createWorktreeCreateTimingRecorder(undefined, inFlight)
+
+ const create = consumePreparedWorktreeCreate({ ...request, timing })
+ await settleClaim()
+ checkout.resolve()
+ await preparation
+
+ expect(await create).toMatchObject({ status: 'hit', origin: 'prefetch' })
+ expect(inFlight.end().preparations).toBe(0)
+ expect(bystander.end().preparations).toBe(1)
})
})
diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts
index 052181da228..cff625c14b1 100644
--- a/src/main/worktree-create-preparation.test.ts
+++ b/src/main/worktree-create-preparation.test.ts
@@ -131,6 +131,11 @@ describe('worktree create preparation registry', () => {
).resolves.toEqual({
status: 'hit',
retargeted: true,
+ // The mocked finalize found the checkout already at the requested commit.
+ reset: 'none',
+ buildMs: expect.any(Number),
+ idleMs: expect.any(Number),
+ origin: 'prefetch',
result: {},
rearm: expect.any(Function)
})
@@ -213,6 +218,10 @@ describe('worktree create preparation registry', () => {
).resolves.toEqual({
status: 'hit',
retargeted: false,
+ reset: 'none',
+ buildMs: expect.any(Number),
+ idleMs: expect.any(Number),
+ origin: 'prefetch',
result: {},
rearm: expect.any(Function)
})
@@ -543,6 +552,11 @@ describe('worktree create preparation registry', () => {
).resolves.toEqual({
status: 'hit',
retargeted: false,
+ reset: 'none',
+ buildMs: expect.any(Number),
+ idleMs: expect.any(Number),
+ // Built by the burst re-arm after the second create; the prefetch after it asked for it too.
+ origin: 'rearm_then_prefetch',
result: {},
rearm: expect.any(Function)
})
diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts
index 0bca1160d8e..5ca98be283b 100644
--- a/src/main/worktree-create-preparation.ts
+++ b/src/main/worktree-create-preparation.ts
@@ -5,23 +5,28 @@ import type { Store } from './persistence'
import type { Repo } from '../shared/repo-types'
import { isFolderRepo } from '../shared/repo-kind'
import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path'
-import type { PreparedCheckoutMissReason } from '../shared/worktree/create-types'
+import type {
+ PreparedCheckoutMissReason,
+ PreparedCheckoutOrigin,
+ PreparedCheckoutReset
+} from '../shared/worktree/create-types'
+import type { WorktreeCreatePhase } from '../shared/worktree/create-timing-vocabulary'
import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree'
-import { measureRetargetDivergence } from './git/worktree-base-divergence'
-import { resolveLocalWorktreeBaseRef } from './git/worktree-base-ref-probe'
import { WorktreePreparationLockOwnershipError } from './git/worktree-preparation-lock'
-import { preparationPathKey, selectPreparationForCreate } from './worktree-create-preparation-claim'
import {
_resetPreparationPoolForTests,
hasPendingPreparations,
- listPreparations,
releasePreparationClaim,
startPreparation,
- takePreparation,
type DeferredPreparation,
type PreparationClaim,
type PreparationEntry
} from './worktree-create-preparation-pool'
+import {
+ canonicalBaseRef,
+ reservePreparation,
+ type ReservedPreparation
+} from './worktree-create-preparation-reservation'
import {
discardPreparedWorktree,
finalizePreparedWorktree
@@ -56,6 +61,10 @@ export type PreparedWorktreeCreateAttempt =
| {
status: 'hit'
retargeted: boolean
+ reset: PreparedCheckoutReset
+ origin: PreparedCheckoutOrigin
+ buildMs: number
+ idleMs: number
result: AddWorktreeResult
/** Run after materialization/startup completes, before returning the create result. */
rearm: () => void
@@ -70,18 +79,18 @@ type ConsumePreparedWorktreeArgs = {
baseBranch: string
refreshLocalBaseRef?: boolean
options?: AddWorktreeOptions
- timing?: Pick
+ timing?: Pick<
+ WorktreeCreateTimingRecorder,
+ 'time' | 'recordPreparedCheckout' | 'recordAdoptedPreparation'
+ >
}
-function canonicalBaseRef(
- repoPath: string,
- baseBranch: string,
- options: AddWorktreeOptions
-): Promise {
- return resolveLocalWorktreeBaseRef(repoPath, baseBranch, {
- ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
- ...(options.admissionTier ? { admissionTier: options.admissionTier } : {})
- })
+function timePhase(
+ args: ConsumePreparedWorktreeArgs,
+ phase: WorktreeCreatePhase,
+ operation: () => Promise
+): Promise {
+ return args.timing ? args.timing.time(phase, operation) : operation()
}
export function prepareWorktreeCreateForRepo(
@@ -125,84 +134,26 @@ async function prepareWorktreeCreateInBackground(
}
type ClaimedPreparation =
- | {
- status: 'claimed'
- entry: PreparationEntry
- reservation: PreparationClaim
- retargeted: boolean
- canonicalBase: string
- }
+ | ({ status: 'claimed'; claimedAt: number } & ReservedPreparation)
| { status: 'miss'; reason: PreparedCheckoutMissReason; rearm?: () => void }
async function claimPreparedWorktree(
args: ConsumePreparedWorktreeArgs,
options: AddWorktreeOptions
): Promise {
- const request = {
- repoPathKey: preparationPathKey(args.repoPath),
- workspaceRootKey: preparationPathKey(args.workspaceRoot),
- wslDistro: options.wslDistro ?? '',
- baseBranch: args.baseBranch
+ // Timed on its own so a miss's probes and drift check are not read as the plain add's cost.
+ const reserved = await timePhase(args, 'prepared_checkout_claim', () =>
+ reservePreparation(args, options)
+ )
+ if (reserved.status === 'miss') {
+ return reserved
}
- let selection = selectPreparationForCreate(listPreparations(), {
- ...request,
- canonicalBase: null
- })
- if (selection.kind === 'needs-canonical-base') {
- // The probe is the only await here, and the pool is re-read after it, so the select-and-take
- // below stays one synchronous run and no other create can hold the same entry.
- const canonicalBase = await canonicalBaseRef(args.repoPath, args.baseBranch, options)
- selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase })
- }
- if (selection.kind !== 'exact' && selection.kind !== 'retarget') {
- return {
- status: 'miss',
- reason: selection.kind === 'miss' ? selection.reason : 'base_mismatch'
- }
- }
- if (selection.kind === 'retarget') {
- const candidate = selection.candidate
- const { canonicalBase } = selection
- const divergence = await measureRetargetDivergence(
- args.repoPath,
- candidate.canonicalBase,
- canonicalBase,
- {
- ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}),
- ...(options.admissionTier ? { admissionTier: options.admissionTier } : {}),
- // Why forward it: a cancelled create must stop these probes now, not at the deadline.
- ...(options.signal ? { signal: options.signal } : {})
- }
- )
- if (divergence !== 'within') {
- return {
- status: 'miss',
- reason: divergence === 'exceeded' ? 'retarget_too_divergent' : 'retarget_unverifiable'
- }
- }
- // Re-select after the walk: the pool may have gained an exact match or lost this entry. A
- // different retarget candidate is left for the next create rather than claimed unverified.
- selection = selectPreparationForCreate(listPreparations(), { ...request, canonicalBase })
- if (selection.kind === 'miss' || selection.kind === 'needs-canonical-base') {
- return { status: 'miss', reason: 'base_mismatch' }
- }
- if (selection.kind === 'retarget' && selection.candidate !== candidate) {
- return { status: 'miss', reason: 'base_mismatch' }
- }
- }
- const entry = selection.candidate
- const reservation = takePreparation(entry, selection.canonicalBase)
+ const { entry, reservation } = reserved
+ args.timing?.recordAdoptedPreparation(entry.activity.work)
+ const claimedAt = performance.now()
try {
- await (args.timing
- ? args.timing.time('prepared_checkout_wait', () => entry.ready)
- : entry.ready)
- return {
- status: 'claimed',
- entry,
- reservation,
- retargeted: selection.kind === 'retarget',
- canonicalBase: selection.canonicalBase
- }
+ await timePhase(args, 'prepared_checkout_wait', () => entry.ready)
+ return { ...reserved, status: 'claimed', claimedAt }
} catch {
return { status: 'miss', reason: 'prepare_failed', rearm: releaseClaimAfterCreate(reservation) }
}
@@ -267,6 +218,31 @@ function deferRearmPreparation(
export async function consumePreparedWorktreeCreate(
args: ConsumePreparedWorktreeArgs
+): Promise {
+ const attempt = await attemptPreparedWorktreeCreate(args)
+ args.timing?.recordPreparedCheckout(
+ attempt.status === 'hit'
+ ? {
+ status: 'hit',
+ reset: attempt.reset,
+ origin: attempt.origin,
+ buildMs: attempt.buildMs,
+ idleMs: attempt.idleMs
+ }
+ : { status: 'miss', reason: attempt.reason }
+ )
+ return attempt
+}
+
+function preparedCheckoutReset(retargeted: boolean, headReset: boolean): PreparedCheckoutReset {
+ if (!headReset) {
+ return 'none'
+ }
+ return retargeted ? 'retargeted' : 'base_moved'
+}
+
+async function attemptPreparedWorktreeCreate(
+ args: ConsumePreparedWorktreeArgs
): Promise {
const options = args.options ?? {}
const claim = await claimPreparedWorktree(args, options)
@@ -275,37 +251,45 @@ export async function consumePreparedWorktreeCreate(
}
const { entry, reservation } = claim
try {
- const parentDir = isWindowsAbsolutePathLike(args.worktreePath)
- ? win32.dirname(args.worktreePath)
- : posix.dirname(args.worktreePath)
- await mkdir(toHostFilesystemPath(parentDir), { recursive: true })
// Finalize resolves the requested base itself and resets the prepared checkout onto that
// commit, so a retargeted claim is handed over at the requested commit or not at all.
- const finalize = (): Promise =>
- finalizePreparedWorktree(
- args.repoPath,
- entry.preparedPath,
- args.worktreePath,
- args.branch,
- args.baseBranch,
- args.refreshLocalBaseRef,
- options,
- entry.lockReason
- )
- const result = args.timing
- ? await args.timing.time('prepared_checkout_finalize', finalize)
- : await finalize()
+ const { preparedHeadReset, ...result } = await timePhase(
+ args,
+ 'prepared_checkout_finalize',
+ async () => {
+ const parentDir = isWindowsAbsolutePathLike(args.worktreePath)
+ ? win32.dirname(args.worktreePath)
+ : posix.dirname(args.worktreePath)
+ await mkdir(toHostFilesystemPath(parentDir), { recursive: true })
+ return finalizePreparedWorktree(
+ args.repoPath,
+ entry.preparedPath,
+ args.worktreePath,
+ args.branch,
+ args.baseBranch,
+ args.refreshLocalBaseRef,
+ options,
+ entry.lockReason
+ )
+ }
+ )
// Consuming the only prepared checkout leaves the next create cold. Re-arm for a user who is
// creating in a burst; the TTL and the preparation limit still bound an unused replacement.
const rearm = deferRearmPreparation(entry, reservation, args.baseBranch, claim.canonicalBase)
- return { status: 'hit', retargeted: claim.retargeted, result, rearm }
+ return {
+ status: 'hit',
+ retargeted: claim.retargeted,
+ reset: preparedCheckoutReset(claim.retargeted, preparedHeadReset),
+ origin: entry.activity.origin(),
+ ...entry.activity.timesAt(claim.claimedAt),
+ result,
+ rearm
+ }
} catch (error) {
+ // Another owner holds the preparation's lock, so it is not ours to remove.
if (!(error instanceof WorktreePreparationLockOwnershipError)) {
- await discardPreparedWorktree(
- args.repoPath,
- entry.preparedPath,
- options,
- entry.lockReason
+ await timePhase(args, 'prepared_checkout_discard', () =>
+ discardPreparedWorktree(args.repoPath, entry.preparedPath, options, entry.lockReason)
).catch(() => {})
}
console.warn(
diff --git a/src/main/worktree-create-timing.test.ts b/src/main/worktree-create-timing.test.ts
index 6ef4d23ff42..5fbb0663f4e 100644
--- a/src/main/worktree-create-timing.test.ts
+++ b/src/main/worktree-create-timing.test.ts
@@ -1,12 +1,17 @@
-import { describe, expect, it } from 'vitest'
+import { afterEach, describe, expect, it } from 'vitest'
import { createWorktreeCreateTimingRecorder } from './worktree-create-timing'
+import {
+ _resetWorktreeCreateConcurrencyForTests,
+ beginPreparationWork,
+ beginWorktreeCreate
+} from './worktree-create-concurrency'
describe('createWorktreeCreateTimingRecorder', () => {
it('records ordered phase timings and total duration', async () => {
const samples = [100, 105, 112, 130, 144, 155]
const recorder = createWorktreeCreateTimingRecorder(() => samples.shift() ?? 155)
- const syncResult = recorder.timeSync('resolve_branch', () => 'branch')
+ const syncResult = recorder.timeSync('resolve_name', () => 'branch')
const asyncResult = await recorder.time('git_worktree_add', async () => 'created')
expect(syncResult).toBe('branch')
@@ -14,9 +19,166 @@ describe('createWorktreeCreateTimingRecorder', () => {
expect(recorder.finish()).toEqual({
totalDurationMs: 55,
phases: [
- { phase: 'resolve_branch', startedAtMs: 5, durationMs: 7 },
+ { phase: 'resolve_name', startedAtMs: 5, durationMs: 7 },
{ phase: 'git_worktree_add', startedAtMs: 30, durationMs: 14 }
]
})
})
+
+ it('carries the execution host and worktree count only once recorded', () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ expect(recorder.finish()).not.toHaveProperty('executionHost')
+ expect(recorder.finish()).not.toHaveProperty('worktreeCount')
+
+ recorder.recordExecutionHost('local')
+ recorder.recordExecutionHost('wsl')
+ recorder.recordWorktreeCount(0)
+
+ expect(recorder.finish()).toMatchObject({ executionHost: 'wsl', worktreeCount: 0 })
+ })
+
+ describe('failedPhase', () => {
+ function captureRejection(promise: Promise): Promise {
+ return promise.then(
+ () => {
+ throw new Error('expected a rejection')
+ },
+ (error: unknown) => error
+ )
+ }
+
+ it('names the phase whose operation threw', async () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ await recorder.time('resolve_name', async () => undefined)
+ const error = await captureRejection(
+ recorder.time('git_worktree_add', async () => {
+ throw new Error('boom')
+ })
+ )
+
+ expect(recorder.failedPhase(error)).toBe('git_worktree_add')
+ // The phase that threw is still timed.
+ expect(recorder.finish().phases.map((phase) => phase.phase)).toEqual([
+ 'resolve_name',
+ 'git_worktree_add'
+ ])
+ })
+
+ it('is undefined for an error no phase threw', async () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ await recorder.time('git_worktree_add', async () => undefined)
+ expect(recorder.failedPhase(new Error('outside'))).toBeUndefined()
+ expect(recorder.failedPhase('not an object')).toBeUndefined()
+ })
+
+ it('names the outermost phase an error propagated through', async () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ const error = await captureRejection(
+ recorder.time('git_worktree_add', () =>
+ recorder.time('prepared_checkout_wait', async () => {
+ throw new Error('prepare failed')
+ })
+ )
+ )
+
+ expect(recorder.failedPhase(error)).toBe('git_worktree_add')
+ })
+
+ it('reports untimed for a later throw after an inner failure was caught', async () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ await recorder.time('git_worktree_add', async () => {
+ await recorder
+ .time('prepared_checkout_wait', async () => {
+ throw new Error('prepare failed')
+ })
+ .catch(() => undefined)
+ })
+
+ expect(recorder.failedPhase(new Error('later, outside every phase'))).toBeUndefined()
+ })
+
+ it('names the rejecting phase when a concurrent sibling settles later', async () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ let resolveSibling: () => void = () => {}
+ const sibling = recorder.time(
+ 'resolve_worktreeinclude',
+ () =>
+ new Promise((resolve) => {
+ resolveSibling = resolve
+ })
+ )
+ const error = await captureRejection(
+ Promise.all([
+ recorder.time('resolve_shared_directories', async () => {
+ throw new Error('shared dirs failed')
+ }),
+ sibling
+ ])
+ )
+ resolveSibling()
+ await sibling
+
+ expect(recorder.failedPhase(error)).toBe('resolve_shared_directories')
+ })
+
+ it('follows the cause chain of an error wrapped outside the phase', async () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ const inner = await captureRejection(
+ recorder.time('git_worktree_add', async () => {
+ throw new Error('old relay')
+ })
+ )
+
+ expect(recorder.failedPhase(new Error('wrapped', { cause: inner }))).toBe('git_worktree_add')
+ })
+
+ it('stops walking a cyclic cause chain', () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ const first = new Error('first')
+ const second = new Error('second', { cause: first })
+ first.cause = second
+
+ expect(recorder.failedPhase(first)).toBeUndefined()
+ })
+
+ it('names a sync phase that threw', () => {
+ const recorder = createWorktreeCreateTimingRecorder(() => 0)
+ let error: unknown
+ try {
+ recorder.timeSync('persist_metadata', () => {
+ throw new Error('disk full')
+ })
+ } catch (caught) {
+ error = caught
+ }
+
+ expect(recorder.failedPhase(error)).toBe('persist_metadata')
+ })
+ })
+
+ describe('concurrency window', () => {
+ afterEach(() => {
+ _resetWorktreeCreateConcurrencyForTests()
+ })
+
+ it('closes at finish, so work the create starts afterwards is not counted', () => {
+ const inFlight = beginWorktreeCreate()
+ const recorder = createWorktreeCreateTimingRecorder(() => 0, inFlight)
+ const during = beginPreparationWork()
+ during.end()
+ recorder.finish()
+ beginPreparationWork()
+ beginPreparationWork()
+ expect(inFlight.end().preparations).toBe(1)
+ })
+
+ it('leaves out the prepared checkout the create adopted', () => {
+ const adopted = beginPreparationWork()
+ const inFlight = beginWorktreeCreate()
+ const recorder = createWorktreeCreateTimingRecorder(() => 0, inFlight)
+ recorder.recordAdoptedPreparation(adopted)
+ recorder.finish()
+ expect(inFlight.end().preparations).toBe(0)
+ })
+ })
})
diff --git a/src/main/worktree-create-timing.ts b/src/main/worktree-create-timing.ts
index bc1e49f4842..d04fb6640e8 100644
--- a/src/main/worktree-create-timing.ts
+++ b/src/main/worktree-create-timing.ts
@@ -1,18 +1,41 @@
import type {
PreparedCheckoutOutcome,
+ WorktreeCreateExecutionHost,
WorktreeCreateTiming,
WorktreeCreateTimingPhase
} from '../shared/worktree/create-types'
+import type { WorktreeCreatePhase } from '../shared/worktree/create-timing-vocabulary'
+import type { PreparationWork, WorktreeCreateInFlightHandle } from './worktree-create-concurrency'
+import { wslDistroForCommand } from './git/command-runner/git-command-resolution'
type TimingClock = () => number
+const MAX_CAUSE_DEPTH = 5
+
export type WorktreeCreateTimingRecorder = {
- time(phase: string, operation: () => Promise): Promise
- timeSync(phase: string, operation: () => T): T
+ time(phase: WorktreeCreatePhase, operation: () => Promise): Promise
+ timeSync(phase: WorktreeCreatePhase, operation: () => T): T
recordPreparedCheckout(outcome: PreparedCheckoutOutcome): void
+ /** The prepared checkout this create claimed, so its build is not counted as competing work. */
+ recordAdoptedPreparation(work: PreparationWork): void
+ recordExecutionHost(host: WorktreeCreateExecutionHost): void
+ recordWorktreeCount(count: number): void
+ /** The outermost phase this error (or one in its cause chain) propagated out of; undefined when none did. */
+ failedPhase(error: unknown): WorktreeCreatePhase | undefined
+ /** Also closes the concurrency window, so work this create starts afterwards (its own re-arm)
+ * is not counted against it. */
finish(): WorktreeCreateTiming
}
+/** A local repo's create host by the Git routing rule: a \\wsl.localhost repo runs Git in WSL even
+ * without a WSL project runtime. */
+export function localWorktreeCreateExecutionHost(gitExecOptions: {
+ cwd?: string
+ wslDistro?: string
+}): WorktreeCreateExecutionHost {
+ return wslDistroForCommand(gitExecOptions.cwd, gitExecOptions.wslDistro) ? 'wsl' : 'local'
+}
+
function defaultClock(): number {
return performance.now()
}
@@ -22,7 +45,7 @@ function clampDuration(value: number): number {
}
function createPhase(
- phase: string,
+ phase: WorktreeCreatePhase,
operationStartedAt: number,
operationEndedAt: number,
rootStartedAt: number
@@ -35,29 +58,46 @@ function createPhase(
}
export function createWorktreeCreateTimingRecorder(
- clock: TimingClock = defaultClock
+ clock: TimingClock = defaultClock,
+ inFlight?: WorktreeCreateInFlightHandle
): WorktreeCreateTimingRecorder {
const startedAt = clock()
const phases: WorktreeCreateTimingPhase[] = []
let preparedCheckout: PreparedCheckoutOutcome | undefined
+ let executionHost: WorktreeCreateExecutionHost | undefined
+ let worktreeCount: number | undefined
+ // Keyed by the thrown value, so a caught failure or a concurrent sibling cannot be misattributed.
+ const phaseByError = new WeakMap