Files
orca/src/main/runtime/runtime-local-git-worktree-create.ts
T
Brennan Benson e5ade4b868 fix(worktrees): when the host can't fetch a remote base, create from the local branch and say so (#26009)
* fix(worktrees): create from the usable local base when there is no tracking ref, and say so, on the host's create

The host's create (agent.launch, worktree.create, the phone and the CLI) asked for a remote base like
origin/main with no tracking ref yet fetched it, and threw offline, even when the local branch existed;
it also never reported a base it fell back to. Like the desktop create, it now uses the local branch
without fetching and returns baseFallback, which the desktop window already turns into a notice.

* fix(worktrees): fetch the remote base first and fall back to the local branch only when the fetch fails

The host's create skipped the fetch whenever a local branch matched a
remote base with no tracking ref, so online phone/CLI/agent.launch creates
silently built on a possibly stale local branch, every time. Fetch first,
as main does; only when that fetch fails use the local branch the remote
names and report it with baseFallback. With no local branch the existing
error is unchanged.

Decide the base before naming so branch reuse and conflict checks, the add
and the persisted baseRef all see the base actually used, and return
baseFallback as its own field of the create result instead of riding on the
git add result.

* fix(worktrees): report the fallback for a local ref of the requested name, as the desktop create does

The tracking ref is still missing there; only the fetch is skipped, as before.

* test(worktrees): pin that the host create names only after the base fetch settles

Also pins the existing not-found-after-fetching error, now owned by the base decision.

* fix(worktrees): fall back to the local base only for creates a person asked for; keep the exact-name ref silent

Scheduled automations, orchestration workers and federation keep main's
"Check your network" error offline: nobody is watching to be told the
workspace was built on a possibly stale local branch. The fallback is a
host-side create option (allowLocalBaseFallback, default off, not on the
wire) that the worktree.create RPC and agent.launch set unless the request
carries automation provenance.

A local ref of the exact requested name goes back to silent, as on main's
host: reporting it showed "may not include the latest remote changes"
online for plain local branches.

* test(worktrees): pin the local-base opt-in at agent.launch and its absence for an automation's worktree.create
2026-10-07 00:55:21 -07:00

212 lines
7.8 KiB
TypeScript

