diff --git a/src/main/ipc/worktrees/listing/detected-provider-listing.ts b/src/main/ipc/worktrees/listing/detected-provider-listing.ts index 1f92ff6b981..3252b707663 100644 --- a/src/main/ipc/worktrees/listing/detected-provider-listing.ts +++ b/src/main/ipc/worktrees/listing/detected-provider-listing.ts @@ -41,6 +41,7 @@ import { import { readAllWorktreeMetaForRepo } from '../../../persistence/host-qualified-worktree-meta' import { classifyWorktreeScanFailure } from '../../../../shared/worktree-scan-failure' import { scanUntilNotOvertaken } from './overtaken-scan-rerun' +import { withoutInFlightWorktreeCreates } from '../../../worktree-create-in-flight' export async function listDetectedWorktreesForCapturedRepo( store: Store, @@ -123,7 +124,10 @@ export async function listDetectedWorktreesForCapturedRepo( return abortedResult() ?? null } const { gitWorktrees, fresh: freshScan, sideEffectToken, metadataPrune, hygieneDue } = scan - const localRows = await withUnregisteredRemovalCheckouts(repo.id, gitWorktrees) + const localRows = await withUnregisteredRemovalCheckouts( + repo.id, + withoutInFlightWorktreeCreates(repo.id, gitWorktrees) + ) const aborted = abortedResult() if (aborted) { return aborted diff --git a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts index 2c24d9c1526..f2ba076e8d7 100644 --- a/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts +++ b/src/main/ipc/worktrees/listing/register-worktree-catalog-handlers.ts @@ -33,6 +33,7 @@ import { withUnregisteredRemovalCheckouts, type PendingWorktreeRemovals } from '../../../worktree-removal-listing' +import { withoutInFlightWorktreeCreates } from '../../../worktree-create-in-flight' const WORKTREE_LIST_ALL_CONCURRENCY = 8 @@ -128,7 +129,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = metadataForRepo(repo) - const rows = await withUnregisteredRemovalCheckouts(repo.id, scan.gitWorktrees) + const rows = await withUnregisteredRemovalCheckouts( + repo.id, + withoutInFlightWorktreeCreates(repo.id, scan.gitWorktrees) + ) const worktrees = buildDetectedGitWorktrees(store, repo, rows, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) @@ -196,7 +200,10 @@ export function registerWorktreeCatalogHandlers(context: WorktreeIpcContext): vo } loggedWorktreeListFailures.delete(`${repo.id}:${repo.path}`) const metadata = allMeta ?? readAllWorktreeMetaForRepo(store, repo) - const rows = await withUnregisteredRemovalCheckouts(repo.id, scan.gitWorktrees) + const rows = await withUnregisteredRemovalCheckouts( + repo.id, + withoutInFlightWorktreeCreates(repo.id, scan.gitWorktrees) + ) const worktrees = buildDetectedGitWorktrees(store, repo, rows, metadata) .filter((worktree) => worktree.visible) .map((worktree) => stampAndMergeVisibleDetectedWorktree(store, repo, worktree, metadata)) diff --git a/src/main/runtime/runtime-local-worktree-create.test.ts b/src/main/runtime/runtime-local-worktree-create.test.ts index bd3b56d2ee1..0420e137e79 100644 --- a/src/main/runtime/runtime-local-worktree-create.test.ts +++ b/src/main/runtime/runtime-local-worktree-create.test.ts @@ -92,6 +92,7 @@ import { type WorktreeCreateTimingRecorder } from '../worktree-create-timing' import type { SparsePreset } from '../../shared/worktree/create-types' +import { withoutInFlightWorktreeCreates } from '../worktree-create-in-flight' const worktreePath = resolve('/worktrees', 'app') @@ -184,6 +185,29 @@ beforeEach(() => { readSparsePresets.mockReturnValue([WEB_PRESET]) }) +describe('runtime create listing hold', () => { + const rows = [{ path: worktreePath }] + + it('holds the checkout during Git creation and releases it once metadata is saved', async () => { + mocks.consume.mockImplementation(async () => { + expect(withoutInFlightWorktreeCreates('repo-1', rows)).toEqual([]) + return { status: 'hit', result: {}, rearm: mocks.rearm } + }) + mocks.resolveShared.mockImplementation(async () => { + expect(withoutInFlightWorktreeCreates('repo-1', rows)).toBe(rows) + return [] + }) + await createWorktree() + expect(mocks.resolveShared).toHaveBeenCalledOnce() + }) + + it('releases the checkout when Git creation fails', async () => { + mocks.consume.mockRejectedValue(new Error('checkout failed')) + await expect(createWorktree()).rejects.toThrow('checkout failed') + expect(withoutInFlightWorktreeCreates('repo-1', rows)).toBe(rows) + }) +}) + describe('runtime nested-workspaces layout', () => { const addedPath = (): string => String(mocks.add.mock.calls[0]?.[1]) diff --git a/src/main/runtime/runtime-local-worktree-create.ts b/src/main/runtime/runtime-local-worktree-create.ts index d96d2dc5b95..1a4226742cc 100644 --- a/src/main/runtime/runtime-local-worktree-create.ts +++ b/src/main/runtime/runtime-local-worktree-create.ts @@ -25,6 +25,7 @@ import { } from './runtime-local-git-worktree-create' import { resolveRuntimeLocalWorktreeCreateBase } from './runtime-local-worktree-create-base' import { materializeRuntimeLocalWorktree } from './runtime-local-worktree-materialization' +import { holdInFlightWorktreeCreate } from '../worktree-create-in-flight' import { resolveRuntimeSetupDecision } from './runtime-local-worktree-setup' import { WorktreeCreateSetupChoiceRefusedError } from './worktree-create-setup-choice-refusal' import type { PreparationRearmHolder } from '../worktree-create-preparation' @@ -160,48 +161,60 @@ async function performRuntimeLocalWorktreeCreate(args: RuntimeLocalWorktreeCr hostedReviewExecutionContext: args.hostedReviewExecutionContext }) ) - const git = await createRuntimeLocalGitWorktree({ - request, - repo, - store, - settings, - base, - workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), - branchName: candidate.branchName, - worktreePath: candidate.worktreePath, - effectiveSanitizedName: candidate.effectiveSanitizedName, - checkoutExistingBranch: candidate.checkoutExistingBranch, - localWorktreeGitOptions: worktreeGitOptions, - baseRefRefresh, - rearm: args.rearm, - timing: args.timing - }) - const materialized = await materializeRuntimeLocalWorktree({ - request, - repo, - store, - settings, - created: git.created, - remoteTrackingBase: base.remoteTrackingBase, - sparseDirectories: git.sparseDirectories, - configuredPushTarget: git.configuredPushTarget, - checkoutExistingBranch: candidate.checkoutExistingBranch, - baseBranch: base.baseBranch, - branchName: candidate.branchName, - effectiveRequestedName: candidate.effectiveRequestedName, - requestedDisplayName: candidate.requestedDisplayName, - displayNameKind: candidate.displayNameKind, - effectiveSanitizedName: candidate.effectiveSanitizedName, - effectiveCreatedWithAgent: args.createdWithAgent, - localWorktreeGitOptions: worktreeGitOptions, - onMetadataPersisted: args.onWorktreeMetadataPersisted, - timing: args.timing - }) - return { - ...materialized, - worktreePath: candidate.worktreePath, - created: git.created, - addResult: git.addResult, - ...(base.baseFallback ? { baseFallback: base.baseFallback } : {}) + const releaseInFlightHold = await holdInFlightWorktreeCreate( + repo.id, + candidate.worktreePath, + worktreeGitOptions + ) + try { + const git = await createRuntimeLocalGitWorktree({ + request, + repo, + store, + settings, + base, + workspaceRoot: computeWorkspaceRoot(repo.path, pathSettings), + branchName: candidate.branchName, + worktreePath: candidate.worktreePath, + effectiveSanitizedName: candidate.effectiveSanitizedName, + checkoutExistingBranch: candidate.checkoutExistingBranch, + localWorktreeGitOptions: worktreeGitOptions, + baseRefRefresh, + rearm: args.rearm, + timing: args.timing + }) + const materialized = await materializeRuntimeLocalWorktree({ + request, + repo, + store, + settings, + created: git.created, + remoteTrackingBase: base.remoteTrackingBase, + sparseDirectories: git.sparseDirectories, + configuredPushTarget: git.configuredPushTarget, + checkoutExistingBranch: candidate.checkoutExistingBranch, + baseBranch: base.baseBranch, + branchName: candidate.branchName, + effectiveRequestedName: candidate.effectiveRequestedName, + requestedDisplayName: candidate.requestedDisplayName, + displayNameKind: candidate.displayNameKind, + effectiveSanitizedName: candidate.effectiveSanitizedName, + effectiveCreatedWithAgent: args.createdWithAgent, + localWorktreeGitOptions: worktreeGitOptions, + onMetadataPersisted: (worktree) => { + releaseInFlightHold() + return args.onWorktreeMetadataPersisted(worktree) + }, + timing: args.timing + }) + return { + ...materialized, + worktreePath: candidate.worktreePath, + created: git.created, + addResult: git.addResult, + ...(base.baseFallback ? { baseFallback: base.baseFallback } : {}) + } + } finally { + releaseInFlightHold() } } diff --git a/src/main/runtime/workspaces/workspace-catalog.ts b/src/main/runtime/workspaces/workspace-catalog.ts index f4a17a55988..034d318a0aa 100644 --- a/src/main/runtime/workspaces/workspace-catalog.ts +++ b/src/main/runtime/workspaces/workspace-catalog.ts @@ -28,6 +28,7 @@ import { LOCAL_EXECUTION_HOST_ID } from '../../../shared/execution-host' import { withUnregisteredRemovalCheckouts } from '../../worktree-removal-listing' +import { withoutInFlightWorktreeCreates } from '../../worktree-create-in-flight' import { preserveFolderUpgradeWorktreePath } from '../../folder-upgrade-worktree-path' import type { RuntimeWorktreeScanCache } from '../orca-runtime-core' import { resolveWorktreeScanCacheTtlMs } from '../runtime-worktree-scan-cache' @@ -152,7 +153,13 @@ export abstract class RuntimeWorkspaceCatalog extends OrcaRuntimeWithReadKittyFi // checkout must carry the folder's locator here or its id loses metadata and terminals (#30150). const scan = { ...cached, worktrees: preserveFolderUpgradeWorktreePath(repo, cached.worktrees) } return scan.ok && getRepoExecutionHostId(repo) === LOCAL_EXECUTION_HOST_ID - ? { ok: true, worktrees: await withUnregisteredRemovalCheckouts(repo.id, scan.worktrees) } + ? { + ok: true, + worktrees: await withUnregisteredRemovalCheckouts( + repo.id, + withoutInFlightWorktreeCreates(repo.id, scan.worktrees) + ) + } : scan } diff --git a/src/main/worktree-create-in-flight.test.ts b/src/main/worktree-create-in-flight.test.ts new file mode 100644 index 00000000000..1ab9043cbab --- /dev/null +++ b/src/main/worktree-create-in-flight.test.ts @@ -0,0 +1,56 @@ +import { mkdir, mkdtemp, realpath, rm, symlink } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { describe, expect, it } from 'vitest' +import { + holdInFlightWorktreeCreate, + withoutInFlightWorktreeCreates +} from './worktree-create-in-flight' + +const rows = [{ path: '/repo' }, { path: '/ws/new' }, { path: '/ws/other' }] + +describe('withoutInFlightWorktreeCreates', () => { + it('omits a held checkout from its own repo until released', async () => { + const release = await holdInFlightWorktreeCreate('repo-a', '/ws/new') + expect(withoutInFlightWorktreeCreates('repo-a', rows)).toEqual([ + { path: '/repo' }, + { path: '/ws/other' } + ]) + expect(withoutInFlightWorktreeCreates('repo-b', rows)).toBe(rows) + release() + expect(withoutInFlightWorktreeCreates('repo-a', rows)).toBe(rows) + }) + + it('keeps a second hold on the same path after the first releases', async () => { + const releaseFirst = await holdInFlightWorktreeCreate('repo-a', '/ws/new') + const releaseSecond = await holdInFlightWorktreeCreate('repo-a', '/ws/new/') + releaseFirst() + releaseFirst() + expect(withoutInFlightWorktreeCreates('repo-a', rows)).toHaveLength(2) + releaseSecond() + expect(withoutInFlightWorktreeCreates('repo-a', rows)).toBe(rows) + }) + + it('hides the real path Git lists under a symlinked workspace with missing parents', async () => { + const root = await mkdtemp(join(tmpdir(), 'orca-create-hold-')) + let release = () => {} + try { + const actual = join(root, 'actual') + const alias = join(root, 'alias') + await mkdir(actual) + await symlink(actual, alias, 'junction') + const requested = join(alias, 'nested', 'new') + release = await holdInFlightWorktreeCreate('repo-alias', requested) + await mkdir(requested, { recursive: true }) + const canonical = await realpath(requested) + const listed = [{ path: requested }, { path: canonical }, { path: join(actual, 'other') }] + expect(withoutInFlightWorktreeCreates('repo-alias', listed)).toEqual([listed[2]]) + expect(withoutInFlightWorktreeCreates('other-repo', listed)).toBe(listed) + release() + expect(withoutInFlightWorktreeCreates('repo-alias', listed)).toBe(listed) + } finally { + release() + await rm(root, { recursive: true, force: true }) + } + }) +}) diff --git a/src/main/worktree-create-in-flight.ts b/src/main/worktree-create-in-flight.ts new file mode 100644 index 00000000000..b3f52a5adfa --- /dev/null +++ b/src/main/worktree-create-in-flight.ts @@ -0,0 +1,71 @@ +import { realpath } from 'node:fs/promises' +import { basename, dirname, resolve } from 'node:path' +import { toWindowsWslPath } from '../shared/wsl-paths' +import { areWorktreePathsEqual } from './git/worktree-path-comparison' + +type InFlightWorktreeCreate = { repoId: string; paths: string[] } + +const inFlightCreates = new Set() + +// Git lists real paths even when creation uses a symlink and the leaf does not exist yet. +async function resolveCreatePathAlias(worktreePath: string): Promise { + let ancestor = worktreePath + const missing: string[] = [] + for (;;) { + try { + return resolve(await realpath(ancestor), ...missing) + } catch (error) { + if ( + typeof error !== 'object' || + error === null || + !('code' in error) || + error.code !== 'ENOENT' + ) { + return worktreePath + } + const parent = dirname(ancestor) + if (parent === ancestor) { + return worktreePath + } + missing.unshift(basename(ancestor)) + ancestor = parent + } + } +} + +/** Hide untagged checkouts until metadata makes sidebar filters reliable. */ +export async function holdInFlightWorktreeCreate( + repoId: string, + worktreePath: string, + options: { wslDistro?: string } = {} +): Promise<() => void> { + const hostPath = + process.platform === 'win32' && options.wslDistro && worktreePath.startsWith('/') + ? toWindowsWslPath(worktreePath, options.wslDistro) + : worktreePath + const entry: InFlightWorktreeCreate = { + repoId, + paths: [worktreePath, hostPath, await resolveCreatePathAlias(hostPath)] + } + inFlightCreates.add(entry) + return () => { + inFlightCreates.delete(entry) + } +} + +export function withoutInFlightWorktreeCreates( + repoId: string, + rows: T[] +): T[] { + if (inFlightCreates.size === 0) { + return rows + } + const held = [...inFlightCreates].filter((entry) => entry.repoId === repoId) + if (held.length === 0) { + return rows + } + return rows.filter( + (row) => + !held.some((entry) => entry.paths.some((path) => areWorktreePathsEqual(row.path, path))) + ) +}