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:
parent
f215a48064
commit
04046fc4f2
|
|
@ -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.`
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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.'
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.`
|
||||
)
|
||||
|
|
|
|||
Loading…
Reference in New Issue