Files
orca/src/main/runtime/runtime-local-git-worktree-create.ts
T
Neil b4ba3e97ff perf(worktree): defer fork-PR remote creation from create-time to first use (#17922)
* perf(worktree): defer fork-PR remote creation from create-time to first use

Fork-PR review worktrees eagerly ran `git remote add` + `git fetch` for the
contributor's fork (and pinned branch.<x>.remote) at create time, even for a
read-only review. That grows remote count unboundedly with review volume and
pays a network fetch nobody asked for yet.

Defer prepareWorktreePushTarget(Ssh) and the --set-upstream-to configure step
at create time (local + SSH, IPC + runtime create paths); persist the
pushTarget metadata untouched. Materialize the remote on demand the first
time push/pull/fetch/fast-forward actually needs it, via two shared
functions (materializeWorktreePushTargetRemote(Ssh)) reused across the
legacy IPC handlers and the RPC runtime sync commands. A cheap
`remote get-url <name>` probe keeps steady-state calls down to one extra
subprocess once materialized, instead of repeating the O(remotes) scan.

Add repo-local `remote.<name>.orca-created` config provenance, written when
the remote is added, so cleanup can recognize ownership of a remote that was
lazily materialized (and therefore never round-tripped through the store's
`remoteCreated` flag).

Refs #17828

* perf(worktree): materialize a deferred fork-PR remote on terminal spawn

An agent running raw git in a freshly opened fork-PR review terminal has no
usable upstream until an Orca-driven sync happens -- "sync through Orca
first" isn't available mid-task, and git pull/log @{u}.. hard-fail without
one (verified against real git). Fire the same on-demand materialization
used by push/pull/fetch/fast-forward from the single terminal-spawn
resolver (resolveTerminalWorkspaceLaunchTarget), fire-and-forget, so a
newly opened terminal gets a working upstream without blocking spawn.

* fix(worktree): retest deferred fork-remote CI failures, fix SSH provenance-marker RPC

Rewrites the 5 CI failures on the deferred fork-remote change (#17828) as
evidence, not fixtures: the SSH relay-upgrade/rollback/sibling-ownership
tests move to materializeWorktreePushTargetRemoteSsh, where that
unchanged logic now actually runs (create defers it to first sync).

While writing a stricter test that routes its mock exec through the
relay's real validateGitExecArgs, found that the SSH provenance-marker
write (`git config remote.<name>.orca-created true`) was unconditionally
rejected by the relay's generic git.exec (it blocks all non-read-only
config writes) -- a real bug that would break every SSH fork-remote
materialization against a live relay. Fixes it with a narrow
git.markRemoteOrcaCreated RPC, mirroring renameCurrentBranch, with a
graceful no-op fallback for relays that predate it.

* fix(worktree): scope post-#17887 test assertions past narrow-refspec config calls

Rebasing onto #17887's narrow-refspec `remote add` broke two broad `['config']`
call-filters into false positives/negatives, and the local materialize test still
asserted the pre-#17887 wide `remote add`/fetch-refspec forms.

* fix(worktree): restructure upstream restore, persist provenance, widen short-circuit refspec (#17828 review)

- Move upstream restoration to the materializer level so it runs on both the
  remoteAlreadyMatchesUrl short-circuit and the full-prepare path, not just
  buried inside prepare*.
- Persist {remoteCreated, remoteName} to the store on materialize so #17842's
  orphan sweep can see a lazily-created remote, including via desktop IPC,
  terminal-spawn, and the RPC host-callback paths.
- Widen the refspec on the local short-circuit path too (SSH's bare `remote
  add` refspec gap remains a documented, pre-existing limitation).
- Fetch the branch's tracking ref before restoring upstream when the
  short-circuit widens onto a *new* branch on an already-existing remote --
  a bare refspec-config widen never itself imports anything, so
  `branch --set-upstream-to` was hard-failing for a sibling worktree's first
  materialize (found via a real-git fixture, not just mocked unit tests).
  Skipped when the ref already exists so the common repeat-call case stays a
  local-only probe with no network round-trip.

* fix(worktree): merge duplicate shared/worktree/types import

oxlint --deny-warnings flags the split import as no-duplicates; full pnpm lint
was failing on it after the #17828 review restructuring.

* fix(worktree): scope the deferred fetch timeout to fetch calls, retarget stale create-time assertions

CI on the previous push failed 3 shards, all argument-shape mismatches:

- worktrees-wsl-runtime-routing.test.ts: the "restructure upstream restore" commit
  wrapped every call `prepareWorktreePushTarget` makes (remote, remote add, config,
  fetch) with DEFERRED_PUSH_TARGET_FETCH_TIMEOUT_MS, not just the network fetch. Local
  git subprocesses never need a timeout; scope it to `args[0] === 'fetch'` only,
  matching the short-circuit path's existing pattern. Updated the test to expect the
  timeout on the fetch call specifically (point 5 legitimately adds it there), while
  every other call stays untimed.

- worktrees-create-metadata-persistence.test.ts (2 tests): stale from before this
  session -- create no longer mints a fork remote at all (#17828 deferred that to
  first sync), so asserting `remote add`/`fetch`/`remoteCreated: true` at create time
  no longer matches reality. Retargeted both tests to assert the deferred contract
  (no remote add at create, pushTarget persisted unmaterialized); minting itself
  stays covered by worktree-remote-push-target-materialization.test.ts and
  worktree-push-target-setup.test.ts.

Re-verified all 5 fixture points (mint upstream, store persistence, single-flight,
short-circuit refspec widen + fetch-missing-ref for local and SSH, finite timeout)
against a real git fixture after this fix -- all still pass.

* fix(worktree): hook pty:spawn into deferred push-target materialization (#17828)

triggerTerminalSpawnPushTargetMaterialization only fired for agent/background/
mobile terminals; the desktop GUI's own pty:spawn path (new tab, split,
reattach) never materialized a deferred fork-PR remote before raw git
commands could run there. Add a small wrapper that resolves the worktree's
push target and owning repo from args.worktreeId via the store, and
fire-and-forget delegates to the existing materializer, wired as the first
statement of runPtyIpcSpawn. Degrades silently (optional chaining + catch)
so a partial/fake Store in existing spawn tests can't turn this into a
spawn-blocking throw.

* test(worktree): retarget stale editor-remote-branch assertions for worktreeId threading

runtime-git-sync-client's local-path fetch/pull/fastForward/push calls now
forward context.worktreeId (needed by the main-process handlers to key
deferred push-target materialization). Update the 17 call-site mocks across
15 tests in editor-remote-branch-actions.test.ts to expect worktreeId: 'wt-1',
matching the already-correct source behavior -- no assertion was loosened.

* fix(worktree): give a materialize joiner its own branch wiring

The materialize single flight is keyed on the remote, but everything after
the remote add is per-branch. A sibling worktree joining an in-flight mint
for a different branch received the minter's target and skipped its own
refspec widen, tracking-ref fetch, and upstream link, so its branch ended
with no upstream at all.

Wait for the remote, then run the per-branch work against the joiner's own
target -- the same path the already-exists short-circuit takes, now shared
rather than duplicated. Adopting a remote a sibling minted also stamps
ownership, so removing the minter cannot strand the survivor's metadata
outside the orphan sweep's reach.

* fix(worktree): stop a failed mint from leaving a config-only fork remote

Review of the joiner fix found it made things worse in three ways.

Swallowing the mint's rejection let a joiner adopt a remote the rollback
had already removed, writing remote.<name>.fetch with no URL. Verified on
real git: that ghost section breaks `git fetch --all`, forces every later
mint to a `-2` name, and cannot be removed by `git remote remove`.
Propagate instead; the in-flight map is already cleared, so a retry
re-mints.

The SSH twin still returned the minter's target to a joiner, so the
original per-branch bug survived there. It now adopts against its own
target through a twin helper.

The ownership stamp was unreachable: it required both a store and a repo
id, and no caller passes both. Derive the repo id from the worktree id.

Adopters also write remote config, and concurrent `git config --add` has
no lock retry -- 135 of 160 writes failed at 8-way concurrency, and equal
values duplicate the refspec. Chain adoptions per remote.
2026-09-01 22:44:05 -07:00

264 lines
9.5 KiB
TypeScript

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 { hasLocalWorktreeBaseRef } from '../git/worktree-base-ref-probe'
import { isGeneratedWorktreeCreateName } from '../worktree-create-candidates'
import { consumePreparedWorktreeCreate } from '../worktree-create-preparation'
import {
failedWorktreeCreationNeedsRetirement,
retireGeneratedWorktreeName
} from '../worktree-name-retirement'
export async function createRuntimeLocalGitWorktree(args: {
request: RuntimeManagedWorktreeCreateArgs
repo: Repo
store: RuntimeStore
settings: {
workspaceDir: string
nestWorkspaces: boolean
refreshLocalBaseRefOnWorktreeCreate: boolean
localBaseRefSuggestionDismissed?: boolean
}
baseBranch: string
workspaceRoot: string
branchName: string
worktreePath: string
effectiveSanitizedName?: string
checkoutExistingBranch: boolean
localWorktreeGitOptions: { wslDistro?: string }
hasLocalWorktreeGitOptions: boolean
localWorktreeGitOptionArgs: [] | [{ wslDistro?: string }]
resolveRemoteTrackingBase: (
repoPath: string,
baseBranch: string,
...options: [] | [{ wslDistro?: string }]
) => Promise<RemoteTrackingBase | null>
hasRemoteTrackingRef: (
repoPath: string,
base: RemoteTrackingBase,
...options: [] | [{ wslDistro?: string }]
) => Promise<boolean>
refreshRemoteTrackingBase: (
repoPath: string,
base: RemoteTrackingBase,
...options: [] | [{ wslDistro?: string }]
) => Promise<RemoteFetchResult>
fetchRemote: (
repoPath: string,
remote: string,
...options: [] | [{ wslDistro?: string }]
) => Promise<void>
}): Promise<{
remoteTrackingBase: RemoteTrackingBase | null
sparseDirectories: string[]
configuredPushTarget?: GitPushTarget
created: GitWorktreeInfo
addResult: AddWorktreeResult
}> {
let remoteTrackingBase = await args.resolveRemoteTrackingBase(
args.repo.path,
args.baseBranch,
...args.localWorktreeGitOptionArgs
)
if (remoteTrackingBase) {
const [hadRemoteRef, hasNamedLocalBaseRef] = await Promise.all([
args.hasRemoteTrackingRef(
args.repo.path,
remoteTrackingBase,
...args.localWorktreeGitOptionArgs
),
hasLocalWorktreeBaseRef(
args.repo.path,
args.baseBranch,
args.hasLocalWorktreeGitOptions ? args.localWorktreeGitOptions : {}
)
])
const hasLocalBase = hadRemoteRef || hasNamedLocalBaseRef
if (!hadRemoteRef && hasLocalBase) {
remoteTrackingBase = null
} else {
const refresh = await args.refreshRemoteTrackingBase(
args.repo.path,
remoteTrackingBase,
...args.localWorktreeGitOptionArgs
)
if (!refresh.ok && !hadRemoteRef) {
throw new Error(
`Could not refresh base ref "${args.baseBranch}" from "${remoteTrackingBase.remote}". Check your network and try again.`
)
}
if (
!hadRemoteRef &&
!(await args.hasRemoteTrackingRef(
args.repo.path,
remoteTrackingBase,
...args.localWorktreeGitOptionArgs
))
) {
throw new Error(`Base ref "${args.baseBranch}" was not found after fetching.`)
}
}
} else if (
!(await hasLocalWorktreeBaseRef(
args.repo.path,
args.baseBranch,
args.hasLocalWorktreeGitOptions ? args.localWorktreeGitOptions : {}
))
) {
try {
await args.fetchRemote(args.repo.path, 'origin', ...args.localWorktreeGitOptionArgs)
} 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 baseOptions: AddWorktreeOptions | undefined = args.checkoutExistingBranch
? {
checkoutExistingBranch: true,
...remoteOption,
...(suggestLocalBaseRefUpdate ? { suggestLocalBaseRefUpdate } : {})
}
: suggestLocalBaseRefUpdate
? { ...remoteOption, suggestLocalBaseRefUpdate }
: remoteOption
const addProjectGitOptions = (options?: AddWorktreeOptions): AddWorktreeOptions | undefined =>
args.hasLocalWorktreeGitOptions ? { ...options, ...args.localWorktreeGitOptions } : options
const addOptions = addProjectGitOptions(baseOptions)
const defaultAddWorktreeOption = addProjectGitOptions()
const preparedWorktreeOptions = suggestLocalBaseRefUpdate
? addProjectGitOptions({ ...remoteOption, suggestLocalBaseRefUpdate })
: remoteOption
? addProjectGitOptions(remoteOption)
: defaultAddWorktreeOption
const shouldRetireGeneratedName =
args.request.nameWasGenerated === true &&
Boolean(args.effectiveSanitizedName) &&
isGeneratedWorktreeCreateName(args.effectiveSanitizedName!)
const addStandardWorktree = async (): Promise<AddWorktreeResult> =>
addOptions
? ((await addWorktree(
args.repo.path,
args.worktreePath,
args.branchName,
args.baseBranch,
args.settings.refreshLocalBaseRefOnWorktreeCreate,
false,
addOptions
)) ?? {})
: ((await addWorktree(
args.repo.path,
args.worktreePath,
args.branchName,
args.baseBranch,
args.settings.refreshLocalBaseRefOnWorktreeCreate
)) ?? {})
let addResult: AddWorktreeResult
try {
const preparedAttempt =
sparseDirectories.length === 0 && !args.checkoutExistingBranch
? await consumePreparedWorktreeCreate({
repoPath: args.repo.path,
workspaceRoot: args.workspaceRoot,
worktreePath: args.worktreePath,
branch: args.branchName,
baseBranch: args.baseBranch,
refreshLocalBaseRef: args.settings.refreshLocalBaseRefOnWorktreeCreate,
...(preparedWorktreeOptions ? { options: preparedWorktreeOptions } : {})
})
: null
// This path has no create-span recorder, so the miss reason is only observable on the IPC path.
if (preparedAttempt?.status === 'hit') {
addResult = preparedAttempt.result
} else if (sparseDirectories.length > 0) {
addResult =
(await (addOptions
? addSparseWorktree(
args.repo.path,
args.worktreePath,
args.branchName,
sparseDirectories,
args.baseBranch,
args.settings.refreshLocalBaseRefOnWorktreeCreate,
addOptions
)
: addSparseWorktree(
args.repo.path,
args.worktreePath,
args.branchName,
sparseDirectories,
args.baseBranch,
args.settings.refreshLocalBaseRefOnWorktreeCreate
))) ?? {}
} else {
addResult = await addStandardWorktree()
}
} catch (error) {
if (shouldRetireGeneratedName && failedWorktreeCreationNeedsRetirement(error)) {
await retireGeneratedWorktreeName(
args.store as Parameters<typeof retireGeneratedWorktreeName>[0],
args.repo,
args.settings,
args.effectiveSanitizedName!
)
}
throw error
}
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 } = await resolveCreatedWorktree(
args.repo.path,
args.worktreePath,
args.branchName,
args.hasLocalWorktreeGitOptions ? args.localWorktreeGitOptions : undefined
)
return {
remoteTrackingBase,
sparseDirectories,
...(configuredPushTarget ? { configuredPushTarget } : {}),
created,
addResult
}
}