mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
perf(worktree): prepare checkouts while the composer is open
Squashed merge of PR #17290.
This commit is contained in:
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 & {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
Reference in New Issue
Block a user