mirror of
https://github.com/stablyai/orca.git
synced 2026-09-23 08:02:31 +00:00
Refresh optimistic worktrees after base fetch (#2310)
* Refresh optimistic worktrees after base fetch * Refresh remote-tracking bases before creating worktrees - Ensure local and SSH worktree creation fetches the exact remote base ref before `git worktree add`, so new worktrees do not start from stale refs. - Fail creation before mutating git state when the required base refresh fails or remains missing. - Replace post-create repair/reconcile behavior with read-only drift reporting and add coverage for refresh ordering, cache bypassing, and SSH failures. * Queue exact base refreshes during remote fetches * rm design doc
This commit is contained in:
+129
-122
@@ -48,13 +48,6 @@ import { invalidateAuthorizedRootsCache } from './filesystem-auth'
|
||||
import { createWorktreeSymlinks } from './worktree-symlinks'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
|
||||
async function readCommitSha(repoPath: string, ref: string): Promise<string> {
|
||||
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', `${ref}^{commit}`], {
|
||||
cwd: repoPath
|
||||
})
|
||||
return stdout.trim()
|
||||
}
|
||||
|
||||
async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise<string | null> {
|
||||
const target = parseGitHubOwnerRepo(remoteUrl)
|
||||
try {
|
||||
@@ -289,17 +282,50 @@ async function configureCreatedWorktreePushTargetSsh(
|
||||
return target
|
||||
}
|
||||
|
||||
async function resolveRemoteTrackingBaseSsh(
|
||||
provider: SshGitProvider,
|
||||
repoPath: string,
|
||||
baseBranch: string
|
||||
): Promise<RemoteTrackingBase | null> {
|
||||
let remotes: string[]
|
||||
try {
|
||||
const { stdout } = await provider.exec(['remote'], repoPath)
|
||||
remotes = stdout
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
|
||||
const remote = remotes
|
||||
.filter((candidate) => baseBranch.startsWith(`${candidate}/`))
|
||||
.sort((a, b) => b.length - a.length)[0]
|
||||
if (!remote) {
|
||||
return null
|
||||
}
|
||||
const branch = baseBranch.slice(remote.length + 1)
|
||||
if (!branch) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
remote,
|
||||
branch,
|
||||
ref: `refs/remotes/${remote}/${branch}`,
|
||||
base: baseBranch
|
||||
}
|
||||
}
|
||||
|
||||
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
|
||||
if (!mainWindow.isDestroyed()) {
|
||||
mainWindow.webContents.send('worktrees:changed', { repoId })
|
||||
}
|
||||
}
|
||||
|
||||
// Why (§3.3): two-phase spinner. Main process fires `'fetching'` immediately
|
||||
// after kicking off `git fetch` and `'creating'` after that fetch resolves
|
||||
// (or is determined to be cache-fresh). Renderer swaps its spinner label in
|
||||
// response; fallback is the static "Creating worktree..." label if no event
|
||||
// arrives (e.g. renderer races destruction of the window).
|
||||
// Why: two-phase spinner. Main process fires `'fetching'` before waiting on
|
||||
// pre-create fetch work and `'creating'` immediately before `git worktree add`.
|
||||
// Renderer swaps its spinner label in response; fallback is the static
|
||||
// "Creating worktree..." label if no event arrives.
|
||||
export function emitCreateWorktreeProgress(
|
||||
mainWindow: BrowserWindow,
|
||||
phase: 'fetching' | 'creating'
|
||||
@@ -385,12 +411,33 @@ export async function createRemoteWorktree(
|
||||
)
|
||||
}
|
||||
|
||||
// Fetch latest
|
||||
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
|
||||
try {
|
||||
await provider.exec(['fetch', remote], repo.path)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
const remoteTrackingBase = await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch)
|
||||
if (remoteTrackingBase) {
|
||||
try {
|
||||
await provider.exec(
|
||||
[
|
||||
'fetch',
|
||||
'--no-tags',
|
||||
remoteTrackingBase.remote,
|
||||
`+refs/heads/${remoteTrackingBase.branch}:${remoteTrackingBase.ref}`
|
||||
],
|
||||
repo.path
|
||||
)
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
} else {
|
||||
// Why: local or otherwise non-remote-tracking bases preserve legacy
|
||||
// best-effort fetch behavior. Only remote-tracking bases must fail closed,
|
||||
// because creating from them after a failed refresh silently makes stale worktrees.
|
||||
const fallbackRemote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
|
||||
try {
|
||||
await provider.exec(['fetch', fallbackRemote], repo.path)
|
||||
} catch {
|
||||
/* best-effort */
|
||||
}
|
||||
}
|
||||
|
||||
let preparedPushTarget: GitPushTarget | undefined
|
||||
@@ -524,10 +571,9 @@ export async function createLocalWorktree(
|
||||
? sanitizeWorktreeDisplayName(args.displayName)
|
||||
: undefined
|
||||
|
||||
// Why (§3.3): determine the base branch (and therefore the remote we need to
|
||||
// fetch) FIRST, so the fetch can overlap all pre-create work below. Neither
|
||||
// of these calls depends on the suffix loop / PR probe / branch-conflict
|
||||
// resolution, so they are safe to hoist.
|
||||
// Why: resolve the base before branch/path selection so remote-tracking bases
|
||||
// can be refreshed before `git worktree add`. Creating first and repairing
|
||||
// later races setup scripts, agents, and user edits.
|
||||
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
|
||||
if (!baseBranch) {
|
||||
// Why: getDefaultBaseRef may return null when none of origin/HEAD,
|
||||
@@ -540,31 +586,23 @@ export async function createLocalWorktree(
|
||||
)
|
||||
}
|
||||
|
||||
let optimisticBase: RemoteTrackingBase | null = null
|
||||
let optimisticFetchPromise: Promise<RemoteFetchResult> | null = null
|
||||
let initialBaseStatus: CreateWorktreeResult['initialBaseStatus']
|
||||
let remoteTrackingBase: RemoteTrackingBase | null = null
|
||||
let remoteTrackingRefresh: {
|
||||
base: RemoteTrackingBase
|
||||
hadLocalBaseRef: boolean
|
||||
promise: Promise<RemoteFetchResult>
|
||||
} | null = null
|
||||
let legacyFetchPromise: Promise<void> | null = null
|
||||
|
||||
if (runtime) {
|
||||
optimisticBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch)
|
||||
if (optimisticBase) {
|
||||
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, optimisticBase)
|
||||
if (hasLocalBaseRef) {
|
||||
const isFresh = await runtime.isRemoteFetchFresh(repo.path, optimisticBase.remote)
|
||||
if (!isFresh) {
|
||||
optimisticFetchPromise = runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
|
||||
}
|
||||
} else {
|
||||
emitCreateWorktreeProgress(mainWindow, 'fetching')
|
||||
const result = await runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
|
||||
if (!(await runtime.hasRemoteTrackingRef(repo.path, optimisticBase))) {
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`Could not fetch base ref "${baseBranch}" from "${optimisticBase.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
|
||||
}
|
||||
remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch)
|
||||
if (remoteTrackingBase) {
|
||||
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
|
||||
emitCreateWorktreeProgress(mainWindow, 'fetching')
|
||||
remoteTrackingRefresh = {
|
||||
base: remoteTrackingBase,
|
||||
hadLocalBaseRef: hasLocalBaseRef,
|
||||
promise: runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
|
||||
}
|
||||
} else {
|
||||
// Why: when the base branch does not match a configured remote prefix
|
||||
@@ -593,6 +631,40 @@ export async function createLocalWorktree(
|
||||
const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null
|
||||
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
|
||||
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
|
||||
|
||||
// Why: this validation does not depend on remote refs, so it can overlap a
|
||||
// required remote-tracking base refresh.
|
||||
const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup
|
||||
if (primarySetupScript) {
|
||||
shouldRunSetupForCreate(repo, args.setupDecision)
|
||||
}
|
||||
const sparseDirectories = args.sparseCheckout
|
||||
? normalizeSparseDirectories(args.sparseCheckout.directories)
|
||||
: []
|
||||
if (args.sparseCheckout && sparseDirectories.length === 0) {
|
||||
throw new Error('Sparse checkout requires at least one repo-relative directory.')
|
||||
}
|
||||
let sparsePresetId: string | undefined
|
||||
if (args.sparseCheckout?.presetId) {
|
||||
const preset = store
|
||||
.getSparsePresets(repo.id)
|
||||
.find((entry) => entry.id === args.sparseCheckout?.presetId)
|
||||
if (preset?.repoId === repo.id) {
|
||||
try {
|
||||
const presetDirectories = normalizeSparseDirectories(preset.directories)
|
||||
// Why: use Set-based comparison so directory order does not affect
|
||||
// attribution — matches the renderer's sparseDirectoriesMatch logic.
|
||||
const presetSet = new Set(presetDirectories)
|
||||
const directoriesMatch =
|
||||
presetDirectories.length === sparseDirectories.length &&
|
||||
sparseDirectories.every((entry) => presetSet.has(entry))
|
||||
sparsePresetId = directoriesMatch ? preset.id : undefined
|
||||
} catch {
|
||||
// Why: corrupt preset data should not block creation or falsely label the new worktree.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let effectiveRequestedName = requestedName
|
||||
let effectiveSanitizedName = sanitizedName
|
||||
let branchName = ''
|
||||
@@ -682,39 +754,18 @@ export async function createLocalWorktree(
|
||||
)
|
||||
}
|
||||
|
||||
// Why: `ask` is a pre-create choice gate, not a post-create side effect.
|
||||
// Resolve it before mutating git state so missing UI input cannot strand
|
||||
// a real worktree on disk while the renderer reports "create failed". The
|
||||
// actual run/skip decision is recomputed after the worktree exists against
|
||||
// the worktree-bound setup script.
|
||||
const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup
|
||||
if (primarySetupScript) {
|
||||
shouldRunSetupForCreate(repo, args.setupDecision)
|
||||
}
|
||||
const sparseDirectories = args.sparseCheckout
|
||||
? normalizeSparseDirectories(args.sparseCheckout.directories)
|
||||
: []
|
||||
if (args.sparseCheckout && sparseDirectories.length === 0) {
|
||||
throw new Error('Sparse checkout requires at least one repo-relative directory.')
|
||||
}
|
||||
let sparsePresetId: string | undefined
|
||||
if (args.sparseCheckout?.presetId) {
|
||||
const preset = store
|
||||
.getSparsePresets(repo.id)
|
||||
.find((entry) => entry.id === args.sparseCheckout?.presetId)
|
||||
if (preset?.repoId === repo.id) {
|
||||
try {
|
||||
const presetDirectories = normalizeSparseDirectories(preset.directories)
|
||||
// Why: use Set-based comparison so directory order does not affect
|
||||
// attribution — matches the renderer's sparseDirectoriesMatch logic.
|
||||
const presetSet = new Set(presetDirectories)
|
||||
const directoriesMatch =
|
||||
presetDirectories.length === sparseDirectories.length &&
|
||||
sparseDirectories.every((entry) => presetSet.has(entry))
|
||||
sparsePresetId = directoriesMatch ? preset.id : undefined
|
||||
} catch {
|
||||
// Why: corrupt preset data should not block creation or falsely label the new worktree.
|
||||
}
|
||||
if (remoteTrackingRefresh) {
|
||||
const result = await remoteTrackingRefresh.promise
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
if (
|
||||
!remoteTrackingRefresh.hadLocalBaseRef &&
|
||||
!(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base))
|
||||
) {
|
||||
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -860,53 +911,9 @@ export async function createLocalWorktree(
|
||||
}
|
||||
}
|
||||
|
||||
if (runtime && optimisticBase && optimisticFetchPromise) {
|
||||
initialBaseStatus = {
|
||||
repoId: repo.id,
|
||||
worktreeId,
|
||||
status: 'checking',
|
||||
base: optimisticBase.base,
|
||||
remote: optimisticBase.remote
|
||||
}
|
||||
runtime.emitWorktreeBaseStatus(initialBaseStatus)
|
||||
// Why: record the reconcile token BEFORE the rev-parse await so a racing
|
||||
// worktree remove during the await isn't a no-op (its
|
||||
// clearOptimisticReconcileToken would then run before the token exists,
|
||||
// letting the post-await record install a fresh token whose reconcile
|
||||
// would re-populate base status for a worktree that no longer exists).
|
||||
const token = runtime.recordOptimisticReconcileToken(worktreeId)
|
||||
try {
|
||||
const createdBaseSha = await readCommitSha(created.path, 'HEAD')
|
||||
void runtime
|
||||
.reconcileWorktreeBaseStatus({
|
||||
repoId: repo.id,
|
||||
repoPath: repo.path,
|
||||
worktreeId,
|
||||
base: optimisticBase,
|
||||
branchName,
|
||||
createdBaseSha,
|
||||
token,
|
||||
fetchPromise: optimisticFetchPromise
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn(`[worktree-base-status] reconcile failed for ${worktreeId}:`, error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.warn(`[worktree-base-status] failed to read created base for ${worktreeId}:`, error)
|
||||
runtime.emitWorktreeBaseStatus({
|
||||
repoId: repo.id,
|
||||
worktreeId,
|
||||
status: 'unknown',
|
||||
base: optimisticBase.base,
|
||||
remote: optimisticBase.remote
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
notifyWorktreesChanged(mainWindow, repo.id)
|
||||
return {
|
||||
worktree,
|
||||
...(setup ? { setup } : {}),
|
||||
...(initialBaseStatus ? { initialBaseStatus } : {})
|
||||
...(setup ? { setup } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -186,7 +186,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
||||
const runtimeStub = {
|
||||
resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null),
|
||||
hasRemoteTrackingRef: vi.fn().mockResolvedValue(false),
|
||||
isRemoteFetchFresh: vi.fn().mockResolvedValue(false),
|
||||
getOrStartRemoteTrackingBaseRefresh: vi.fn().mockResolvedValue({ ok: true }),
|
||||
getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }),
|
||||
fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined),
|
||||
emitWorktreeBaseStatus: vi.fn(),
|
||||
|
||||
+126
-22
@@ -176,7 +176,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
let runtimeStub: {
|
||||
resolveRemoteTrackingBase: ReturnType<typeof vi.fn>
|
||||
hasRemoteTrackingRef: ReturnType<typeof vi.fn>
|
||||
isRemoteFetchFresh: ReturnType<typeof vi.fn>
|
||||
getOrStartRemoteTrackingBaseRefresh: ReturnType<typeof vi.fn>
|
||||
getOrStartRemoteFetch: ReturnType<typeof vi.fn>
|
||||
fetchRemoteWithCache: ReturnType<typeof vi.fn>
|
||||
emitWorktreeBaseStatus: ReturnType<typeof vi.fn>
|
||||
@@ -319,7 +319,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
runtimeStub = {
|
||||
resolveRemoteTrackingBase: vi.fn().mockResolvedValue(null),
|
||||
hasRemoteTrackingRef: vi.fn().mockResolvedValue(false),
|
||||
isRemoteFetchFresh: vi.fn().mockResolvedValue(false),
|
||||
getOrStartRemoteTrackingBaseRefresh: vi.fn().mockResolvedValue({ ok: true }),
|
||||
getOrStartRemoteFetch: vi.fn().mockResolvedValue({ ok: true }),
|
||||
fetchRemoteWithCache: vi.fn().mockResolvedValue(undefined),
|
||||
emitWorktreeBaseStatus: vi.fn(),
|
||||
@@ -761,7 +761,12 @@ describe('registerWorktreeHandlers', () => {
|
||||
worktreeBaseRef: 'origin/main'
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockResolvedValue({ stdout: '', stderr: '' }),
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
@@ -811,6 +816,50 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('does not create an SSH worktree when remote-tracking base refresh fails', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: '/remote/repo',
|
||||
displayName: 'ssh',
|
||||
badgeColor: '#000',
|
||||
addedAt: 0,
|
||||
connectionId: 'conn-1',
|
||||
worktreeBaseRef: 'origin/main'
|
||||
}
|
||||
const provider = {
|
||||
exec: vi.fn().mockImplementation(async (args: string[]) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
throw new Error('network unavailable')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
addWorktree: vi.fn(),
|
||||
listWorktrees: vi.fn()
|
||||
}
|
||||
const mux = {
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn()
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getActiveMultiplexerMock.mockReturnValue(mux)
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
|
||||
)
|
||||
|
||||
expect(provider.addWorktree).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('prunes stale child lineage after a successful SSH worktree scan proves the child is missing', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
@@ -909,7 +958,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('does not await a cold fetch when the remote-tracking base exists locally', async () => {
|
||||
it('awaits a cold refresh before creating from an existing remote-tracking base', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
@@ -922,7 +971,7 @@ describe('registerWorktreeHandlers', () => {
|
||||
})
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||||
runtimeStub.getOrStartRemoteFetch.mockReturnValue(pendingFetch)
|
||||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockReturnValue(pendingFetch)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
@@ -939,33 +988,88 @@ describe('registerWorktreeHandlers', () => {
|
||||
name: 'improve-dashboard'
|
||||
}) as Promise<unknown>
|
||||
|
||||
const result = await Promise.race([
|
||||
createPromise,
|
||||
new Promise((resolve) => setTimeout(() => resolve('timed-out'), 0))
|
||||
const earlyResult = await Promise.race([
|
||||
createPromise.then(() => 'resolved'),
|
||||
new Promise((resolve) => setTimeout(() => resolve('pending'), 0))
|
||||
])
|
||||
expect(result).not.toBe('timed-out')
|
||||
expect(earlyResult).toBe('pending')
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
|
||||
expect(runtimeStub.getOrStartRemoteFetch).toHaveBeenCalledWith('/workspace/repo', 'origin')
|
||||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
remoteBase
|
||||
)
|
||||
expect(runtimeStub.fetchRemoteWithCache).not.toHaveBeenCalled()
|
||||
expect(runtimeStub.emitWorktreeBaseStatus).toHaveBeenCalledWith({
|
||||
repoId: 'repo-1',
|
||||
worktreeId: 'repo-1::/workspace/improve-dashboard',
|
||||
status: 'checking',
|
||||
base: 'origin/main',
|
||||
remote: 'origin'
|
||||
})
|
||||
expect(runtimeStub.reconcileWorktreeBaseStatus).toHaveBeenCalledWith(
|
||||
resolveFetch()
|
||||
const result = await createPromise
|
||||
expect(addWorktreeMock).toHaveBeenCalled()
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
createdBaseSha: 'created-sha',
|
||||
fetchPromise: pendingFetch
|
||||
worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' })
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('does not create when the pre-create remote-tracking refresh fails', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||||
runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({
|
||||
ok: false,
|
||||
errorKind: 'git_error'
|
||||
})
|
||||
|
||||
await expect(
|
||||
handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
|
||||
)
|
||||
|
||||
expect(addWorktreeMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('refreshes before create even when the remote-tracking base was recently fetched', async () => {
|
||||
const remoteBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase)
|
||||
runtimeStub.hasRemoteTrackingRef.mockResolvedValue(true)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: '/workspace/improve-dashboard',
|
||||
head: 'created-sha',
|
||||
branch: 'improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'created-sha\n', stderr: '' })
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard'
|
||||
})
|
||||
|
||||
expect(runtimeStub.getOrStartRemoteTrackingBaseRefresh).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
remoteBase
|
||||
)
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
initialBaseStatus: expect.objectContaining({ status: 'checking', base: 'origin/main' })
|
||||
worktree: expect.objectContaining({ id: 'repo-1::/workspace/improve-dashboard' })
|
||||
})
|
||||
)
|
||||
resolveFetch()
|
||||
})
|
||||
|
||||
it('throws a clear error when no default base ref can be resolved', async () => {
|
||||
|
||||
@@ -129,4 +129,139 @@ describe('OrcaRuntimeService.fetchRemoteWithCache', () => {
|
||||
base: 'foo/bar/main'
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes a remote-tracking base with an exact no-tags refspec', async () => {
|
||||
mockFetchResults([{ stdout: '', stderr: '' }])
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
|
||||
await runtime.getOrStartRemoteTrackingBaseRefresh('/repo/f', {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
})
|
||||
|
||||
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
|
||||
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
|
||||
{ cwd: '/repo/f' }
|
||||
)
|
||||
})
|
||||
|
||||
it('shares an in-flight remote-tracking base refresh without using freshness cache', async () => {
|
||||
let resolveFetch!: () => void
|
||||
const pending = new Promise<{ stdout: string; stderr: string }>((resolve) => {
|
||||
resolveFetch = () => resolve({ stdout: '', stderr: '' })
|
||||
})
|
||||
mockFetchResults([pending, { stdout: '', stderr: '' }])
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
const base = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
|
||||
const first = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base)
|
||||
const second = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(fetchCallCount()).toBe(1)
|
||||
|
||||
resolveFetch()
|
||||
await Promise.all([first, second])
|
||||
await runtime.getOrStartRemoteTrackingBaseRefresh('/repo/g', base)
|
||||
|
||||
expect(fetchCallCount()).toBe(2)
|
||||
})
|
||||
|
||||
it('queues a full remote fetch behind an in-flight remote-tracking base refresh', async () => {
|
||||
let resolveBaseFetch!: () => void
|
||||
let resolveFullFetch!: () => void
|
||||
const pendingBaseFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => {
|
||||
resolveBaseFetch = () => resolve({ stdout: '', stderr: '' })
|
||||
})
|
||||
const pendingFullFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => {
|
||||
resolveFullFetch = () => resolve({ stdout: '', stderr: '' })
|
||||
})
|
||||
mockFetchResults([pendingBaseFetch, pendingFullFetch])
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
const base = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
|
||||
const baseRefresh = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/h', base)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(fetchCallCount()).toBe(1)
|
||||
|
||||
const fullFetch = runtime.getOrStartRemoteFetch('/repo/h', 'origin')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(fetchCallCount()).toBe(1)
|
||||
|
||||
resolveBaseFetch()
|
||||
await vi.waitFor(() => expect(fetchCallCount()).toBe(2))
|
||||
resolveFullFetch()
|
||||
|
||||
await expect(Promise.all([baseRefresh, fullFetch])).resolves.toEqual([
|
||||
{ ok: true },
|
||||
{ ok: true }
|
||||
])
|
||||
const fetchCalls = gitExecFileAsyncMock.mock.calls.filter(
|
||||
([argv]) => Array.isArray(argv) && argv[0] === 'fetch'
|
||||
)
|
||||
expect(fetchCalls).toEqual([
|
||||
[
|
||||
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
|
||||
{ cwd: '/repo/h' }
|
||||
],
|
||||
[['fetch', 'origin'], { cwd: '/repo/h' }]
|
||||
])
|
||||
})
|
||||
|
||||
it('queues an exact base refresh behind an in-flight full remote fetch', async () => {
|
||||
let resolveFullFetch!: () => void
|
||||
let resolveBaseFetch!: () => void
|
||||
const pendingFullFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => {
|
||||
resolveFullFetch = () => resolve({ stdout: '', stderr: '' })
|
||||
})
|
||||
const pendingBaseFetch = new Promise<{ stdout: string; stderr: string }>((resolve) => {
|
||||
resolveBaseFetch = () => resolve({ stdout: '', stderr: '' })
|
||||
})
|
||||
mockFetchResults([pendingFullFetch, pendingBaseFetch])
|
||||
const runtime = new OrcaRuntimeService(null)
|
||||
const base = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
|
||||
const fullFetch = runtime.getOrStartRemoteFetch('/repo/i', 'origin')
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(fetchCallCount()).toBe(1)
|
||||
|
||||
const baseRefresh = runtime.getOrStartRemoteTrackingBaseRefresh('/repo/i', base)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
expect(fetchCallCount()).toBe(1)
|
||||
|
||||
resolveFullFetch()
|
||||
await vi.waitFor(() => expect(fetchCallCount()).toBe(2))
|
||||
resolveBaseFetch()
|
||||
|
||||
await expect(Promise.all([fullFetch, baseRefresh])).resolves.toEqual([
|
||||
{ ok: true },
|
||||
{ ok: true }
|
||||
])
|
||||
const fetchCalls = gitExecFileAsyncMock.mock.calls.filter(
|
||||
([argv]) => Array.isArray(argv) && argv[0] === 'fetch'
|
||||
)
|
||||
expect(fetchCalls).toEqual([
|
||||
[['fetch', 'origin'], { cwd: '/repo/i' }],
|
||||
[
|
||||
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
|
||||
{ cwd: '/repo/i' }
|
||||
]
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -733,6 +733,97 @@ describe('OrcaRuntimeService', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('refreshes runtime remote-tracking bases before creating local worktrees', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const refresh = deferred<{ stdout: string; stderr: string }>()
|
||||
const createdWorktree = {
|
||||
path: '/tmp/workspaces/cli-fresh-base',
|
||||
head: 'def',
|
||||
branch: 'cli-fresh-base',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
computeWorktreePathMock.mockReturnValue(createdWorktree.path)
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path)
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([createdWorktree])
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
|
||||
return { stdout: '/tmp/repo/.git\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--verify') {
|
||||
return { stdout: 'base-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
return refresh.promise
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
try {
|
||||
const createPromise = runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'cli-fresh-base'
|
||||
})
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(gitSpy).toHaveBeenCalledWith(
|
||||
['fetch', '--no-tags', 'origin', '+refs/heads/main:refs/remotes/origin/main'],
|
||||
{ cwd: TEST_REPO_PATH }
|
||||
)
|
||||
})
|
||||
expect(addWorktree).not.toHaveBeenCalled()
|
||||
|
||||
refresh.resolve({ stdout: '', stderr: '' })
|
||||
const result = await createPromise
|
||||
|
||||
expect(addWorktree).toHaveBeenCalledWith(
|
||||
TEST_REPO_PATH,
|
||||
createdWorktree.path,
|
||||
'cli-fresh-base',
|
||||
'origin/main',
|
||||
false
|
||||
)
|
||||
expect(result.worktree).toMatchObject({ path: createdWorktree.path })
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('does not create runtime local worktrees when remote-tracking base refresh fails', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => {
|
||||
if (args[0] === 'remote') {
|
||||
return { stdout: 'origin\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) {
|
||||
return { stdout: '/tmp/repo/.git\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'rev-parse' && args[1] === '--verify') {
|
||||
return { stdout: 'base-sha\n', stderr: '' }
|
||||
}
|
||||
if (args[0] === 'fetch') {
|
||||
throw new Error('network unavailable')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
})
|
||||
try {
|
||||
await expect(
|
||||
runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'cli-refresh-fails'
|
||||
})
|
||||
).rejects.toThrow(
|
||||
'Could not refresh base ref "origin/main" from "origin". Check your network and try again.'
|
||||
)
|
||||
|
||||
expect(addWorktree).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('creates a branchNameOverride worktree from the selected matching remote base ref', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
vi.spyOn(gitRunner, 'gitExecFileAsync').mockResolvedValue({ stdout: '', stderr: '' })
|
||||
@@ -5485,6 +5576,193 @@ describe('OrcaRuntimeService', () => {
|
||||
expect(worktreeBaseStatus).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
const remoteTrackingBase = {
|
||||
remote: 'origin',
|
||||
branch: 'main',
|
||||
ref: 'refs/remotes/origin/main',
|
||||
base: 'origin/main'
|
||||
}
|
||||
|
||||
function createReconcileRuntime(): {
|
||||
runtime: OrcaRuntimeService
|
||||
worktreeBaseStatus: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
const worktreeBaseStatus = vi.fn()
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({
|
||||
worktreeBaseStatus,
|
||||
worktreeRemoteBranchConflict: vi.fn()
|
||||
} as never)
|
||||
return { runtime, worktreeBaseStatus }
|
||||
}
|
||||
|
||||
function mockReconcileGit(options: {
|
||||
postFetchSha?: string
|
||||
ancestor?: boolean
|
||||
baseRefMissing?: boolean
|
||||
}) {
|
||||
const { postFetchSha = 'new-base-sha', ancestor = true, baseRefMissing = false } = options
|
||||
|
||||
return vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args, options) => {
|
||||
const command = args as string[]
|
||||
const cwd = (options as { cwd?: string } | undefined)?.cwd
|
||||
if (
|
||||
cwd === TEST_REPO_PATH &&
|
||||
command[0] === 'rev-parse' &&
|
||||
command[1] === '--verify' &&
|
||||
command[2] === `${remoteTrackingBase.ref}^{commit}`
|
||||
) {
|
||||
if (baseRefMissing) {
|
||||
throw new Error('missing base ref')
|
||||
}
|
||||
return { stdout: `${postFetchSha}\n`, stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_REPO_PATH && command[0] === 'merge-base') {
|
||||
if (!ancestor) {
|
||||
throw new Error('not ancestor')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_REPO_PATH && command[0] === 'rev-list') {
|
||||
return { stdout: '3\n', stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_REPO_PATH && command[0] === 'log') {
|
||||
return { stdout: 'base commit 3\nbase commit 2\n', stderr: '' }
|
||||
}
|
||||
if (cwd === TEST_REPO_PATH && command[0] === 'config') {
|
||||
throw new Error('config missing')
|
||||
}
|
||||
if (
|
||||
cwd === TEST_REPO_PATH &&
|
||||
command[0] === 'rev-parse' &&
|
||||
command[1] === '--verify' &&
|
||||
command[2] === 'refs/remotes/origin/feature^{commit}'
|
||||
) {
|
||||
throw new Error('no publish branch conflict')
|
||||
}
|
||||
throw new Error(`unexpected git command: ${command.join(' ')}`)
|
||||
})
|
||||
}
|
||||
|
||||
async function reconcileWithToken(runtime: OrcaRuntimeService, token: string): Promise<void> {
|
||||
await runtime.reconcileWorktreeBaseStatus({
|
||||
repoId: TEST_REPO_ID,
|
||||
repoPath: TEST_REPO_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
base: remoteTrackingBase,
|
||||
branchName: 'feature',
|
||||
createdBaseSha: 'created-base-sha',
|
||||
token,
|
||||
fetchPromise: Promise.resolve({ ok: true })
|
||||
})
|
||||
}
|
||||
|
||||
it('emits drift without mutating when the fetched base fast-forwards created HEAD', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({})
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
status: 'drift',
|
||||
behind: 3,
|
||||
recentSubjects: ['base commit 3', 'base commit 2']
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits current when the fetched base still matches created HEAD', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ postFetchSha: 'created-base-sha' })
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith({
|
||||
repoId: TEST_REPO_ID,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
base: 'origin/main',
|
||||
remote: 'origin',
|
||||
status: 'current'
|
||||
})
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits base_changed without mutation when the fetched base rewrote history', async () => {
|
||||
const { runtime, worktreeBaseStatus } = createReconcileRuntime()
|
||||
const token = runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ ancestor: false })
|
||||
try {
|
||||
await reconcileWithToken(runtime, token)
|
||||
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(worktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'base_changed' })
|
||||
)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('skips stale-token reconciles without mutating or emitting stale status', async () => {
|
||||
const stale = createReconcileRuntime()
|
||||
const staleToken = stale.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
stale.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const staleGitSpy = mockReconcileGit({})
|
||||
try {
|
||||
await reconcileWithToken(stale.runtime, staleToken)
|
||||
expect(stale.worktreeBaseStatus).not.toHaveBeenCalled()
|
||||
expect(staleGitSpy).not.toHaveBeenCalled()
|
||||
} finally {
|
||||
staleGitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('emits unknown without mutation when fetch fails or the base ref is missing', async () => {
|
||||
const fetchFailure = createReconcileRuntime()
|
||||
const fetchFailureToken = fetchFailure.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
await fetchFailure.runtime.reconcileWorktreeBaseStatus({
|
||||
repoId: TEST_REPO_ID,
|
||||
repoPath: TEST_REPO_PATH,
|
||||
worktreeId: TEST_WORKTREE_ID,
|
||||
base: remoteTrackingBase,
|
||||
branchName: 'feature',
|
||||
createdBaseSha: 'created-base-sha',
|
||||
token: fetchFailureToken,
|
||||
fetchPromise: Promise.resolve({ ok: false, errorKind: 'git_error' })
|
||||
})
|
||||
expect(fetchFailure.worktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'unknown' })
|
||||
)
|
||||
|
||||
const missingBase = createReconcileRuntime()
|
||||
const missingBaseToken = missingBase.runtime.recordOptimisticReconcileToken(TEST_WORKTREE_ID)
|
||||
const gitSpy = mockReconcileGit({ baseRefMissing: true })
|
||||
try {
|
||||
await reconcileWithToken(missingBase.runtime, missingBaseToken)
|
||||
expect(gitSpy).not.toHaveBeenCalledWith(['reset', '--hard', 'new-base-sha'], {
|
||||
cwd: TEST_WORKTREE_PATH
|
||||
})
|
||||
expect(missingBase.worktreeBaseStatus).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'unknown' })
|
||||
)
|
||||
} finally {
|
||||
gitSpy.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('invalidates the filesystem-auth cache after CLI worktree creation', async () => {
|
||||
// Reproduces: CLI-created worktrees fail with "Access denied: unknown
|
||||
// repository or worktree path" because the filesystem-auth cache was
|
||||
|
||||
@@ -925,16 +925,21 @@ export class OrcaRuntimeService {
|
||||
// vice-versa. Keyed by `<repoPath>::<remote>` so multi-remote repos (even
|
||||
// though v1 only uses `origin`) don't cross-contaminate. The in-flight Map
|
||||
// also provides serialization — two concurrent callers share a single
|
||||
// underlying `git fetch`. Lifecycle rules are enforced in
|
||||
// `fetchRemoteWithCache` and MUST NOT be duplicated elsewhere:
|
||||
// underlying `git fetch`. Full-remote fetch lifecycle rules:
|
||||
// - entry inserted BEFORE await,
|
||||
// - `.finally()` removes the entry on BOTH success and rejection,
|
||||
// - timestamp written ONLY on success (rejection must not make the
|
||||
// 30s freshness cache lie).
|
||||
// A literal "insert before await / read-back after await" without these
|
||||
// three rules wedges all future creates on the same repo after a single
|
||||
// DNS hiccup until process restart (see §3.3 Lifecycle).
|
||||
// three rules wedges future fetches on the same repo after a single
|
||||
// DNS hiccup until process restart (see §3.3 Lifecycle). Exact base-ref
|
||||
// refreshes share the in-flight rule but intentionally do not write the
|
||||
// full-remote freshness timestamp.
|
||||
private fetchInflight = new Map<string, Promise<RemoteFetchResult>>()
|
||||
// Why: `git fetch origin` and `git fetch origin <refspec>` contend for the
|
||||
// same repo remote/ref locks. This queue serializes all fetch shapes for one
|
||||
// canonical repo+remote while still letting same-shape callers share promises.
|
||||
private remoteFetchQueueTail = new Map<string, Promise<RemoteFetchResult>>()
|
||||
private fetchLastCompletedAt = new Map<string, number>()
|
||||
// Why: `getCanonicalFetchKey` is awaited from every freshness probe and
|
||||
// every getOrStartRemoteFetch call. Without memoization the warm-cache hot
|
||||
@@ -5758,19 +5763,33 @@ export class OrcaRuntimeService {
|
||||
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
|
||||
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
|
||||
worktreePath = ensurePathWithinWorkspace(worktreePath, workspaceRoot)
|
||||
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
|
||||
// Why (§3.3 Lifecycle): route through the shared fetch cache so back-to-back
|
||||
// CLI creates on the same repo don't each pay the round-trip, and so a
|
||||
// subsequent dispatch probe within the 30s window reuses this result. The
|
||||
// helper swallows rejection (log-and-proceed) so a DNS hiccup never wedges
|
||||
// future creates and CLI creation stays usable offline — same intent as
|
||||
// the previous try/catch around gitExecFileSync.
|
||||
try {
|
||||
await this.fetchRemoteWithCache(repo.path, remote)
|
||||
} catch {
|
||||
// Why: belt-and-suspenders. fetchRemoteWithCache already logs and does
|
||||
// not throw; the outer try/catch guarantees create-path tolerance even
|
||||
// if future refactors change that contract.
|
||||
const remoteTrackingBase = await this.resolveRemoteTrackingBase(repo.path, baseBranch)
|
||||
if (remoteTrackingBase) {
|
||||
const hadLocalBaseRef = await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
|
||||
const refreshResult = await this.getOrStartRemoteTrackingBaseRefresh(
|
||||
repo.path,
|
||||
remoteTrackingBase
|
||||
)
|
||||
if (!refreshResult.ok) {
|
||||
throw new Error(
|
||||
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
|
||||
)
|
||||
}
|
||||
if (!hadLocalBaseRef && !(await this.hasRemoteTrackingRef(repo.path, remoteTrackingBase))) {
|
||||
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
|
||||
}
|
||||
} else {
|
||||
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
|
||||
// Why: local bases keep legacy best-effort fetch behavior. Remote-tracking
|
||||
// bases fail closed above because stale create-from-base is worse than a
|
||||
// clear retryable error.
|
||||
try {
|
||||
await this.fetchRemoteWithCache(repo.path, remote)
|
||||
} catch {
|
||||
// Why: belt-and-suspenders. fetchRemoteWithCache already logs and does
|
||||
// not throw; the outer try/catch guarantees create-path tolerance even
|
||||
// if future refactors change that contract.
|
||||
}
|
||||
}
|
||||
|
||||
const sparseDirectories = args.sparseCheckout
|
||||
@@ -6123,9 +6142,8 @@ export class OrcaRuntimeService {
|
||||
|
||||
/**
|
||||
* Fetch `remote` in `repoPath`, sharing the 30s freshness window + in-flight
|
||||
* serialization with all other callers (renderer-create path, CLI create,
|
||||
* dispatch drift probe). Never rejects — callers log-and-proceed on offline
|
||||
* failures (§3.3 Lifecycle).
|
||||
* serialization with all other callers. Never rejects — callers
|
||||
* log-and-proceed on offline failures (§3.3 Lifecycle).
|
||||
*
|
||||
* Why a shared cache on the runtime instead of module-scoped: §7.1 relies on
|
||||
* one cache for BOTH the renderer create path and `probeWorktreeDrift`. A
|
||||
@@ -6157,10 +6175,19 @@ export class OrcaRuntimeService {
|
||||
return resolved
|
||||
}
|
||||
|
||||
async isRemoteFetchFresh(repoPath: string, remote: string): Promise<boolean> {
|
||||
const key = await this.getCanonicalFetchKey(repoPath, remote)
|
||||
const lastAt = this.fetchLastCompletedAt.get(key)
|
||||
return lastAt !== undefined && Date.now() - lastAt < FETCH_FRESHNESS_MS
|
||||
private enqueueRemoteFetch(
|
||||
remoteKey: string,
|
||||
runFetch: () => Promise<RemoteFetchResult>
|
||||
): Promise<RemoteFetchResult> {
|
||||
const previous = this.remoteFetchQueueTail.get(remoteKey)
|
||||
const promise = previous ? previous.then(runFetch, runFetch) : runFetch()
|
||||
this.remoteFetchQueueTail.set(remoteKey, promise)
|
||||
promise.finally(() => {
|
||||
if (this.remoteFetchQueueTail.get(remoteKey) === promise) {
|
||||
this.remoteFetchQueueTail.delete(remoteKey)
|
||||
}
|
||||
})
|
||||
return promise
|
||||
}
|
||||
|
||||
async getOrStartRemoteFetch(repoPath: string, remote: string): Promise<RemoteFetchResult> {
|
||||
@@ -6180,26 +6207,59 @@ export class OrcaRuntimeService {
|
||||
return existing
|
||||
}
|
||||
|
||||
const promise = gitExecFileAsync(['fetch', remote], { cwd: repoPath })
|
||||
.then((): RemoteFetchResult => {
|
||||
// Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection
|
||||
// would make the freshness cache lie about the last known remote state.
|
||||
this.fetchLastCompletedAt.set(key, Date.now())
|
||||
return { ok: true }
|
||||
})
|
||||
.catch((err): RemoteFetchResult => {
|
||||
// Why: swallow here so awaiters don't throw at the await site. Outer
|
||||
// create/dispatch paths are already tolerant of offline fetch failure;
|
||||
// this is the behavioral contract of this helper.
|
||||
console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err)
|
||||
return { ok: false, errorKind: 'git_error' }
|
||||
})
|
||||
.finally(() => {
|
||||
// Why (§3.3 Lifecycle): evict on BOTH success and rejection. A
|
||||
// rejected entry that survived in the Map would wedge every future
|
||||
// create on this repo until Orca restarted (the F2 bug §3.3 pins).
|
||||
this.fetchInflight.delete(key)
|
||||
})
|
||||
const promise = this.enqueueRemoteFetch(key, () =>
|
||||
gitExecFileAsync(['fetch', remote], { cwd: repoPath })
|
||||
.then((): RemoteFetchResult => {
|
||||
// Why (§3.3 Lifecycle): timestamp on success ONLY. Writing on rejection
|
||||
// would make the freshness cache lie about the last known remote state.
|
||||
this.fetchLastCompletedAt.set(key, Date.now())
|
||||
return { ok: true }
|
||||
})
|
||||
.catch((err): RemoteFetchResult => {
|
||||
// Why: swallow here so awaiters don't throw at the await site. Outer
|
||||
// create/dispatch paths are already tolerant of offline fetch failure;
|
||||
// this is the behavioral contract of this helper.
|
||||
console.warn(`[fetchRemoteWithCache] ${remote} fetch failed for ${repoPath}:`, err)
|
||||
return { ok: false, errorKind: 'git_error' }
|
||||
})
|
||||
).finally(() => {
|
||||
// Why (§3.3 Lifecycle): evict on BOTH success and rejection. A
|
||||
// rejected entry that survived in the Map would wedge every future
|
||||
// create on this repo until Orca restarted (the F2 bug §3.3 pins).
|
||||
this.fetchInflight.delete(key)
|
||||
})
|
||||
|
||||
this.fetchInflight.set(key, promise)
|
||||
return promise
|
||||
}
|
||||
|
||||
async getOrStartRemoteTrackingBaseRefresh(
|
||||
repoPath: string,
|
||||
base: RemoteTrackingBase
|
||||
): Promise<RemoteFetchResult> {
|
||||
const remoteKey = await this.getCanonicalFetchKey(repoPath, base.remote)
|
||||
const key = await this.getCanonicalFetchKey(repoPath, `base:${base.remote}:${base.branch}`)
|
||||
const existing = this.fetchInflight.get(key)
|
||||
if (existing) {
|
||||
return existing
|
||||
}
|
||||
|
||||
const promise = this.enqueueRemoteFetch(remoteKey, () =>
|
||||
gitExecFileAsync(
|
||||
['fetch', '--no-tags', base.remote, `+refs/heads/${base.branch}:${base.ref}`],
|
||||
{ cwd: repoPath }
|
||||
)
|
||||
.then((): RemoteFetchResult => ({ ok: true }))
|
||||
.catch((err): RemoteFetchResult => {
|
||||
console.warn(
|
||||
`[refreshRemoteTrackingBase] ${base.base} refresh failed for ${repoPath}:`,
|
||||
err
|
||||
)
|
||||
return { ok: false, errorKind: 'git_error' }
|
||||
})
|
||||
).finally(() => {
|
||||
this.fetchInflight.delete(key)
|
||||
})
|
||||
|
||||
this.fetchInflight.set(key, promise)
|
||||
return promise
|
||||
|
||||
Reference in New Issue
Block a user