Fall back to local base ref when worktree-create refresh fails (#7335)

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 <help@stably.ai>
This commit is contained in:
Neil 2026-07-04 01:30:01 -07:00 committed by GitHub
parent f215a48064
commit 04046fc4f2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 217 additions and 29 deletions

View File

@ -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<boolean> {
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.`
)

View File

@ -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, {

View File

@ -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.'
)

View File

@ -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.`
)