Files
orca/src/main/git/worktree-create-preparation.ts
T
Neil a4cd00ed18 fix(wsl): drop the linked-worktree Git route cache after worktree mutations (#17791)
On Windows with a WSL distro configured, `prepareWslLinkedWorktreeGitRouting`
caches for 30s which Git owns a drive-letter checkout, by reading that checkout's
`.git` marker. `git worktree add/move/remove` rewrites exactly that marker, so
the verdict could stay authoritative for up to 30s after it stopped being true.

- Add `invalidateWslLinkedWorktreeGitRouting(cwd)`: drops the cached route and
  the probe retry backoff for that path and for anything under it (a submodule
  inside the worktree derived its route from the same marker walk). Eight calls
  at six sites: `worktree add`, `worktree move` (both paths), `worktree remove`,
  the prepared checkout's add, the finalize move (both paths), and the
  prepared-worktree discard. Five sites invalidate from a `finally`, because a
  Git failure can still have rewritten the marker; the prepared checkout's add
  invalidates on the success path only, since its failure path runs the discard,
  which has its own `finally`.
- Split the parent-directory marker walk into
  `wsl-linked-worktree-git-route-probe.ts` (the routing module was at the
  `max-lines` ceiling) and have it report whether the walk settled. A `.git`
  file with no `gitdir:` line is a half-written marker mid `worktree add`: it is
  now retried under the existing backoff instead of cached for 30s. The route it
  yields is unchanged (distro); only the number of parent walks changes.

An invalidation only drops cached state; a probe already in flight is left to
finish and cache normally, so a mutation landing mid-probe is no worse off than
main's 30s TTL. The cost is that a failed mutation also drops a still-correct
route: `gitExecFileAsync` and `gitStreamStdout` re-resolve the route after their
own `prepareWslLinkedWorktreeGitRouting`, and `gitSpawn` resolves again after the
git-admission wait, so a command already in flight for that path can take the
empty-cache default and run a host-owned checkout under `wsl.exe git`. It fails
once and self-heals on the next command.

Reachable only on win32 + configured WSL distro + drive-letter cwd; every other
platform and configuration never populates this cache, so the new calls scan two
empty maps.
2026-08-31 20:35:49 -07:00

288 lines
8.9 KiB
TypeScript

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'
import { invalidateWslLinkedWorktreeGitRouting } from './wsl-linked-worktree-git-routing'
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.
}
try {
await gitExecFileAsync(
[...windowsLongPathGitArgs(repoPath), 'worktree', 'remove', '--force', worktreePath],
cleanupGitOptions
)
} finally {
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
}
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() }
)
// The add just wrote the marker; drop any pre-create route before the reset routes Git.
invalidateWslLinkedWorktreeGitRouting(worktreePath)
// 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 [targetHeadResult, preparedHeadResult] = await Promise.all([
gitExecFileAsync(
['rev-parse', '--verify', `${baseContext.effectiveBase}^{commit}`],
gitExecOptions(repoPath, finalizeGitOptions)
),
gitExecFileAsync(
['rev-parse', '--verify', 'HEAD'],
gitExecOptions(preparedPath, finalizeGitOptions)
)
])
const { stdout: targetHeadOutput } = targetHeadResult
const targetHead = targetHeadOutput.trim()
const { stdout: preparedHeadOutput } = preparedHeadResult
if (preparedHeadOutput.trim() !== targetHead) {
await gitExecFileAsync(
[...windowsLongPathGitArgs(preparedPath), 'reset', '--hard', targetHead],
gitExecOptions(preparedPath, finalizeGitOptions)
)
}
let moved = false
try {
try {
// Why: `-f -f` moves the locked preparation while preserving its lock reason (Git >=2.25).
await gitExecFileAsync(
[
...windowsLongPathGitArgs(repoPath),
'worktree',
'move',
'-f',
'-f',
preparedPath,
worktreePath
],
gitExecOptions(repoPath, finalizeGitOptions)
)
moved = true
} finally {
// The move rewrites both `.git` markers, and a failure can have rewritten one.
invalidateWslLinkedWorktreeGitRouting(preparedPath)
invalidateWslLinkedWorktreeGitRouting(worktreePath)
}
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)
}
}