From 04046fc4f2abf78a0beb53e224730592f63a4fa1 Mon Sep 17 00:00:00 2001 From: Neil <4138956+nwparker@users.noreply.github.com> Date: Sat, 4 Jul 2026 01:30:01 -0700 Subject: [PATCH] Fall back to local base ref when worktree-create refresh fails (#7335) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Creating a workspace hard-failed with "Could not refresh base ref …" whenever the pre-create git fetch of a remote-tracking base (e.g. origin/main) failed — offline, transient, or (common on Linux GUI) the Electron process lacking SSH_AUTH_SOCK so `ssh -o BatchMode=yes` cannot auth. This blocked creation even when a perfectly usable local origin/main already existed and `git worktree add` from it would succeed. Regression introduced by #2310 (5c93579e40), which replaced the prior best-effort fetch + post-create reconcile with a hard network gate. Fix: at all three create sites (local runtime, local IPC, SSH), only throw when the refresh failed AND no usable local base ref exists; otherwise create from the local ref (a possibly stale but valid base). Also: move the SSH local-ref probe after session.registerRoot so relays that gate generic git.exec don't false-negative and defeat the fallback. Co-authored-by: Orca --- src/main/ipc/worktree-remote.ts | 82 ++++++++++++++------ src/main/ipc/worktrees.test.ts | 104 +++++++++++++++++++++++++- src/main/runtime/orca-runtime.test.ts | 52 ++++++++++++- src/main/runtime/orca-runtime.ts | 8 +- 4 files changed, 217 insertions(+), 29 deletions(-) diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 86d18129826..c1003a10651 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -623,6 +623,26 @@ function hasRemoteCommitObject( return hasCommitObjectViaGitExec((gitArgs) => provider.exec(gitArgs, repoPath), ref) } +// Why: hasRemoteCommitObject only resolves full SHAs; a remote-tracking base is +// a symbolic ref (refs/remotes/origin/main), so detect its presence directly so +// SSH creates can fall back to an existing local base ref when the refresh +// fetch fails. Require a resolved object id: a missing ref exits non-zero. +async function hasRemoteTrackingRefSsh( + provider: SshGitProvider, + repoPath: string, + ref: string +): Promise { + try { + const { stdout } = await provider.exec( + ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`], + repoPath + ) + return stdout.trim().length > 0 + } catch { + return false + } +} + async function canCheckoutExistingLocalBranchSsh( provider: SshGitProvider, repoPath: string, @@ -1571,32 +1591,14 @@ export async function createRemoteWorktree( } } - if (remoteTrackingBase) { - try { - await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase) - } catch { - throw new Error( - `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` - ) - } - } else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) { - // Why: local or otherwise non-remote-tracking bases preserve legacy - // best-effort fetch behavior. Verified PR/MR SHA bases already have the - // commit object locally, so a broad remote fetch only updates unrelated refs. - try { - await fetchRemoteForWorktreeCreate(provider, repo, 'origin') - } catch { - /* best-effort */ - } - } - const mux = getActiveMultiplexer(repo.connectionId!) if (!mux) { throw new Error('SSH connection is not available. Please reconnect and try again.') } - // Why: register before the local-base advisory probe as well as addWorktree. - // Fresh/older relays may gate generic git.exec calls on registered roots; if - // the probe runs first it degrades to "no suggestion" even though create works. + // Why: register before any generic git.exec probe (base-ref existence, the + // local-base advisory) as well as addWorktree. Fresh/older relays may gate + // generic git.exec on registered roots; probing first would falsely report a + // ref missing (and defeat the offline fallback below) even though create works. try { await Promise.all([ mux.request('session.registerRoot', { rootPath: repo.path }), @@ -1611,6 +1613,34 @@ export async function createRemoteWorktree( } } + if (remoteTrackingBase) { + try { + await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, remoteTrackingBase) + } catch { + // Why: a failed refresh must not block creation when a usable local base + // ref already exists — `git worktree add` can still create from that + // (possibly stale but valid) ref, so a transient offline/auth failure does + // not make the workspace uncreatable. Probe AFTER registerRoot so relays + // that gate generic git.exec accept it; only hard-fail when there is no + // local ref to fall back on. Drift is reflected by the compare-to-base + // view once the remote is reachable again. + if (!(await hasRemoteTrackingRefSsh(provider, repo.path, remoteTrackingBase.ref))) { + throw new Error( + `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` + ) + } + } + } else if (!(await hasRemoteCommitObject(provider, repo.path, baseBranch))) { + // Why: local or otherwise non-remote-tracking bases preserve legacy + // best-effort fetch behavior. Verified PR/MR SHA bases already have the + // commit object locally, so a broad remote fetch only updates unrelated refs. + try { + await fetchRemoteForWorktreeCreate(provider, repo, 'origin') + } catch { + /* best-effort */ + } + } + const localBaseRefRefresh = settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase ? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase) @@ -2135,7 +2165,13 @@ export async function createLocalWorktree( if (remoteTrackingRefresh) { await timing.time('refresh_base_ref', async () => { const result = await remoteTrackingRefresh.promise - if (!result.ok) { + if (!result.ok && !remoteTrackingRefresh.hadLocalBaseRef) { + // Why: only block creation when the refresh failed AND there is no local + // base ref to fall back on. An existing local remote-tracking ref lets + // `git worktree add` proceed from a possibly stale but valid base, so a + // transient offline/auth failure must not make the workspace + // uncreatable. The compare-to-base view reflects any drift once the + // remote is reachable again. throw new Error( `Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.` ) diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 6f2857129e8..f619d078630 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -3668,7 +3668,7 @@ describe('registerWorktreeHandlers', () => { }) }) - it('does not create an SSH worktree when remote-tracking base refresh fails', async () => { + it('does not create an SSH worktree when the refresh fails and no local base ref exists', async () => { const repo = { id: 'repo-ssh', path: '/remote/repo', @@ -3679,6 +3679,7 @@ describe('registerWorktreeHandlers', () => { worktreeBaseRef: 'origin/main' } const provider = { + // Empty rev-parse stdout -> no local remote-tracking base ref to fall back on. exec: vi.fn().mockImplementation(async (args: string[]) => { if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } @@ -3719,6 +3720,70 @@ describe('registerWorktreeHandlers', () => { ) }) + it('creates an SSH worktree from an existing local base ref when the refresh fails', async () => { + // Regression: a failed SSH refresh must fall back to an existing local + // remote-tracking base ref instead of blocking creation. + 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: '' } + } + // Local remote-tracking base ref resolves -> usable fallback exists. + if (args[0] === 'rev-parse' && args.includes('refs/remotes/origin/main^{commit}')) { + return { stdout: `${'a'.repeat(40)}\n`, stderr: '' } + } + return { stdout: '', stderr: '' } + }), + fetchRemoteTrackingRef: vi.fn().mockRejectedValue(new Error('network unavailable')), + addWorktree: vi.fn().mockResolvedValue(undefined), + listWorktrees: vi + .fn() + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([ + { + path: '/remote/improve-dashboard', + head: 'abc123', + branch: 'refs/heads/improve-dashboard', + isBare: false, + isMainWorktree: false + } + ]) + } + const mux = { + request: vi.fn().mockResolvedValue(undefined), + notify: vi.fn() + } + store.getRepos.mockReturnValue([repo]) + store.getRepo.mockReturnValue(repo) + getSshGitProviderMock.mockReturnValue(provider) + getActiveMultiplexerMock.mockReturnValue(mux) + store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta) + + await handlers['worktrees:create'](null, { + repoId: 'repo-ssh', + name: 'improve-dashboard' + }) + + // The refresh must be ATTEMPTED (and fail) before falling back — guards + // against a regression that skips the refresh whenever a local ref exists. + expect(provider.fetchRemoteTrackingRef).toHaveBeenCalledWith( + '/remote/repo', + 'origin', + 'main', + 'refs/remotes/origin/main' + ) + expect(provider.addWorktree).toHaveBeenCalled() + }) + it('reuses a fresh SSH remote-tracking base refresh for repeated creates', async () => { const repo = { id: 'repo-ssh', @@ -4205,7 +4270,9 @@ describe('registerWorktreeHandlers', () => { ) }) - it('does not create when the pre-create remote-tracking refresh fails', async () => { + it('creates from an existing local base ref when the pre-create refresh fails', async () => { + // Regression: a failed refresh must not block creation when a usable local + // remote-tracking base ref already exists. const remoteBase = { remote: 'origin', branch: 'main', @@ -4218,6 +4285,39 @@ describe('registerWorktreeHandlers', () => { ok: false, errorKind: 'git_error' }) + 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' + })) as CreateWorktreeResult + + expect(addWorktreeMock).toHaveBeenCalled() + expect(result.worktree.id).toBe('repo-1::/workspace/improve-dashboard') + }) + + it('does not create when the pre-create refresh fails and no local base ref exists', async () => { + const remoteBase = { + remote: 'origin', + branch: 'main', + ref: 'refs/remotes/origin/main', + base: 'origin/main' + } + runtimeStub.resolveRemoteTrackingBase.mockResolvedValue(remoteBase) + runtimeStub.hasRemoteTrackingRef.mockResolvedValue(false) + runtimeStub.getOrStartRemoteTrackingBaseRefresh.mockResolvedValue({ + ok: false, + errorKind: 'git_error' + }) await expect( handlers['worktrees:create'](null, { diff --git a/src/main/runtime/orca-runtime.test.ts b/src/main/runtime/orca-runtime.test.ts index 87e0352f240..0b410939aba 100644 --- a/src/main/runtime/orca-runtime.test.ts +++ b/src/main/runtime/orca-runtime.test.ts @@ -2355,10 +2355,21 @@ describe('OrcaRuntimeService', () => { } }) - it('does not create runtime local worktrees when remote-tracking base refresh fails', async () => { + it('creates a runtime local worktree from an existing local base ref when the refresh fails', async () => { + // Regression: an offline/auth failure while refreshing the remote-tracking + // base must not block creation when a usable local base ref already exists. const runtime = new OrcaRuntimeService(store) - computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-refresh-fails') - ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/cli-refresh-fails') + const createdWorktree = { + path: '/tmp/workspaces/cli-refresh-fails', + head: 'base-sha', + branch: 'cli-refresh-fails', + isBare: false, + isMainWorktree: false + } + computeWorktreePathMock.mockReturnValue(createdWorktree.path) + ensurePathWithinWorkspaceMock.mockReturnValue(createdWorktree.path) + vi.mocked(addWorktree).mockResolvedValueOnce({}) + vi.mocked(listWorktrees).mockResolvedValue([createdWorktree]) const gitSpy = vi.spyOn(gitRunner, 'gitExecFileAsync').mockImplementation(async (args) => { if (args[0] === 'remote') { return { stdout: 'origin\n', stderr: '' } @@ -2366,6 +2377,7 @@ describe('OrcaRuntimeService', () => { if (args[0] === 'rev-parse' && args.includes('--git-common-dir')) { return { stdout: '/tmp/repo/.git\n', stderr: '' } } + // Local remote-tracking base ref resolves -> usable fallback exists. if (args[0] === 'rev-parse' && args[1] === '--verify') { return { stdout: 'base-sha\n', stderr: '' } } @@ -2380,6 +2392,40 @@ describe('OrcaRuntimeService', () => { repoSelector: 'id:repo-1', name: 'cli-refresh-fails' }) + ).resolves.toBeDefined() + + expect(addWorktree).toHaveBeenCalled() + } finally { + gitSpy.mockRestore() + } + }) + + it('does not create a runtime local worktree when the refresh fails and no local base ref exists', async () => { + const runtime = new OrcaRuntimeService(store) + computeWorktreePathMock.mockReturnValue('/tmp/workspaces/cli-refresh-no-local') + ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/cli-refresh-no-local') + 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: '' } + } + // No local remote-tracking base ref -> nothing to fall back on. + if (args[0] === 'rev-parse' && args[1] === '--verify') { + throw new Error('missing ref') + } + if (args[0] === 'fetch') { + throw new Error('network unavailable') + } + return { stdout: '', stderr: '' } + }) + try { + await expect( + runtime.createManagedWorktree({ + repoSelector: 'id:repo-1', + name: 'cli-refresh-no-local' + }) ).rejects.toThrow( 'Could not refresh base ref "origin/main" from "origin". Check your network and try again.' ) diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 5ae0c67d065..1ef8374f0d4 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -13075,7 +13075,13 @@ export class OrcaRuntimeService { remoteTrackingBase, ...localWorktreeGitOptionArgs ) - if (!refreshResult.ok) { + if (!refreshResult.ok && !hadLocalBaseRef) { + // Why: only block creation when the refresh failed AND there is no + // usable local base ref to fall back on. If a local remote-tracking ref + // already exists, `git worktree add` can create from it — a possibly + // stale but valid base — so a transient offline/auth failure must not + // make the workspace uncreatable. The compare-to-base view reflects any + // drift once the remote is reachable again. throw new Error( `Could not refresh base ref "${baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.` )