perf(worktree): prepare checkouts while the composer is open

Squashed merge of PR #17290.
This commit is contained in:
Neil
2026-08-30 12:12:04 -07:00
committed by GitHub
parent a8183884bd
commit 3ab9766e38
20 changed files with 1384 additions and 63 deletions
+78 -44
View File
@@ -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<WorktreeAddBaseContext> {
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<void> {
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/<branch> (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 } : {})
@@ -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)
})
})
+270
View File
@@ -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<void> {
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<void> {
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<void> {
try {
await runWithGitReadCacheInvalidation(() =>
performDiscardPreparedWorktree(repoPath, worktreePath, options)
)
} finally {
notifyPreparedWorktreeMutation(repoPath)
}
}
export async function unlockPreparedWorktree(
repoPath: string,
worktreePath: string,
options: GitWorktreeExecOptions = {}
): Promise<void> {
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<void> {
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<AddWorktreeResult> {
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)
}
}
+13 -3
View File
@@ -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<GitWorktreeInfo[]> {
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<GitWorktreeInfo[]> {
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(
@@ -18,6 +18,7 @@ export type GitWorktreeExecOptions = {
wslDistro?: string
signal?: AbortSignal
timeout?: number
includeCreatePreparations?: boolean
}
export type WorktreeRemovalPreflightOptions = GitWorktreeExecOptions & {
+1 -1
View File
@@ -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
+6
View File
@@ -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'
+14 -1
View File
@@ -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 {
+18
View File
@@ -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(
@@ -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.
}
@@ -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 () => {
+1 -1
View File
@@ -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
+32 -2
View File
@@ -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)
+11 -10
View File
@@ -44,7 +44,7 @@ async function prefetchLocalWorktreeCreateBase(
repo: Repo,
baseBranch: string | undefined,
runtime: WorktreeCreateBasePrefetchRuntime
): Promise<void> {
): Promise<string | undefined> {
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<void> {
}): Promise<string | undefined> {
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)
}
@@ -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)
})
})
+283
View File
@@ -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<void>
expiration: NodeJS.Timeout
}
type ConsumePreparedWorktreeArgs = {
repoPath: string
workspaceRoot: string
worktreePath: string
branch: string
baseBranch: string
refreshLocalBaseRef?: boolean
options?: AddWorktreeOptions
}
const preparations = new Map<string, PreparationEntry>()
const staleCleanupInFlight = new Map<string, Promise<void>>()
function pathOps(path: string): Pick<typeof posix, 'dirname' | 'join' | 'normalize'> {
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<void> {
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<void> {
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<void> {
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<void> {
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<PreparationEntry | null> {
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<AddWorktreeResult | null> {
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<void> {
const entries = [...preparations.values()]
preparations.clear()
staleCleanupInFlight.clear()
await Promise.all(
entries.map(async (entry) => {
clearTimeout(entry.expiration)
await discardEntry(entry)
})
)
}
@@ -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')
@@ -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)
})
})
+42
View File
@@ -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
}
@@ -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))