import type { LocalGitExecOptions } from '../git/repo-default-base-ref'
import { runWorktreeChangeInvalidators } from '../ipc/worktree-change-invalidators'
import type { GitPushTarget, GitWorktreeInfo } from '../../shared/worktree/types'
import type { Repo } from '../../shared/repo-types'
import { resolveCreatedWorktree } from '../ipc/created-worktree-reconciliation'
import { normalizeSparseDirectories } from '../ipc/sparse-checkout-directories'
import { configureCreatedWorktreePushTarget } from '../ipc/worktree-remote'
import {
addSparseWorktree,
addWorktree,
type AddWorktreeOptions,
type AddWorktreeResult
} from '../git/worktree'
import type { RuntimeStore } from './runtime-store-contract'
import type { RuntimeManagedWorktreeCreateArgs } from './runtime-managed-worktree-create-types'
import type { RemoteFetchResult, RemoteTrackingBase } from './runtime-remote-fetch-controller'
import type { RuntimeLocalWorktreeCreateBase } from './runtime-local-worktree-create-base'
import { isGeneratedWorktreeCreateName } from '../worktree-create-candidates'
import {
consumePreparedWorktreeCreate,
type PreparationRearmHolder
} from '../worktree-create-preparation'
import {
failedWorktreeCreationNeedsRetirement,
retireGeneratedWorktreeName
} from '../worktree-name-retirement'
import type { WorktreeCreateTimingRecorder } from '../worktree-create-timing'
export async function createRuntimeLocalGitWorktree(args: {
request: RuntimeManagedWorktreeCreateArgs
repo: Repo
store: RuntimeStore
settings: {
workspaceDir: string
nestWorkspaces: boolean
refreshLocalBaseRefOnWorktreeCreate: boolean
localBaseRefSuggestionDismissed?: boolean
}
base: RuntimeLocalWorktreeCreateBase
workspaceRoot: string
branchName: string
worktreePath: string
effectiveSanitizedName?: string
checkoutExistingBranch: boolean
localWorktreeGitOptions: LocalGitExecOptions
refreshRemoteTrackingBase: (
repoPath: string,
base: RemoteTrackingBase,
options?: LocalGitExecOptions
) => Promise<RemoteFetchResult>
fetchRemote: (repoPath: string, remote: string, options?: LocalGitExecOptions) => Promise<void>
rearm: PreparationRearmHolder
timing: WorktreeCreateTimingRecorder
}): Promise<{
sparseDirectories: string[]
configuredPushTarget?: GitPushTarget
created: GitWorktreeInfo
addResult: AddWorktreeResult
}> {
const { baseBranch, remoteTrackingBase, deferredRefresh } = args.base
if (remoteTrackingBase && deferredRefresh === 'tracking_ref') {
await args.timing.time('refresh_base_ref', () =>
args.refreshRemoteTrackingBase(
args.repo.path,
remoteTrackingBase,
args.localWorktreeGitOptions
)
)
} else if (deferredRefresh === 'origin') {
try {
await args.timing.time('refresh_base_ref', () =>
args.fetchRemote(args.repo.path, 'origin', args.localWorktreeGitOptions)
)
} catch {}
}
const sparseDirectories = args.request.sparseCheckout
? normalizeSparseDirectories(args.request.sparseCheckout.directories)
: []
if (args.request.sparseCheckout && sparseDirectories.length === 0) {
throw new Error('Sparse checkout requires at least one repo-relative directory.')
}
// Why: defer the remote add + fetch (fork case) or the redundant re-fetch
// (same-repo case, already fetched while resolving the PR start point) to
// first use -- push/pull/fetch/fast-forward materialize it on demand
// (#17828). Metadata is persisted untouched; only the git mutation defers.
const preparedPushTarget = args.request.pushTarget
const suggestLocalBaseRefUpdate =
!args.settings.refreshLocalBaseRefOnWorktreeCreate &&
!args.settings.localBaseRefSuggestionDismissed &&
Boolean(remoteTrackingBase)
const remoteOption = remoteTrackingBase ? { remoteTrackingBase } : undefined
const preparedWorktreeOptions: AddWorktreeOptions = {
...remoteOption,
...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {}),
...args.localWorktreeGitOptions
}
const addOptions: AddWorktreeOptions = {
...preparedWorktreeOptions,
...(args.checkoutExistingBranch ? { checkoutExistingBranch: true } : {})
}
const shouldRetireGeneratedName =
args.request.nameWasGenerated === true &&
Boolean(args.effectiveSanitizedName) &&
isGeneratedWorktreeCreateName(args.effectiveSanitizedName!)
let addResult: AddWorktreeResult
try {
addResult = await args.timing.time('git_worktree_add', async () => {
if (sparseDirectories.length > 0 || args.checkoutExistingBranch) {
args.timing.recordPreparedCheckout({
status: 'miss',
reason: sparseDirectories.length > 0 ? 'sparse_checkout' : 'checkout_existing_branch'
})
} else {
const preparedAttempt = await consumePreparedWorktreeCreate({
repoPath: args.repo.path,
workspaceRoot: args.workspaceRoot,
worktreePath: args.worktreePath,
branch: args.branchName,
baseBranch,
refreshLocalBaseRef: args.settings.refreshLocalBaseRefOnWorktreeCreate,
options: preparedWorktreeOptions,
timing: args.timing
})
if (preparedAttempt.status === 'hit') {
// Deferred, not fired: re-arming is a full `reset --hard`, and the caller still has
// materialization probes and terminals ahead of it.
args.rearm.fire = preparedAttempt.rearm
return preparedAttempt.result
}
if (preparedAttempt.rearm) {
args.rearm.fire = preparedAttempt.rearm
}
}
if (sparseDirectories.length > 0) {
return (
(await addSparseWorktree(
args.repo.path,
args.worktreePath,
args.branchName,
sparseDirectories,
baseBranch,
args.settings.refreshLocalBaseRefOnWorktreeCreate,
addOptions
)) ?? {}
)
}
return (
(await addWorktree(
args.repo.path,
args.worktreePath,
args.branchName,
baseBranch,
args.settings.refreshLocalBaseRefOnWorktreeCreate,
false,
addOptions
)) ?? {}
)
})
} catch (error) {
if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) {
await retireGeneratedWorktreeName(
args.store as Parameters<typeof retireGeneratedWorktreeName>[0],
args.repo,
args.settings,
args.effectiveSanitizedName!
)
}
throw error
}
// Why: the worktree is listable from here on; scans that began before it appeared are stale.
runWorktreeChangeInvalidators(args.repo.id)
if (shouldRetireGeneratedName) {
await retireGeneratedWorktreeName(
args.store as Parameters<typeof retireGeneratedWorktreeName>[0],
args.repo,
args.settings,
args.effectiveSanitizedName!
)
}
// Why: `--set-upstream-to` requires the remote to already exist -- safe for a
// same-repo target (its remote, e.g. `origin`, always exists) but not for a
// deferred fork remote, which is materialized lazily at first push/pull/fetch.
const configuredPushTarget =
preparedPushTarget && !preparedPushTarget.remoteUrl
? await configureCreatedWorktreePushTarget(
args.worktreePath,
args.branchName,
preparedPushTarget,
args.localWorktreeGitOptions
)
: preparedPushTarget
const { created, worktrees, listingComplete } = await args.timing.time(
'list_created_worktree',
() =>
resolveCreatedWorktree(
args.repo.path,
args.worktreePath,
args.branchName,
args.localWorktreeGitOptions
)
)
if (listingComplete) {
args.timing.recordWorktreeCount(worktrees.length)
}
return {
sparseDirectories,
...(configuredPushTarget ? { configuredPushTarget } : {}),
created,
addResult
}
}