mirror of
https://github.com/stablyai/orca.git
synced 2026-10-02 16:02:15 +00:00
* 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.
264 lines
9.5 KiB
TypeScript
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
|
|
}
|
|
}
|