mirror of
https://github.com/stablyai/orca.git
synced 2026-10-05 00:02:33 +00:00
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.
288 lines
8.9 KiB
TypeScript
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)
|
|
}
|
|
}
|