diff --git a/src/main/git/worktree-add.ts b/src/main/git/worktree-add.ts index 4110a4bda0b..8b43cdcca85 100644 --- a/src/main/git/worktree-add.ts +++ b/src/main/git/worktree-add.ts @@ -19,7 +19,46 @@ import type { import { gitExecOptions, resolveWorktreeAddTimeoutMs } from './worktree-operation-options' import { bumpWorktreeScanGeneration } from './worktree-scan-cache' -async function persistWorktreeCreationBase( +export type WorktreeAddBaseContext = AddWorktreeResult & { + effectiveBase: string +} + +export async function resolveWorktreeAddBaseContext( + repoPath: string, + baseBranch: string, + refreshLocalBaseRef: boolean, + options: AddWorktreeOptions +): Promise { + const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + ) + const localBaseRefRefresh = refreshLocalBaseRef + ? await refreshLocalBaseRefForWorktreeCreate( + repoPath, + baseBranch, + effectiveBase, + options.remoteTrackingBase, + options + ) + : undefined + const localBaseRefUpdateSuggestion = + !refreshLocalBaseRef && options.suggestLocalBaseRefUpdate + ? await getLocalBaseRefUpdateSuggestionForWorktreeCreate( + repoPath, + baseBranch, + effectiveBase, + options.remoteTrackingBase, + options + ) + : undefined + return { + effectiveBase, + ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), + ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) + } +} + +export async function persistWorktreeCreationBase( worktreePath: string, branch: string, effectiveBase: string, @@ -46,6 +85,35 @@ async function persistWorktreeCreationBase( } } +export async function configurePushAutoSetupRemote( + worktreePath: string, + options: GitWorktreeExecOptions +): Promise { + try { + // Why: `--get` (not `--local --get`) treats a value at any scope as an explicit user choice. + let alreadySet = false + try { + await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], { + ...gitExecOptions(worktreePath, options) + }) + alreadySet = true + } catch (readError) { + // Why: exit 1 means unset; other codes are real read failures and must not overwrite config. + const code = (readError as { code?: unknown })?.code + if (code !== 1) { + throw readError + } + } + if (!alreadySet) { + await gitExecFileAsync(['config', '--local', 'push.autoSetupRemote', 'true'], { + ...gitExecOptions(worktreePath, options) + }) + } + } catch (error) { + console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error) + } +} + export async function unsetWorktreeCreationBase( worktreePath: string, branch: string, @@ -120,27 +188,15 @@ async function performAddWorktree( // Why: --no-track avoids inheriting the base's upstream so `git status` won't misreport "behind by N" pre-publish; first push sets it (see push.autoSetupRemote below). args.push('--no-track', '-b', branch, worktreePath) if (baseBranch) { - effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => - hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + const baseContext = await resolveWorktreeAddBaseContext( + repoPath, + baseBranch, + refreshLocalBaseRef, + options ) - // Why: resolve the creation base first to distinguish remote-tracking refs from slash-containing local branches (mutation gated behind the explicit setting). - if (refreshLocalBaseRef) { - localBaseRefRefresh = await refreshLocalBaseRefForWorktreeCreate( - repoPath, - baseBranch, - effectiveBase, - options.remoteTrackingBase, - options - ) - } else if (options.suggestLocalBaseRefUpdate) { - localBaseRefUpdateSuggestion = await getLocalBaseRefUpdateSuggestionForWorktreeCreate( - repoPath, - baseBranch, - effectiveBase, - options.remoteTrackingBase, - options - ) - } + effectiveBase = baseContext.effectiveBase + localBaseRefRefresh = baseContext.localBaseRefRefresh + localBaseRefUpdateSuggestion = baseContext.localBaseRefUpdateSuggestion args.push(effectiveBase) } } @@ -163,29 +219,7 @@ async function performAddWorktree( // `git push` create+set origin/ (git >=2.37; older clients ignore it). `--local` on a // linked worktree writes the shared common-dir config (whole repo) — intentional and idempotent, // so it's warn-only and not rolled back on failure. - try { - // Why: `--get` (not `--local --get`) so a value at any scope counts as "user already chose" and isn't overwritten. - let alreadySet = false - try { - await gitExecFileAsync(['config', '--get', 'push.autoSetupRemote'], { - ...gitExecOptions(worktreePath, options) - }) - alreadySet = true - } catch (readError) { - // Why: `git config --get` exits 1 only when unset at every scope; any other code is a real read failure — rethrow rather than overwrite the user's value. - const code = (readError as { code?: unknown })?.code - if (code !== 1) { - throw readError - } - } - if (!alreadySet) { - await gitExecFileAsync(['config', '--local', 'push.autoSetupRemote', 'true'], { - ...gitExecOptions(worktreePath, options) - }) - } - } catch (error) { - console.warn(`addWorktree: failed to set push.autoSetupRemote for ${worktreePath}`, error) - } + await configurePushAutoSetupRemote(worktreePath, options) return { ...(localBaseRefRefresh ? { localBaseRefRefresh } : {}), ...(localBaseRefUpdateSuggestion ? { localBaseRefUpdateSuggestion } : {}) diff --git a/src/main/git/worktree-create-preparation-real-git.test.ts b/src/main/git/worktree-create-preparation-real-git.test.ts new file mode 100644 index 00000000000..bdb1e9fcc4b --- /dev/null +++ b/src/main/git/worktree-create-preparation-real-git.test.ts @@ -0,0 +1,127 @@ +import { execFileSync } from 'node:child_process' +import { mkdir, mkdtemp, readFile, realpath, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' +import { + createWorktreePreparationLockReason, + isWorktreeCreatePreparation, + WORKTREE_CREATE_PREPARATION_DIRECTORY +} from '../../shared/worktree/create-preparation' +import { listWorktrees } from './worktree' +import { + discardPreparedWorktree, + finalizePreparedWorktree, + prepareWorktreeCreateCheckout +} from './worktree-create-preparation' +import { areWorktreePathsEqual } from './worktree-path-comparison' + +const tempRoots: string[] = [] + +function git(cwd: string, args: string[]): string { + return execFileSync('git', args, { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'] + }).trim() +} + +async function createRepo(): Promise<{ repoPath: string; root: string }> { + const root = await mkdtemp(join(tmpdir(), 'orca-prepared-worktree-')) + tempRoots.push(root) + const repoPath = join(root, 'repo') + execFileSync('git', ['init', '--quiet', repoPath]) + git(repoPath, ['symbolic-ref', 'HEAD', 'refs/heads/main']) + git(repoPath, ['config', 'user.email', 'test@example.com']) + git(repoPath, ['config', 'user.name', 'Test User']) + git(repoPath, ['config', 'core.autocrlf', 'false']) + await writeFile(join(repoPath, 'version.txt'), 'one\n') + git(repoPath, ['add', 'version.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'initial']) + return { repoPath, root } +} + +afterEach(async () => { + await Promise.all(tempRoots.splice(0).map((root) => rm(root, { recursive: true, force: true }))) +}) + +describe('prepared worktree creation with real Git', () => { + it('cleans up when the create signal is canceled', async () => { + const { repoPath, root } = await createRepo() + const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const preparedPath = join(preparationRoot, `${process.pid}-canceled`) + await mkdir(preparationRoot, { recursive: true }) + + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('canceled-test') + ) + + const controller = new AbortController() + controller.abort() + await expect( + discardPreparedWorktree(repoPath, preparedPath, { signal: controller.signal }) + ).resolves.toBeUndefined() + + expect(await listWorktrees(repoPath, { includeCreatePreparations: true })).toHaveLength(1) + }) + + it('hides the preparation, retargets an advanced base, and attaches the final branch', async () => { + const { repoPath, root } = await createRepo() + const preparationRoot = join(root, WORKTREE_CREATE_PREPARATION_DIRECTORY) + const preparedPath = join(preparationRoot, `${process.pid}-test`) + const finalPath = join(root, 'final-worktree') + await mkdir(preparationRoot, { recursive: true }) + + await prepareWorktreeCreateCheckout( + repoPath, + preparedPath, + 'main', + createWorktreePreparationLockReason('real-git-test') + ) + + const visibleBeforeSubmit = await listWorktrees(repoPath) + const allBeforeSubmit = await listWorktrees(repoPath, { includeCreatePreparations: true }) + expect(visibleBeforeSubmit).toHaveLength(1) + expect(allBeforeSubmit).toHaveLength(2) + expect(allBeforeSubmit.find(isWorktreeCreatePreparation)).toMatchObject({ + locked: true, + lockReason: expect.stringContaining('orca-create-preparation:v1:') + }) + + await writeFile(join(repoPath, 'version.txt'), 'two\n') + git(repoPath, ['add', 'version.txt']) + git(repoPath, ['commit', '--quiet', '-m', 'advance base']) + const latestHead = git(repoPath, ['rev-parse', 'HEAD']) + + await finalizePreparedWorktree( + repoPath, + preparedPath, + finalPath, + 'feature/prepared', + 'main', + false + ) + + expect(git(finalPath, ['rev-parse', 'HEAD'])).toBe(latestHead) + expect(git(finalPath, ['branch', '--show-current'])).toBe('feature/prepared') + expect((await readFile(join(finalPath, 'version.txt'), 'utf8')).replaceAll('\r\n', '\n')).toBe( + 'two\n' + ) + expect(git(finalPath, ['config', '--get', 'branch.feature/prepared.base'])).toBe( + 'refs/heads/main' + ) + expect(git(finalPath, ['config', '--get', 'push.autoSetupRemote'])).toBe('true') + const listedWorktrees = await listWorktrees(repoPath) + const resolvedFinalPath = await realpath(finalPath) + expect( + listedWorktrees.some((worktree) => areWorktreePathsEqual(worktree.path, resolvedFinalPath)) + ).toBe(true) + expect( + listedWorktrees.find((worktree) => areWorktreePathsEqual(worktree.path, resolvedFinalPath)) + ?.locked + ).not.toBe(true) + }) +}) diff --git a/src/main/git/worktree-create-preparation.ts b/src/main/git/worktree-create-preparation.ts new file mode 100644 index 00000000000..626b090a0e0 --- /dev/null +++ b/src/main/git/worktree-create-preparation.ts @@ -0,0 +1,270 @@ +import { windowsLongPathGitArgs } from '../../shared/windows-long-path-git-args' +import { resolveWorktreeAddBaseRef } from '../../shared/worktree/base-ref' +import type { AddWorktreeOptions, AddWorktreeResult, GitWorktreeExecOptions } from './worktree' +import { + configurePushAutoSetupRemote, + notifyPreparedWorktreeMutation, + persistWorktreeCreationBase, + resolveWorktreeAddBaseContext, + resolveWorktreeAddTimeoutMs, + WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS +} from './worktree' +import { hasWorktreeBaseCommitRef } from './worktree-base-ref-probe' +import { gitExecFileAsync } from './runner' +import { runWithGitReadCacheInvalidation } from './status' + +function gitExecOptions( + cwd: string, + options: GitWorktreeExecOptions +): { cwd: string; wslDistro?: string; signal?: AbortSignal; timeout?: number } { + return { + cwd, + ...(options.wslDistro ? { wslDistro: options.wslDistro } : {}), + ...(options.signal ? { signal: options.signal } : {}), + ...(options.timeout ? { timeout: options.timeout } : {}) + } +} + +function gitCleanupOptions( + cwd: string, + options: GitWorktreeExecOptions +): { cwd: string; wslDistro?: string; timeout?: number } { + // Why: cancellation must not strand a partially moved worktree; cleanup is bounded separately. + return gitExecOptions(cwd, { ...options, signal: undefined }) +} + +async function performDiscardPreparedWorktree( + repoPath: string, + worktreePath: string, + options: GitWorktreeExecOptions +): Promise { + const cleanupGitOptions = { + ...gitCleanupOptions(repoPath, options), + timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + } + try { + await gitExecFileAsync( + [...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath], + cleanupGitOptions + ) + } catch { + // It may be unlocked already or only partially registered. + } + await gitExecFileAsync( + [...windowsLongPathGitArgs(repoPath), 'worktree', 'remove', '--force', worktreePath], + cleanupGitOptions + ) +} + +export async function prepareWorktreeCreateCheckout( + repoPath: string, + worktreePath: string, + baseBranch: string, + lockReason: string, + options: GitWorktreeExecOptions = {} +): Promise { + try { + await runWithGitReadCacheInvalidation(async () => { + const effectiveBase = await resolveWorktreeAddBaseRef(baseBranch, (qualifiedRef) => + hasWorktreeBaseCommitRef(repoPath, qualifiedRef, options) + ) + try { + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'add', + '--detach', + '--no-checkout', + worktreePath, + effectiveBase + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + // Why: reset materializes files without running user post-checkout hooks before submit. + await gitExecFileAsync( + [...windowsLongPathGitArgs(worktreePath), 'reset', '--hard', effectiveBase], + { ...gitExecOptions(worktreePath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'lock', + '--reason', + lockReason, + worktreePath + ], + { ...gitExecOptions(repoPath, options), timeout: resolveWorktreeAddTimeoutMs() } + ) + } catch (error) { + await performDiscardPreparedWorktree(repoPath, worktreePath, options).catch(() => {}) + throw error + } + }) + } finally { + notifyPreparedWorktreeMutation(repoPath) + } +} + +export async function discardPreparedWorktree( + repoPath: string, + worktreePath: string, + options: GitWorktreeExecOptions = {} +): Promise { + try { + await runWithGitReadCacheInvalidation(() => + performDiscardPreparedWorktree(repoPath, worktreePath, options) + ) + } finally { + notifyPreparedWorktreeMutation(repoPath) + } +} + +export async function unlockPreparedWorktree( + repoPath: string, + worktreePath: string, + options: GitWorktreeExecOptions = {} +): Promise { + const cleanupGitOptions = { + ...gitCleanupOptions(repoPath, options), + timeout: options.timeout ?? WORKTREE_REMOVAL_REGISTRATION_TIMEOUT_MS + } + try { + await runWithGitReadCacheInvalidation(() => + gitExecFileAsync( + [...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath], + cleanupGitOptions + ) + ) + } finally { + notifyPreparedWorktreeMutation(repoPath) + } +} + +async function removeFailedFinalization( + repoPath: string, + cleanupPath: string, + branch: string, + moved: boolean, + options: GitWorktreeExecOptions +): Promise { + let branchAttached = false + if (moved) { + try { + const { stdout } = await gitExecFileAsync( + ['symbolic-ref', '--short', 'HEAD'], + gitCleanupOptions(cleanupPath, options) + ) + branchAttached = stdout.trim() === branch + } catch { + // Detached or no longer readable. + } + } + await performDiscardPreparedWorktree(repoPath, cleanupPath, options).catch(() => {}) + if (branchAttached) { + await gitExecFileAsync( + ['branch', '-D', '--', branch], + gitCleanupOptions(repoPath, options) + ).catch(() => {}) + } +} + +export async function finalizePreparedWorktree( + repoPath: string, + preparedPath: string, + worktreePath: string, + branch: string, + baseBranch: string, + refreshLocalBaseRef = false, + options: AddWorktreeOptions = {} +): Promise { + const finalizeGitOptions: AddWorktreeOptions = { + ...options, + timeout: options.timeout ?? resolveWorktreeAddTimeoutMs() + } + try { + return await runWithGitReadCacheInvalidation(async () => { + const baseContext = await resolveWorktreeAddBaseContext( + repoPath, + baseBranch, + refreshLocalBaseRef, + finalizeGitOptions + ) + const { stdout: targetHeadOutput } = await gitExecFileAsync( + ['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`], + gitExecOptions(repoPath, finalizeGitOptions) + ) + const targetHead = targetHeadOutput.trim() + const { stdout: preparedHeadOutput } = await gitExecFileAsync( + ['rev-parse', '--verify', 'HEAD'], + gitExecOptions(preparedPath, finalizeGitOptions) + ) + if (preparedHeadOutput.trim() !== targetHead) { + await gitExecFileAsync( + [...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead], + gitExecOptions(preparedPath, finalizeGitOptions) + ) + } + + let moved = false + try { + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(repoPath), + 'worktree', + 'move', + '-f', + '-f', + preparedPath, + worktreePath + ], + gitExecOptions(repoPath, finalizeGitOptions) + ) + moved = true + // Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25). + await gitExecFileAsync( + [ + ...windowsLongPathGitArgs(worktreePath), + 'checkout', + '--no-track', + '-b', + branch, + targetHead + ], + gitExecOptions(worktreePath, finalizeGitOptions) + ) + await persistWorktreeCreationBase( + worktreePath, + branch, + baseContext.effectiveBase, + finalizeGitOptions + ) + await configurePushAutoSetupRemote(worktreePath, finalizeGitOptions) + await gitExecFileAsync( + [...windowsLongPathGitArgs(repoPath), 'worktree', 'unlock', worktreePath], + gitExecOptions(repoPath, finalizeGitOptions) + ) + } catch (error) { + await removeFailedFinalization( + repoPath, + moved ? worktreePath : preparedPath, + branch, + moved, + finalizeGitOptions + ) + throw error + } + return { + ...(baseContext.localBaseRefRefresh + ? { localBaseRefRefresh: baseContext.localBaseRefRefresh } + : {}), + ...(baseContext.localBaseRefUpdateSuggestion + ? { localBaseRefUpdateSuggestion: baseContext.localBaseRefUpdateSuggestion } + : {}) + } + }) + } finally { + notifyPreparedWorktreeMutation(repoPath) + } +} diff --git a/src/main/git/worktree-listing.ts b/src/main/git/worktree-listing.ts index dd6ee20765f..c2c293678d3 100644 --- a/src/main/git/worktree-listing.ts +++ b/src/main/git/worktree-listing.ts @@ -1,4 +1,5 @@ import { stat } from 'node:fs/promises' +import { isWorktreeCreatePreparation } from '../../shared/worktree/create-preparation' import type { GitWorktreeInfo } from '../../shared/worktree/types' import { readTranslatedWorktreeGraph, readWorktreeList } from './worktree-list-reader' import type { GitWorktreeExecOptions } from './worktree-operation-options' @@ -13,7 +14,10 @@ export async function listWorktreeGraph( options: GitWorktreeExecOptions = {} ): Promise { try { - return await readTranslatedWorktreeGraph(repoPath, options) + const worktrees = await readTranslatedWorktreeGraph(repoPath, options) + return options.includeCreatePreparations + ? worktrees + : worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree)) } catch (err) { if (getErrorCode(err) === 'ENOENT') { try { @@ -39,7 +43,10 @@ export async function listWorktreesUnshared( ): Promise { try { const worktrees = await readTranslatedWorktreeGraph(repoPath, options) - return annotateSparseCheckoutStatus(worktrees) + const visibleWorktrees = options.includeCreatePreparations + ? worktrees + : worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree)) + return annotateSparseCheckoutStatus(visibleWorktrees) } catch (err) { if (getErrorCode(err) === 'ENOENT') { try { @@ -68,7 +75,10 @@ export async function listWorktreesStrict( const translatedPath = translateWorktreePath(worktree.path, repoPath, options) return translatedPath === worktree.path ? worktree : { ...worktree, path: translatedPath } }) - return annotateSparseCheckoutStatus(worktrees) + const visibleWorktrees = options.includeCreatePreparations + ? worktrees + : worktrees.filter((worktree) => !isWorktreeCreatePreparation(worktree)) + return annotateSparseCheckoutStatus(visibleWorktrees) } async function annotateSparseCheckoutStatus( diff --git a/src/main/git/worktree-operation-options.ts b/src/main/git/worktree-operation-options.ts index c88c246be9c..9376fe63f85 100644 --- a/src/main/git/worktree-operation-options.ts +++ b/src/main/git/worktree-operation-options.ts @@ -18,6 +18,7 @@ export type GitWorktreeExecOptions = { wslDistro?: string signal?: AbortSignal timeout?: number + includeCreatePreparations?: boolean } export type WorktreeRemovalPreflightOptions = GitWorktreeExecOptions & { diff --git a/src/main/git/worktree-scan-cache.ts b/src/main/git/worktree-scan-cache.ts index 3bb674c2a99..325a8b92d50 100644 --- a/src/main/git/worktree-scan-cache.ts +++ b/src/main/git/worktree-scan-cache.ts @@ -66,7 +66,7 @@ function shareWorktreeScan( const timeout = options.timeout ?? WORKTREE_LIST_TIMEOUT_MS // Why: callers with different deadlines cannot safely share which timeout wins the scan. // Why `run.name`: a strict joiner must never receive a softened `[]` from a lenient scan. - const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${generation}\0${run.name}` + const key = `${repoPath}\0${options.wslDistro ?? ''}\0${timeout}\0${options.includeCreatePreparations === true}\0${generation}\0${run.name}` const inFlight = inFlightWorktreeScans.get(key) if (inFlight) { return inFlight diff --git a/src/main/git/worktree.ts b/src/main/git/worktree.ts index 317585a82e6..78562787a69 100644 --- a/src/main/git/worktree.ts +++ b/src/main/git/worktree.ts @@ -1,4 +1,9 @@ export { addWorktree } from './worktree-add' +export { + configurePushAutoSetupRemote, + persistWorktreeCreationBase, + resolveWorktreeAddBaseContext +} from './worktree-add' export { forceDeleteLocalBranch } from './worktree-branch-removal' export { parseWorktreeList } from './worktree-list-parser' export { listWorktreeGraph, listWorktreesStrict } from './worktree-listing' @@ -25,5 +30,6 @@ export { listWorktrees, listWorktreesSharedStrict } from './worktree-scan-cache' +export { bumpWorktreeScanGeneration as notifyPreparedWorktreeMutation } from './worktree-scan-cache' export { addSparseWorktree } from './worktree-sparse-add' export { parseCoreSparseCheckoutFlag } from './worktree-sparse-state' diff --git a/src/main/host-tree-removal.ts b/src/main/host-tree-removal.ts index dcc8a2f2a43..666d129b27c 100644 --- a/src/main/host-tree-removal.ts +++ b/src/main/host-tree-removal.ts @@ -6,15 +6,28 @@ import type { RmOptions } from 'node:fs' import { rm } from 'node:fs/promises' import { win32 } from 'node:path' import { setTimeout as delay } from 'node:timers/promises' +import { isWindowsAbsolutePathLike } from '../shared/cross-platform-path' +import { isWslUncPath } from '../shared/wsl-paths' const WINDOWS_REMOVE_RETRY_DELAYS_MS = [250, 500, 1_000, 2_000] const WINDOWS_RM_MAX_RETRIES = 8 const WINDOWS_RM_RETRY_DELAY_MS = 150 +/** Convert a native host filesystem path to the Win32 long-path namespace. */ +export function toHostFilesystemPath(targetPath: string): string { + // POSIX paths are used by WSL callers even while the Electron process runs + // on Windows; do not reinterpret those as drive-relative Win32 paths. + return process.platform === 'win32' && + isWindowsAbsolutePathLike(targetPath) && + !isWslUncPath(targetPath) + ? win32.toNamespacedPath(targetPath) + : targetPath +} + export function toHostRemovalPath(targetPath: string): string { // Why: Git for Windows can fail long recursive deletes even after Orca has // proven the worktree target; Node's host deletion should use Win32 long paths. - return process.platform === 'win32' ? win32.toNamespacedPath(targetPath) : targetPath + return toHostFilesystemPath(targetPath) } function getHostRemovalOptions(): RmOptions { diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 88e4942c94f..66234738128 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -29,6 +29,7 @@ import type { import { getPRForBranch } from '../github/client' import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree' import type { AddWorktreeOptions, AddWorktreeResult } from '../git/worktree' +import { consumePreparedWorktreeCreate } from '../worktree-create-preparation' import { getBranchConflictKind, resolveDefaultBaseRefViaExec, @@ -2381,10 +2382,27 @@ export async function createLocalWorktree( ...remoteTrackingBaseOption, ...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}) } + const preparedWorktreeOptions = suggestLocalBaseRefUpdate + ? addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) + : addProjectGitOptions(remoteTrackingBaseOption) let addResult: AddWorktreeResult try { addResult = (await timing.time('git_worktree_add', async () => { + if (sparseDirectories.length === 0 && !checkoutExistingBranch) { + const preparedResult = await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot, + worktreePath, + branch: branchName, + baseBranch, + refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, + ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) + }) + if (preparedResult) { + return preparedResult + } + } if (sparseDirectories.length > 0) { if (checkoutExistingBranch) { return addSparseWorktree( diff --git a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts index 19b0516f274..849a1ff66d9 100644 --- a/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts +++ b/src/main/ipc/worktrees/create/register-worktree-prefetch-handler.ts @@ -1,5 +1,6 @@ import { ipcMain } from 'electron' import { prefetchWorktreeCreateBase } from '../../../worktree-create-base-prefetch' +import { prepareWorktreeCreateForRepo } from '../../../worktree-create-preparation' import type { WorktreeIpcContext } from '../worktree-ipc-context' export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): void { @@ -13,7 +14,14 @@ export function registerWorktreePrefetchHandler(context: WorktreeIpcContext): vo return } try { - await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime }) + const baseBranch = await prefetchWorktreeCreateBase({ + repo, + baseBranch: args.baseBranch, + runtime + }) + if (baseBranch) { + await prepareWorktreeCreateForRepo(store, repo, baseBranch) + } } catch { // Why: optimistic warm-up; the real create path awaits the same refresh and reports failures there. } diff --git a/src/main/local-worktree-filesystem.test.ts b/src/main/local-worktree-filesystem.test.ts index 9eed498e73d..52b1d16f21a 100644 --- a/src/main/local-worktree-filesystem.test.ts +++ b/src/main/local-worktree-filesystem.test.ts @@ -22,6 +22,7 @@ vi.mock('node:fs/promises', () => ({ import { getLocalWorktreePathAccess, removeLocalWorktreePath, + toHostFilesystemPath, toHostRemovalPath } from './local-worktree-filesystem' @@ -103,6 +104,27 @@ describe('local worktree filesystem runtime access', () => { }) }) + it('uses the same Win32 namespace for host directory creation on Windows', async () => { + await withPlatform('win32', async () => { + const longPath = `C:\\repo\\${'nested\\'.repeat(40)}feature` + + expect(toHostFilesystemPath(longPath)).toBe(`\\\\?\\${longPath}`) + }) + }) + + it('leaves POSIX WSL paths unchanged on a Windows host', async () => { + await withPlatform('win32', async () => { + expect(toHostFilesystemPath('/home/me/worktrees')).toBe('/home/me/worktrees') + }) + }) + + it('leaves WSL UNC paths unchanged on a Windows host', async () => { + await withPlatform('win32', async () => { + const wslPath = String.raw`\\wsl.localhost\Ubuntu\home\me\worktrees` + expect(toHostFilesystemPath(wslPath)).toBe(wslPath) + }) + }) + it('retries transient host removal failures on Windows', async () => { vi.useFakeTimers() await withPlatform('win32', async () => { diff --git a/src/main/local-worktree-filesystem.ts b/src/main/local-worktree-filesystem.ts index 9e4a1dba6a9..f5d8714e196 100644 --- a/src/main/local-worktree-filesystem.ts +++ b/src/main/local-worktree-filesystem.ts @@ -5,7 +5,7 @@ import { removeHostTree } from './host-tree-removal' import { toLinuxPath } from './wsl' import type { ReadPath, StatPath } from './worktree-orphan-gitdir-proof' -export { toHostRemovalPath } from './host-tree-removal' +export { toHostFilesystemPath, toHostRemovalPath } from './host-tree-removal' export type LocalWorktreeFilesystemOptions = { wslDistro?: string diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 790fd3dcf53..77b63f3819e 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -1258,6 +1258,10 @@ import { resolveWorktreeRemovalRepoOwner } from '../worktree-removal-repo-owner' import { prefetchWorktreeCreateBase } from '../worktree-create-base-prefetch' +import { + consumePreparedWorktreeCreate, + prepareWorktreeCreateForRepo +} from '../worktree-create-preparation' import { prepareLocalWorktreeRootForRepo } from '../worktree-root-preparation' import { getWorktreeWatcherRemoval } from '../ipc/worktree-watcher-removal' import { acquireWatcherRemovalGate } from '../ipc/watcher-removal-gate' @@ -26498,11 +26502,18 @@ export class OrcaRuntimeService { } const repo = await this.resolveRepoSelector(args.repoSelector) - await prefetchWorktreeCreateBase({ + const baseBranch = await prefetchWorktreeCreateBase({ repo, baseBranch: args.baseBranch, runtime: this }) + if (baseBranch) { + try { + await prepareWorktreeCreateForRepo(this.requireStore(), repo, baseBranch) + } catch { + // Why: speculative preparation is an optimistic warm-up; the real create path reports failures. + } + } } async createManagedWorktree(args: { @@ -27087,9 +27098,27 @@ export class OrcaRuntimeService { ...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}) } const defaultAddWorktreeOption = addProjectGitOptions() + const preparedWorktreeOptions = suggestLocalBaseRefUpdate + ? addProjectGitOptions({ ...remoteTrackingBaseOption, suggestLocalBaseRefUpdate }) + : remoteTrackingBaseOption + ? addProjectGitOptions(remoteTrackingBaseOption) + : defaultAddWorktreeOption let addResult: AddWorktreeResult try { + const preparedResult = + sparseDirectories.length === 0 && !checkoutExistingBranch + ? await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot, + worktreePath, + branch: branchName, + baseBranch, + refreshLocalBaseRef: settings.refreshLocalBaseRefOnWorktreeCreate, + ...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {}) + }) + : null addResult = + preparedResult ?? (await (sparseDirectories.length > 0 ? checkoutExistingBranch ? addSparseWorktree( @@ -27185,7 +27214,8 @@ export class OrcaRuntimeService { branchName, baseBranch, settings.refreshLocalBaseRefOnWorktreeCreate - ))) ?? {} + ))) ?? + {} } catch (error) { if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) { await retireGeneratedWorktreeName(this.store, repo, settings, effectiveSanitizedName) diff --git a/src/main/worktree-create-base-prefetch.ts b/src/main/worktree-create-base-prefetch.ts index a7665fc4e15..50c81ed1959 100644 --- a/src/main/worktree-create-base-prefetch.ts +++ b/src/main/worktree-create-base-prefetch.ts @@ -44,7 +44,7 @@ async function prefetchLocalWorktreeCreateBase( repo: Repo, baseBranch: string | undefined, runtime: WorktreeCreateBasePrefetchRuntime -): Promise { +): Promise { const resolvedBaseBranch = await resolveWorktreeCreateBase({ requestedBaseBranch: baseBranch, repoWorktreeBaseRef: repo.worktreeBaseRef, @@ -64,13 +64,13 @@ async function prefetchLocalWorktreeCreateBase( } }) if (!resolvedBaseBranch) { - return + return undefined } if ( isFullGitObjectId(resolvedBaseBranch) && (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) ) { - return + return resolvedBaseBranch } const remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, resolvedBaseBranch) if (remoteTrackingBase) { @@ -79,34 +79,35 @@ async function prefetchLocalWorktreeCreateBase( !(await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) ) { await runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase) - return + return resolvedBaseBranch } } if (await hasLocalWorktreeBaseRef(repo.path, resolvedBaseBranch)) { // Why: hosted-review start points and local branch bases are already local; a broad remote fetch cannot make them fresher. - return + return resolvedBaseBranch } // Why: keep optimistic prefetch on the same best-effort fallback path as // create so the real create can reuse the runtime's remote fetch cache. await runtime.fetchRemoteWithCache(repo.path, 'origin') + return resolvedBaseBranch } export async function prefetchWorktreeCreateBase(args: { repo: Repo baseBranch?: string runtime: WorktreeCreateBasePrefetchRuntime -}): Promise { +}): Promise { if (isFolderRepo(args.repo)) { - return + return undefined } if (args.repo.connectionId) { const provider = getSshGitProvider(args.repo.connectionId) if (!provider) { - return + return undefined } await prefetchRemoteWorktreeCreateBase(provider, args.repo, { baseBranch: args.baseBranch }) - return + return undefined } - await prefetchLocalWorktreeCreateBase(args.repo, args.baseBranch, args.runtime) + return prefetchLocalWorktreeCreateBase(args.repo, args.baseBranch, args.runtime) } diff --git a/src/main/worktree-create-preparation.test.ts b/src/main/worktree-create-preparation.test.ts new file mode 100644 index 00000000000..0f4aa01e78e --- /dev/null +++ b/src/main/worktree-create-preparation.test.ts @@ -0,0 +1,214 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { Store } from './persistence' +import type { Repo } from '../shared/repo-types' +import { WORKTREE_CREATE_PREPARATION_DIRECTORY } from '../shared/worktree/create-preparation' + +const mocks = vi.hoisted(() => ({ + mkdir: vi.fn(), + listWorktreeGraph: vi.fn(), + prepareCheckout: vi.fn(), + finalize: vi.fn(), + discard: vi.fn(), + unlock: vi.fn(), + getWorktreeOptions: vi.fn() +})) + +vi.mock('node:fs/promises', () => ({ mkdir: mocks.mkdir })) +vi.mock('./git/worktree', () => ({ listWorktreeGraph: mocks.listWorktreeGraph })) +vi.mock('./git/worktree-create-preparation', () => ({ + prepareWorktreeCreateCheckout: mocks.prepareCheckout, + finalizePreparedWorktree: mocks.finalize, + discardPreparedWorktree: mocks.discard, + unlockPreparedWorktree: mocks.unlock +})) +vi.mock('./project-runtime-git-options', () => ({ + getLocalProjectWorktreeGitOptions: mocks.getWorktreeOptions +})) +vi.mock('./ipc/worktree-logic', () => ({ + computeWorkspaceRoot: (repoPath: string) => + process.platform === 'win32' && /^[A-Za-z]:[\\/]/.test(repoPath) + ? 'C:\\workspace' + : '/workspace', + getWorktreePathSettings: () => ({ + workspaceDir: process.platform === 'win32' ? 'C:\\workspace' : '/workspace', + nestWorkspaces: false + }) +})) + +import { + _resetWorktreeCreatePreparationsForTests, + consumePreparedWorktreeCreate, + prepareWorktreeCreateForRepo +} from './worktree-create-preparation' + +const repo = { id: 'repo-1', path: '/repo' } as Repo +const store = { getSettings: () => ({}) } as unknown as Store + +beforeEach(() => { + mocks.mkdir.mockReset().mockResolvedValue(undefined) + mocks.listWorktreeGraph.mockReset().mockResolvedValue([]) + mocks.prepareCheckout.mockReset().mockResolvedValue(undefined) + mocks.finalize.mockReset().mockResolvedValue({}) + mocks.discard.mockReset().mockResolvedValue(undefined) + mocks.unlock.mockReset().mockResolvedValue(undefined) + mocks.getWorktreeOptions.mockReset().mockReturnValue({}) +}) + +afterEach(async () => { + await _resetWorktreeCreatePreparationsForTests() +}) + +describe('worktree create preparation registry', () => { + it('namespaces native Windows preparation directories for long paths', async () => { + const originalPlatform = process.platform + Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' }) + try { + await prepareWorktreeCreateForRepo(store, { ...repo, path: 'C:\\repo' }, 'origin/main') + + expect(mocks.mkdir).toHaveBeenCalledWith( + expect.stringMatching(/^\\\\\?\\C:\\workspace\\\.orca-preparing/), + { recursive: true } + ) + } finally { + Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform }) + } + }) + + it('deduplicates preparation for the same repo, base, runtime, and workspace root', async () => { + await Promise.all([ + prepareWorktreeCreateForRepo(store, repo, 'origin/main'), + prepareWorktreeCreateForRepo(store, repo, 'origin/main') + ]) + + expect(mocks.prepareCheckout).toHaveBeenCalledTimes(1) + }) + + it('does not claim a preparation after the selected base changes', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/release' + }) + ).resolves.toBeNull() + expect(mocks.finalize).not.toHaveBeenCalled() + }) + + it('routes preparation and finalization through the selected WSL runtime', async () => { + const options = { wslDistro: 'Ubuntu' } + mocks.getWorktreeOptions.mockReturnValue(options) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + await consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main', + options + }) + + expect(mocks.prepareCheckout).toHaveBeenCalledWith( + repo.path, + expect.any(String), + 'origin/main', + expect.any(String), + options + ) + expect(mocks.finalize).toHaveBeenCalledWith( + repo.path, + expect.any(String), + '/workspace/final', + 'feature/test', + 'origin/main', + undefined, + options + ) + }) + + it('retries stale cleanup after a transient listing failure', async () => { + mocks.listWorktreeGraph.mockRejectedValueOnce(new Error('temporary listing failure')) + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + await prepareWorktreeCreateForRepo(store, repo, 'origin/release') + + expect(mocks.listWorktreeGraph).toHaveBeenCalledTimes(2) + }) + + it('unlocks a stale branch-attached final path instead of deleting user work', async () => { + mocks.listWorktreeGraph.mockResolvedValueOnce([ + { + path: '/workspace/final', + branch: 'refs/heads/feature/test', + lockReason: 'orca-create-preparation:v1:999999999:stale', + head: 'deadbeef', + isBare: false, + isMainWorktree: false + } + ]) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + expect(mocks.unlock).toHaveBeenCalledWith(repo.path, '/workspace/final', {}) + expect(mocks.discard).not.toHaveBeenCalledWith(repo.path, '/workspace/final', {}) + }) + + it('does not classify a user branch worktree under the preparation directory as stale', async () => { + mocks.listWorktreeGraph.mockResolvedValueOnce([ + { + path: '/workspace/.orca-preparing/999999999-user-worktree', + branch: 'refs/heads/user-worktree', + lockReason: undefined, + head: 'deadbeef', + isBare: false, + isMainWorktree: false + } + ]) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + expect(mocks.unlock).not.toHaveBeenCalled() + expect(mocks.discard).not.toHaveBeenCalled() + }) + + it('does not discard a detached worktree with caller-controlled preparation metadata', async () => { + mocks.listWorktreeGraph.mockResolvedValueOnce([ + { + path: `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/999-checkout`, + branch: undefined, + lockReason: 'orca-create-preparation:v1:999999999:spoofed', + head: 'deadbeef', + isBare: false, + isMainWorktree: false + } + ]) + + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + + expect(mocks.discard).not.toHaveBeenCalledWith( + repo.path, + `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/999-checkout`, + {} + ) + }) + + it('cleans up and returns null so normal add can run when finalization fails', async () => { + await prepareWorktreeCreateForRepo(store, repo, 'origin/main') + mocks.finalize.mockRejectedValueOnce(new Error('submodules prevent worktree move')) + + await expect( + consumePreparedWorktreeCreate({ + repoPath: repo.path, + workspaceRoot: '/workspace', + worktreePath: '/workspace/final', + branch: 'feature/test', + baseBranch: 'origin/main' + }) + ).resolves.toBeNull() + expect(mocks.mkdir).toHaveBeenCalledWith('/workspace', { recursive: true }) + expect(mocks.discard).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/main/worktree-create-preparation.ts b/src/main/worktree-create-preparation.ts new file mode 100644 index 00000000000..c26ca64fcdb --- /dev/null +++ b/src/main/worktree-create-preparation.ts @@ -0,0 +1,283 @@ +import { randomUUID } from 'node:crypto' +import { mkdir } from 'node:fs/promises' +import { posix, win32 } from 'node:path' +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 { + WORKTREE_CREATE_PREPARATION_DIRECTORY, + createWorktreePreparationLockReason, + isWorktreeCreatePreparation, + parseWorktreePreparationOwnerPid, + parseWorktreePreparationPathOwnerPid +} from '../shared/worktree/create-preparation' +import type { AddWorktreeOptions, AddWorktreeResult } from './git/worktree' +import { listWorktreeGraph } from './git/worktree' +import { + discardPreparedWorktree, + finalizePreparedWorktree, + unlockPreparedWorktree, + prepareWorktreeCreateCheckout +} from './git/worktree-create-preparation' +import { getLocalProjectWorktreeGitOptions } from './project-runtime-git-options' +import { computeWorkspaceRoot, getWorktreePathSettings } from './ipc/worktree-logic' +import { toHostFilesystemPath } from './host-tree-removal' + +export const WORKTREE_CREATE_PREPARATION_TTL_MS = 5 * 60_000 +export const WORKTREE_CREATE_PREPARATION_LIMIT = 3 +const STALE_PREPARATION_CLEANUP_CONCURRENCY = 4 + +type PreparationEntry = { + key: string + repoPath: string + workspaceRoot: string + preparedPath: string + options: AddWorktreeOptions + createdAt: number + ready: Promise + expiration: NodeJS.Timeout +} + +type ConsumePreparedWorktreeArgs = { + repoPath: string + workspaceRoot: string + worktreePath: string + branch: string + baseBranch: string + refreshLocalBaseRef?: boolean + options?: AddWorktreeOptions +} + +const preparations = new Map() +const staleCleanupInFlight = new Map>() + +function pathOps(path: string): Pick { + return isWindowsAbsolutePathLike(path) ? win32 : posix +} + +function pathKey(path: string): string { + const normalized = pathOps(path).normalize(path) + return isWindowsAbsolutePathLike(path) ? normalized.toLowerCase() : normalized +} + +function preparationKey( + repoPath: string, + workspaceRoot: string, + baseBranch: string, + options: AddWorktreeOptions +): string { + return `${pathKey(repoPath)}\0${pathKey(workspaceRoot)}\0${baseBranch}\0${options.wslDistro ?? ''}` +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0) + return true + } catch (error) { + return (error as NodeJS.ErrnoException).code !== 'ESRCH' + } +} + +async function discardEntry(entry: PreparationEntry): Promise { + await entry.ready.catch(() => {}) + await discardPreparedWorktree(entry.repoPath, entry.preparedPath, entry.options).catch(() => {}) +} + +function expireEntry(entry: PreparationEntry): void { + if (preparations.get(entry.key) !== entry) { + return + } + preparations.delete(entry.key) + void discardEntry(entry) +} + +function enforcePreparationLimit(): void { + while (preparations.size >= WORKTREE_CREATE_PREPARATION_LIMIT) { + const oldest = [...preparations.values()].sort( + (left, right) => left.createdAt - right.createdAt + )[0] + if (!oldest) { + return + } + preparations.delete(oldest.key) + clearTimeout(oldest.expiration) + void discardEntry(oldest) + } +} + +async function cleanupStalePreparations( + repoPath: string, + options: AddWorktreeOptions +): Promise { + const cleanupKey = `${pathKey(repoPath)}\0${options.wslDistro ?? ''}` + const existing = staleCleanupInFlight.get(cleanupKey) + if (existing) { + await existing.catch(() => {}) + return + } + const cleanup = (async () => { + const worktrees = await listWorktreeGraph(repoPath, { + ...options, + includeCreatePreparations: true + }) + const staleWorktrees = worktrees.filter(isWorktreeCreatePreparation) + let nextIndex = 0 + async function discardNextStalePreparation(): Promise { + while (nextIndex < staleWorktrees.length) { + const worktree = staleWorktrees[nextIndex] + nextIndex += 1 + const lockOwnerPid = parseWorktreePreparationOwnerPid(worktree.lockReason) + const pathOwnerPid = parseWorktreePreparationPathOwnerPid(worktree.path) + if (!lockOwnerPid || isProcessAlive(lockOwnerPid)) { + continue + } + // Preserve a branch-attached final path after a crash; only detached or + // still-hidden preparations are safe to discard automatically. + if (worktree.branch && pathOwnerPid === null) { + await unlockPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } else if (pathOwnerPid === lockOwnerPid) { + await discardPreparedWorktree(repoPath, worktree.path, options).catch(() => {}) + } + } + } + const workerCount = Math.min(STALE_PREPARATION_CLEANUP_CONCURRENCY, staleWorktrees.length) + await Promise.all(Array.from({ length: workerCount }, () => discardNextStalePreparation())) + })() + staleCleanupInFlight.set(cleanupKey, cleanup) + try { + await cleanup.catch(() => {}) + } finally { + if (staleCleanupInFlight.get(cleanupKey) === cleanup) { + staleCleanupInFlight.delete(cleanupKey) + } + } +} + +export function prepareWorktreeCreateForRepo( + store: Store, + repo: Repo, + baseBranch: string +): Promise { + if (repo.connectionId || isFolderRepo(repo)) { + return Promise.resolve() + } + const options = getLocalProjectWorktreeGitOptions(store, repo) + const workspaceRoot = computeWorkspaceRoot( + repo.path, + getWorktreePathSettings(repo, store.getSettings()) + ) + const key = preparationKey(repo.path, workspaceRoot, baseBranch, options) + const existing = preparations.get(key) + if (existing) { + return existing.ready + } + + enforcePreparationLimit() + const preparationId = `${process.pid}-${randomUUID()}` + const lockReason = createWorktreePreparationLockReason(preparationId) + const preparedPath = pathOps(workspaceRoot).join( + workspaceRoot, + WORKTREE_CREATE_PREPARATION_DIRECTORY, + preparationId + ) + const entry = {} as PreparationEntry + const expiration = setTimeout(() => expireEntry(entry), WORKTREE_CREATE_PREPARATION_TTL_MS) + expiration.unref() + Object.assign(entry, { + key, + repoPath: repo.path, + workspaceRoot, + preparedPath, + options, + createdAt: Date.now(), + expiration, + ready: (async () => { + await cleanupStalePreparations(repo.path, options) + await mkdir( + toHostFilesystemPath( + pathOps(workspaceRoot).join(workspaceRoot, WORKTREE_CREATE_PREPARATION_DIRECTORY) + ), + { recursive: true } + ) + await prepareWorktreeCreateCheckout(repo.path, preparedPath, baseBranch, lockReason, options) + })() + } satisfies PreparationEntry) + preparations.set(key, entry) + void entry.ready.catch(() => { + if (preparations.get(key) === entry) { + preparations.delete(key) + clearTimeout(entry.expiration) + } + }) + return entry.ready +} + +async function claimPreparedWorktree( + repoPath: string, + workspaceRoot: string, + baseBranch: string, + options: AddWorktreeOptions +): Promise { + const key = preparationKey(repoPath, workspaceRoot, baseBranch, options) + const entry = preparations.get(key) + if (!entry) { + return null + } + preparations.delete(key) + clearTimeout(entry.expiration) + try { + await entry.ready + return entry + } catch { + return null + } +} + +export async function consumePreparedWorktreeCreate( + args: ConsumePreparedWorktreeArgs +): Promise { + const options = args.options ?? {} + const entry = await claimPreparedWorktree( + args.repoPath, + args.workspaceRoot, + args.baseBranch, + options + ) + if (!entry) { + return null + } + try { + await mkdir(toHostFilesystemPath(pathOps(args.worktreePath).dirname(args.worktreePath)), { + recursive: true + }) + return await finalizePreparedWorktree( + args.repoPath, + entry.preparedPath, + args.worktreePath, + args.branch, + args.baseBranch, + args.refreshLocalBaseRef, + options + ) + } catch (error) { + await discardPreparedWorktree(args.repoPath, entry.preparedPath, options).catch(() => {}) + console.warn( + '[worktree-create] prepared checkout could not be finalized; using normal add', + error + ) + return null + } +} + +export async function _resetWorktreeCreatePreparationsForTests(): Promise { + const entries = [...preparations.values()] + preparations.clear() + staleCleanupInFlight.clear() + await Promise.all( + entries.map(async (entry) => { + clearTimeout(entry.expiration) + await discardEntry(entry) + }) + ) +} diff --git a/src/shared/git-binary-compatibility.test.ts b/src/shared/git-binary-compatibility.test.ts index 712910cef6d..11fede2ae10 100644 --- a/src/shared/git-binary-compatibility.test.ts +++ b/src/shared/git-binary-compatibility.test.ts @@ -151,6 +151,36 @@ describeBinaryCompatibility('real Git binary compatibility', () => { await rm(join(repoPath, 'deferred-trash'), { recursive: true, force: true }) }) + it('supports prepared worktree creation and finalization', async () => { + await runGit(['worktree', 'add', '--detach', '--no-checkout', 'compat-prepared', 'HEAD']) + await runGit(['-C', 'compat-prepared', 'reset', '--hard', 'HEAD']) + await runGit([ + 'worktree', + 'lock', + '--reason', + 'orca-create-preparation:v1:compat', + 'compat-prepared' + ]) + // Why: `-f -f` moves a locked preparation while preserving its lock reason (Git >=2.25). + await runGit(['worktree', 'move', '-f', '-f', 'compat-prepared', 'compat-final']) + await runGit([ + '-C', + 'compat-final', + 'checkout', + '--no-track', + '-b', + 'compat-prepared-final', + 'HEAD' + ]) + + await expect(runGit(['-C', 'compat-final', 'branch', '--show-current'])).resolves.toMatchObject( + { stdout: 'compat-prepared-final\n' } + ) + await runGit(['worktree', 'unlock', 'compat-final']) + await runGit(['worktree', 'remove', '--force', 'compat-final']) + await runGit(['branch', '-D', 'compat-prepared-final']) + }) + it('recognizes ref and merge-tree compatibility boundaries', async () => { const fetchHeadPath = join(repoPath, '.git', 'FETCH_HEAD') await writeFile(fetchHeadPath, 'sentinel\n') diff --git a/src/shared/worktree/create-preparation.test.ts b/src/shared/worktree/create-preparation.test.ts new file mode 100644 index 00000000000..beaca89d186 --- /dev/null +++ b/src/shared/worktree/create-preparation.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, it } from 'vitest' +import { + createWorktreePreparationLockReason, + isWorktreeCreatePreparation, + parseWorktreePreparationPathOwnerPid, + WORKTREE_CREATE_PREPARATION_DIRECTORY +} from './create-preparation' + +describe('worktree create preparation classification', () => { + it('recognizes an explicitly locked preparation regardless of branch state', () => { + expect( + isWorktreeCreatePreparation({ + path: `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/123-checkout`, + branch: 'refs/heads/feature', + lockReason: createWorktreePreparationLockReason('test') + }) + ).toBe(true) + }) + + it('does not classify a branch-attached user worktree by path alone', () => { + expect( + isWorktreeCreatePreparation({ + path: `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/123-user-worktree`, + branch: 'refs/heads/feature', + lockReason: undefined + }) + ).toBe(false) + }) + + it('does not classify an unlocked detached path without durable ownership', () => { + expect( + isWorktreeCreatePreparation({ + path: `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/123-checkout`, + branch: undefined, + lockReason: undefined + }) + ).toBe(false) + }) + + it('does not parse an arbitrary preparation path with a numeric prefix', () => { + expect( + parseWorktreePreparationPathOwnerPid( + `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/123-checkout` + ) + ).toBeNull() + }) + + it('does not classify an arbitrary detached user path by directory name alone', () => { + expect( + isWorktreeCreatePreparation({ + path: `/workspace/${WORKTREE_CREATE_PREPARATION_DIRECTORY}/user-worktree`, + branch: undefined, + lockReason: undefined + }) + ).toBe(false) + }) +}) diff --git a/src/shared/worktree/create-preparation.ts b/src/shared/worktree/create-preparation.ts new file mode 100644 index 00000000000..8d98e75d75d --- /dev/null +++ b/src/shared/worktree/create-preparation.ts @@ -0,0 +1,42 @@ +export const WORKTREE_CREATE_PREPARATION_DIRECTORY = '.orca-preparing' +export const WORKTREE_CREATE_PREPARATION_LOCK_PREFIX = 'orca-create-preparation:v1:' +const WORKTREE_CREATE_PREPARATION_ID_PATTERN = + /^(\d+)-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i + +export function createWorktreePreparationLockReason(sessionId: string): string { + return `${WORKTREE_CREATE_PREPARATION_LOCK_PREFIX}${process.pid}:${sessionId}` +} + +export function parseWorktreePreparationOwnerPid(lockReason?: string): number | null { + if (!lockReason?.startsWith(WORKTREE_CREATE_PREPARATION_LOCK_PREFIX)) { + return null + } + const pid = Number(lockReason.slice(WORKTREE_CREATE_PREPARATION_LOCK_PREFIX.length).split(':')[0]) + return Number.isSafeInteger(pid) && pid > 0 ? pid : null +} + +export function parseWorktreePreparationPathOwnerPid(path: string): number | null { + const pathParts = path.split(/[\\/]+/) + const preparationIndex = pathParts.lastIndexOf(WORKTREE_CREATE_PREPARATION_DIRECTORY) + if (preparationIndex === -1) { + return null + } + const preparationId = pathParts[preparationIndex + 1] + if (!preparationId || !WORKTREE_CREATE_PREPARATION_ID_PATTERN.test(preparationId)) { + return null + } + const pid = Number(preparationId.split('-')[0]) + return Number.isSafeInteger(pid) && pid > 0 ? pid : null +} + +export function isWorktreeCreatePreparation(worktree: { + path: string + lockReason?: string + branch?: string +}): boolean { + // The Git lock reason is the durable ownership proof. A path can be chosen + // by a user (including for an uncommitted detached worktree), so path shape + // alone must never hide or force-remove it. A crash before locking may leave + // an unlocked detached entry for manual cleanup, but cannot delete user data. + return parseWorktreePreparationOwnerPid(worktree.lockReason) !== null +} diff --git a/tests/tools/benchmarks/worktree-create-speculation-bench.mjs b/tests/tools/benchmarks/worktree-create-speculation-bench.mjs new file mode 100644 index 00000000000..b6af96d761f --- /dev/null +++ b/tests/tools/benchmarks/worktree-create-speculation-bench.mjs @@ -0,0 +1,155 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process' +import { mkdtempSync, rmSync } from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { performance } from 'node:perf_hooks' + +function parseArgs(argv) { + const options = { repo: process.cwd(), base: 'HEAD', iterations: 5 } + const firstFlag = argv.findIndex((value, index) => index > 0 && value.startsWith('--')) + for (let index = firstFlag === -1 ? argv.length : firstFlag; index < argv.length; index += 1) { + const flag = argv[index] + const value = argv[index + 1] + if (flag === '--repo' && value) { + options.repo = path.resolve(value) + } else if (flag === '--base' && value) { + options.base = value + } else if (flag === '--iterations' && value && Number.isInteger(Number(value))) { + options.iterations = Number(value) + } else { + throw new Error(`Unknown or incomplete argument: ${flag}`) + } + index += 1 + } + if (options.iterations < 1) { + throw new Error('--iterations must be positive') + } + return options +} + +function git(repo, args) { + const result = spawnSync('git', ['-C', repo, ...args], { + encoding: 'utf8', + maxBuffer: 64 * 1024 * 1024, + windowsHide: true + }) + if (result.status !== 0) { + throw new Error(`git ${args.join(' ')} failed\n${result.stderr || result.stdout}`) + } + return result.stdout.trim() +} + +function time(operation) { + const startedAt = performance.now() + operation() + return performance.now() - startedAt +} + +function median(values) { + const sorted = [...values].sort((left, right) => left - right) + const middle = Math.floor(sorted.length / 2) + return sorted.length % 2 === 0 ? (sorted[middle - 1] + sorted[middle]) / 2 : sorted[middle] +} + +function summarize(samples) { + return { + medianMs: Number(median(samples).toFixed(1)), + minMs: Number(Math.min(...samples).toFixed(1)), + maxMs: Number(Math.max(...samples).toFixed(1)), + samplesMs: samples.map((sample) => Number(sample.toFixed(1))) + } +} + +function removeWorktree(repo, worktreePath, branch, locked = false) { + if (locked) { + git(repo, ['worktree', 'unlock', worktreePath]) + } + git(repo, ['worktree', 'remove', '--force', worktreePath]) + if (branch) { + git(repo, ['branch', '-D', branch]) + } +} + +function benchmark(options) { + const repo = git(options.repo, ['rev-parse', '--show-toplevel']) + const base = git(repo, ['rev-parse', '--verify', `${options.base}^{commit}`]) + const retargetBase = git(repo, ['rev-parse', '--verify', `${base}~20^{commit}`]) + const scratchRoot = mkdtempSync(path.join(path.dirname(repo), '.orca-create-bench-')) + const samples = { baseline: [], prepare: [], submit: [], retarget: [], cancel: [] } + const prefix = `orca-create-bench-${process.pid}-${Date.now()}` + + try { + for (let index = 0; index < options.iterations; index += 1) { + const baselinePath = path.join(scratchRoot, `baseline-${index}`) + const baselineBranch = `${prefix}-baseline-${index}` + samples.baseline.push( + time(() => + git(repo, ['worktree', 'add', '--no-track', '-b', baselineBranch, baselinePath, base]) + ) + ) + removeWorktree(repo, baselinePath, baselineBranch) + + const preparedPath = path.join(scratchRoot, `prepared-${index}`) + const finalPath = path.join(scratchRoot, `final-${index}`) + const finalBranch = `${prefix}-final-${index}` + samples.prepare.push( + time(() => { + git(repo, ['worktree', 'add', '--detach', '--no-checkout', preparedPath, base]) + git(preparedPath, ['reset', '--hard', base]) + git(repo, [ + 'worktree', + 'lock', + '--reason', + `orca-create-preparation:v1:${process.pid}:${index}`, + preparedPath + ]) + }) + ) + samples.submit.push( + time(() => { + const targetHead = git(repo, ['rev-parse', '--verify', `${base}^{commit}`]) + git(preparedPath, ['rev-parse', '--verify', 'HEAD']) + git(repo, ['worktree', 'move', '-f', '-f', preparedPath, finalPath]) + git(finalPath, ['checkout', '--no-track', '-b', finalBranch, targetHead]) + git(repo, ['worktree', 'unlock', finalPath]) + }) + ) + removeWorktree(repo, finalPath, finalBranch) + + const retargetPath = path.join(scratchRoot, `retarget-${index}`) + git(repo, ['worktree', 'add', '--detach', '--no-checkout', retargetPath, base]) + git(retargetPath, ['reset', '--hard', base]) + git(repo, ['worktree', 'lock', '--reason', 'orca-create-preparation:v1:bench', retargetPath]) + samples.retarget.push(time(() => git(retargetPath, ['reset', '--hard', retargetBase]))) + removeWorktree(repo, retargetPath, undefined, true) + + const cancelledPath = path.join(scratchRoot, `cancel-${index}`) + git(repo, ['worktree', 'add', '--detach', '--no-checkout', cancelledPath, base]) + git(cancelledPath, ['reset', '--hard', base]) + git(repo, ['worktree', 'lock', '--reason', 'orca-create-preparation:v1:bench', cancelledPath]) + samples.cancel.push(time(() => removeWorktree(repo, cancelledPath, undefined, true))) + } + } finally { + rmSync(scratchRoot, { force: true, recursive: true }) + git(repo, ['worktree', 'prune']) + } + + return { + platform: `${process.platform}-${process.arch}`, + os: os.release(), + gitVersion: git(repo, ['--version']), + repo, + trackedFiles: Number(git(repo, ['ls-files']).split('\n').filter(Boolean).length), + iterations: options.iterations, + base, + retargetBase, + baselineSubmit: summarize(samples.baseline), + speculativePrepare: summarize(samples.prepare), + speculativeSubmit: summarize(samples.submit), + changeBaseAfterPrepare: summarize(samples.retarget), + cancelPrepared: summarize(samples.cancel) + } +} + +console.log(JSON.stringify(benchmark(parseArgs(process.argv)), null, 2))