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:
Jinjing
2026-05-19 00:42:23 -07:00
committed by GitHub
parent 140ceb7fda
commit 5c93579e40
6 changed files with 773 additions and 189 deletions
+129 -122
View File
@@ -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 } : {})
}
}
+1 -1
View File
@@ -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
View File
@@ -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 () => {
+135
View File
@@ -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' }
]
])
})
})
+278
View File
@@ -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
+104 -44
View File
@@ -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