orca/src/main/ipc/worktree-remote.ts

864 lines
33 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* eslint-disable max-lines */
// Why: extracted from worktrees.ts to keep the main IPC module under the
// max-lines threshold. Worktree creation helpers (local and remote) live
// here so the IPC dispatch file stays focused on handler wiring. The
// sparse-checkout flow plus the post-create setup-runner wiring pushed
// this file marginally over the per-file limit; matches the
// eslint-disable pattern other files in src/renderer use when a
// cohesive flow would split awkwardly.
import type { BrowserWindow } from 'electron'
import { join } from 'path'
import { existsSync } from 'fs'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
import type {
CreateWorktreeArgs,
CreateWorktreeResult,
GitPushTarget,
Repo,
WorktreeMeta
} from '../../shared/types'
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree, addSparseWorktree } from '../git/worktree'
import { getGitUsername, getDefaultBaseRef, getBranchConflictKind } from '../git/repo'
import { validateGitPushTarget } from '../git/push-target-validation'
import { assertGitPushTargetShape } from '../../shared/git-push-target-validation'
import { gitExecFileAsync } from '../git/runner'
import { parseGitHubOwnerRepo } from '../github/gh-utils'
import type { OrcaRuntimeService } from '../runtime/orca-runtime'
import type { RemoteFetchResult, RemoteTrackingBase } from '../runtime/orca-runtime'
import { isWslPath, parseWslPath, getWslHome } from '../wsl'
import { createSetupRunnerScript, getEffectiveHooks, shouldRunSetupForCreate } from '../hooks'
import { getSshGitProvider } from '../providers/ssh-git-dispatch'
import { getActiveMultiplexer } from './ssh'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { isTuiAgent } from '../../shared/tui-agent-config'
import {
sanitizeWorktreeName,
sanitizeWorktreeDisplayName,
computeBranchName,
computeWorktreePath,
ensurePathWithinWorkspace,
shouldSetDisplayName,
mergeWorktree,
areWorktreePathsEqual
} from './worktree-logic'
import { invalidateAuthorizedRootsCache } from './filesystem-auth'
import { createWorktreeSymlinks } from './worktree-symlinks'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
async function readCommitSha(repoPath: string, ref: string): Promise<string> {
const { stdout } = await gitExecFileAsync(['rev-parse', '--verify', `${ref}^{commit}`], {
cwd: repoPath
})
return stdout.trim()
}
async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise<string | null> {
const target = parseGitHubOwnerRepo(remoteUrl)
try {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath })
for (const remote of stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)) {
try {
const { stdout: urlStdout } = await gitExecFileAsync(['remote', 'get-url', remote], {
cwd: repoPath
})
const candidateUrl = urlStdout.trim()
const candidate = parseGitHubOwnerRepo(candidateUrl)
if (
target &&
candidate &&
target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
target.repo.toLowerCase() === candidate.repo.toLowerCase()
) {
return remote
}
if (candidateUrl === remoteUrl) {
return remote
}
} catch {
// Ignore a remote that disappeared or has no fetch URL.
}
}
} catch {
return null
}
return null
}
async function ensureUniqueRemoteName(repoPath: string, preferred: string): Promise<string> {
const { stdout } = await gitExecFileAsync(['remote'], { cwd: repoPath })
const existing = new Set(
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
)
if (!existing.has(preferred)) {
return preferred
}
for (let suffix = 2; suffix < 100; suffix += 1) {
const candidate = `${preferred}-${suffix}`
if (!existing.has(candidate)) {
return candidate
}
}
throw new Error(`Could not find an available remote name for ${preferred}.`)
}
export async function prepareWorktreePushTarget(
repoPath: string,
target: GitPushTarget
): Promise<GitPushTarget> {
await validateGitPushTarget(repoPath, target)
let remoteName = target.remoteName
if (target.remoteUrl) {
const existingRemote = await findRemoteForUrl(repoPath, target.remoteUrl)
if (existingRemote) {
remoteName = existingRemote
} else {
remoteName = await ensureUniqueRemoteName(repoPath, target.remoteName)
await gitExecFileAsync(['remote', 'add', remoteName, target.remoteUrl], { cwd: repoPath })
}
}
await gitExecFileAsync(
[
'fetch',
remoteName,
`+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}`
],
{ cwd: repoPath }
)
return {
...target,
remoteName
}
}
export async function configureCreatedWorktreePushTarget(
worktreePath: string,
branchName: string,
target: GitPushTarget
): Promise<GitPushTarget> {
await gitExecFileAsync(
['branch', '--set-upstream-to', `${target.remoteName}/${target.branchName}`, branchName],
{ cwd: worktreePath }
)
return target
}
async function findRemoteForUrlSsh(
provider: SshGitProvider,
repoPath: string,
remoteUrl: string
): Promise<string | null> {
const target = parseGitHubOwnerRepo(remoteUrl)
try {
const { stdout } = await provider.exec(['remote'], repoPath)
for (const remote of stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)) {
try {
const { stdout: urlStdout } = await provider.exec(['remote', 'get-url', remote], repoPath)
const candidateUrl = urlStdout.trim()
const candidate = parseGitHubOwnerRepo(candidateUrl)
if (
target &&
candidate &&
target.owner.toLowerCase() === candidate.owner.toLowerCase() &&
target.repo.toLowerCase() === candidate.repo.toLowerCase()
) {
return remote
}
if (candidateUrl === remoteUrl) {
return remote
}
} catch {
// Ignore a remote that disappeared or has no fetch URL.
}
}
} catch {
return null
}
return null
}
async function ensureUniqueRemoteNameSsh(
provider: SshGitProvider,
repoPath: string,
preferred: string
): Promise<string> {
const { stdout } = await provider.exec(['remote'], repoPath)
const existing = new Set(
stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
)
if (!existing.has(preferred)) {
return preferred
}
for (let suffix = 2; suffix < 100; suffix += 1) {
const candidate = `${preferred}-${suffix}`
if (!existing.has(candidate)) {
return candidate
}
}
throw new Error(`Could not find an available remote name for ${preferred}.`)
}
async function prepareWorktreePushTargetSsh(
provider: SshGitProvider,
repoPath: string,
target: GitPushTarget
): Promise<GitPushTarget> {
assertGitPushTargetShape(target)
await provider.exec(['check-ref-format', '--branch', target.branchName], repoPath)
let remoteName = target.remoteName
if (target.remoteUrl) {
const existingRemote = await findRemoteForUrlSsh(provider, repoPath, target.remoteUrl)
if (existingRemote) {
remoteName = existingRemote
} else {
remoteName = await ensureUniqueRemoteNameSsh(provider, repoPath, target.remoteName)
await provider.exec(['remote', 'add', remoteName, target.remoteUrl], repoPath)
}
}
await provider.exec(
[
'fetch',
remoteName,
`+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}`
],
repoPath
)
return { ...target, remoteName }
}
async function configureCreatedWorktreePushTargetSsh(
provider: SshGitProvider,
worktreePath: string,
branchName: string,
target: GitPushTarget
): Promise<GitPushTarget> {
await provider.exec(
['branch', '--set-upstream-to', `${target.remoteName}/${target.branchName}`, branchName],
worktreePath
)
return target
}
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:changed', { repoId })
}
}
// Why (§3.3): two-phase spinner. Main process fires `'fetching'` immediately
// after kicking off `git fetch` and `'creating'` after that fetch resolves
// (or is determined to be cache-fresh). Renderer swaps its spinner label in
// response; fallback is the static "Creating worktree..." label if no event
// arrives (e.g. renderer races destruction of the window).
export function emitCreateWorktreeProgress(
mainWindow: BrowserWindow,
phase: 'fetching' | 'creating'
): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('createWorktree:progress', { phase })
}
}
export async function createRemoteWorktree(
args: CreateWorktreeArgs,
repo: Repo,
store: Store,
mainWindow: BrowserWindow
): Promise<CreateWorktreeResult> {
if (args.sparseCheckout) {
throw new Error('Sparse checkout is not supported for remote SSH repos yet.')
}
const provider = getSshGitProvider(repo.connectionId!) as SshGitProvider | undefined
if (!provider) {
throw new Error(`No git provider for connection "${repo.connectionId}"`)
}
const settings = store.getSettings()
const requestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
const requestedDisplayName = args.displayName
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Get git username from remote
let username = ''
try {
const { stdout } = await provider.exec(['config', 'user.name'], repo.path)
username = stdout.trim()
} catch {
/* no username configured */
}
const branchName = computeBranchName(sanitizedName, settings, username)
// Check branch conflict on remote
try {
const { stdout } = await provider.exec(['branch', '--list', '--all', branchName], repo.path)
if (stdout.trim()) {
throw new Error(`Branch "${branchName}" already exists. Pick a different worktree name.`)
}
} catch (e) {
if (e instanceof Error && e.message.includes('already exists')) {
throw e
}
}
// Compute worktree path relative to the repo's parent on the remote
const remotePath = `${repo.path}/../${sanitizedName}`
// Determine base branch
// Why: previously fell back to a hardcoded 'origin/main' when
// symbolic-ref failed. That silently handed addWorktree a ref that may
// not exist on the remote (e.g. repos whose primary branch is master or
// develop), producing an opaque git error. Fail here with a clear
// message so the UI can surface it and prompt the user to pick a base.
let baseBranch = args.baseBranch || repo.worktreeBaseRef
if (!baseBranch) {
try {
const { stdout } = await provider.exec(
['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
repo.path
)
baseBranch = stdout.trim()
} catch {
// Fall through — baseBranch stays unset.
}
}
if (!baseBranch) {
throw new Error(
'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.'
)
}
// Fetch latest
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
try {
await provider.exec(['fetch', remote], repo.path)
} catch {
/* best-effort */
}
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {
// Why: fork-PR SSH worktrees need the same contributor-remote setup as
// local worktrees before creation, otherwise Push/Sync can target origin.
preparedPushTarget = await prepareWorktreePushTargetSsh(provider, repo.path, args.pushTarget)
}
const mux = getActiveMultiplexer(repo.connectionId!)
if (!mux) {
throw new Error('SSH connection is not available. Please reconnect and try again.')
}
// Why: kept for back-compat with old relay binaries during the upgrade
// window — those still gate git.addWorktree on registered roots, so we
// must prime them synchronously to close fresh-host / reconnect windows.
// New relays no-op these calls. Notify-fallback handles older relays that
// pre-date the request-form session.registerRoot handler. Tracked for
// removal once the relay-version floor moves past the cutover (see
// docs/relay-fs-allowlist-removal.md).
try {
await Promise.all([
mux.request('session.registerRoot', { rootPath: repo.path }),
mux.request('session.registerRoot', { rootPath: remotePath })
])
} catch (err) {
if (err instanceof Error && err.message.includes('Method not found')) {
mux.notify('session.registerRoot', { rootPath: repo.path })
mux.notify('session.registerRoot', { rootPath: remotePath })
} else {
throw err
}
}
// Create worktree via relay
try {
await provider.addWorktree(repo.path, branchName, remotePath, {
base: baseBranch
})
} catch (err) {
if (
err instanceof Error &&
(err.message.includes('No workspace roots registered yet') ||
err.message.includes('Path outside authorized workspace'))
) {
// Why: only an OLD relay binary (pre-allowlist-removal) can produce
// these errors. New relays no-op session.registerRoot. Translate the
// raw error into an actionable upgrade-window message while still
// preserving the original string for bug reports. Tracked for removal
// once the relay-version floor moves past the cutover (see
// docs/relay-fs-allowlist-removal.md).
throw new Error(
`Older relay reported an authorization error; please reconnect to deploy the latest relay. (${err.message})`
)
}
throw err
}
// Re-list to get the created worktree info
const gitWorktrees = await provider.listWorktrees(repo.path)
const created = gitWorktrees.find(
(gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(sanitizedName)
)
if (!created) {
throw new Error('Worktree created but not found in listing')
}
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
let configuredPushTarget: GitPushTarget | undefined
if (preparedPushTarget) {
configuredPushTarget = await configureCreatedWorktreePushTargetSsh(
provider,
created.path,
branchName,
preparedPushTarget
)
}
const metaUpdates: Partial<WorktreeMeta> = {
// Why: path-derived worktree IDs can be reused after external deletion.
// Fresh creations must rotate instance identity so stale lineage cannot
// attach to the new occupant of the same path.
instanceId: randomUUID(),
lastActivityAt: now,
// Why: grants the new worktree a short grace window at the top of the
// Recent sort. During worktree creation (git fetch + add can take several
// seconds) other worktrees get ambient PTY bumps that would otherwise
// leave the newly-created one below them; the Recent comparator uses
// max(lastActivityAt, createdAt + GRACE_MS) to keep it on top until the
// window elapses. See smart-sort.ts `CREATE_GRACE_MS`.
createdAt: now,
baseRef: baseBranch,
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
...(requestedDisplayName
? { displayName: requestedDisplayName }
: shouldSetDisplayName(requestedName, branchName, sanitizedName)
? { displayName: requestedName }
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
}
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
const worktree = mergeWorktree(repo.id, created, meta)
// Why: `experimentalWorktreeSymlinks` is intentionally not wired up for
// remote (SSH) worktrees. Creating symlinks on the remote host would
// require a new relay method and authorization surface; the feature is
// local-only until that protocol work is in scope. Remote repos with
// `symlinkPaths` configured have them silently ignored here.
notifyWorktreesChanged(mainWindow, repo.id)
return { worktree }
}
export async function createLocalWorktree(
args: CreateWorktreeArgs,
repo: Repo,
store: Store,
mainWindow: BrowserWindow,
runtime?: OrcaRuntimeService
): Promise<CreateWorktreeResult> {
const settings = store.getSettings()
const username = getGitUsername(repo.path)
const requestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
const requestedDisplayName = args.displayName
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Why (§3.3): determine the base branch (and therefore the remote we need to
// fetch) FIRST, so the fetch can overlap all pre-create work below. Neither
// of these calls depends on the suffix loop / PR probe / branch-conflict
// resolution, so they are safe to hoist.
const baseBranch = args.baseBranch || repo.worktreeBaseRef || getDefaultBaseRef(repo.path)
if (!baseBranch) {
// Why: getDefaultBaseRef may return null when none of origin/HEAD,
// origin/main, origin/master, local main, or local master exist. Don't
// fall back to a hardcoded 'origin/main' — passing a non-existent ref to
// `git worktree add` produces an opaque error. Fail here with a clear
// message so the UI can prompt the user to pick a base branch explicitly.
throw new Error(
'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.'
)
}
let optimisticBase: RemoteTrackingBase | null = null
let optimisticFetchPromise: Promise<RemoteFetchResult> | null = null
let initialBaseStatus: CreateWorktreeResult['initialBaseStatus']
let legacyFetchPromise: Promise<void> | null = null
if (runtime) {
optimisticBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch)
if (optimisticBase) {
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, optimisticBase)
if (hasLocalBaseRef) {
const isFresh = await runtime.isRemoteFetchFresh(repo.path, optimisticBase.remote)
if (!isFresh) {
optimisticFetchPromise = runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
}
} else {
emitCreateWorktreeProgress(mainWindow, 'fetching')
const result = await runtime.getOrStartRemoteFetch(repo.path, optimisticBase.remote)
if (!(await runtime.hasRemoteTrackingRef(repo.path, optimisticBase))) {
if (!result.ok) {
throw new Error(
`Could not fetch base ref "${baseBranch}" from "${optimisticBase.remote}". Check your network and try again.`
)
}
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
}
}
} else {
// Why: when the base branch does not match a configured remote prefix
// (e.g. plain `main`, `master`, or any local branch), the legacy path
// still ran a best-effort `git fetch origin` so a local base could be
// built against fresher tracking refs. Preserve that behavior here so
// local-only bases don't silently skip the pre-create fetch.
const fallbackRemote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
legacyFetchPromise = runtime
.fetchRemoteWithCache(repo.path, fallbackRemote)
.then(() => undefined)
.catch(() => undefined)
emitCreateWorktreeProgress(mainWindow, 'fetching')
}
} else {
const remote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
legacyFetchPromise = gitExecFileAsync(['fetch', remote], { cwd: repo.path })
.then(() => undefined)
.catch(() => undefined)
emitCreateWorktreeProgress(mainWindow, 'fetching')
}
// Why: WSL worktrees live under ~/orca/workspaces inside the WSL
// filesystem. Validate against that root, not the Windows workspace dir.
// If WSL home lookup fails, keep using the configured workspace root so
// the path traversal guard still runs on the fallback path.
const wslInfo = isWslPath(repo.path) ? parseWslPath(repo.path) : null
const wslHome = wslInfo ? getWslHome(wslInfo.distro) : null
const workspaceRoot = wslHome ? join(wslHome, 'orca', 'workspaces') : settings.workspaceDir
let effectiveRequestedName = requestedName
let effectiveSanitizedName = sanitizedName
let branchName = ''
let worktreePath = ''
// Why: silently resolve branch/path/PR name collisions by appending -2/-3/etc.
// instead of failing and forcing the user back to the name picker. This is
// especially important for the new-workspace flow where the user may not have
// direct control over the branch name. Bounded by MAX_SUFFIX_ATTEMPTS so a
// misconfigured environment (e.g. a mock or stub that always reports a
// conflict) cannot spin this loop indefinitely.
const MAX_SUFFIX_ATTEMPTS = 100
let resolved = false
let lastBranchConflictKind: 'local' | 'remote' | null = null
let lastExistingPR: Awaited<ReturnType<typeof getPRForBranch>> | null = null
for (let suffix = 1; suffix <= MAX_SUFFIX_ATTEMPTS; suffix += 1) {
effectiveSanitizedName = suffix === 1 ? sanitizedName : `${sanitizedName}-${suffix}`
effectiveRequestedName =
suffix === 1
? requestedName
: requestedName.trim()
? `${requestedName}-${suffix}`
: effectiveSanitizedName
branchName = computeBranchName(effectiveSanitizedName, settings, username)
lastBranchConflictKind = await getBranchConflictKind(repo.path, branchName)
if (lastBranchConflictKind) {
continue
}
// Why: `gh pr list` is a network round-trip that previously ran on every
// create, adding ~13s to the happy path even when no conflict exists. We
// only probe PR conflicts once a local/remote branch collision has already
// forced us past the first suffix — at that point uniqueness matters
// enough to justify the GitHub call. The common case (brand-new branch
// name, no collisions) skips the network entirely.
if (suffix > 1) {
lastExistingPR = null
try {
lastExistingPR = await getPRForBranch(repo.path, branchName)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR) {
continue
}
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, settings),
workspaceRoot
)
if (existsSync(worktreePath)) {
continue
}
resolved = true
break
}
if (!resolved) {
// Why: if every suffix in range collides, fall back to the original
// "reject with a specific reason" behavior so the user sees why creation
// failed instead of a generic error or (worse) an infinite spinner.
if (lastExistingPR) {
throw new Error(
`Branch "${branchName}" already has PR #${lastExistingPR.number}. Pick a different worktree name.`
)
}
if (lastBranchConflictKind) {
throw new Error(
`Branch "${branchName}" already exists ${lastBranchConflictKind === 'local' ? 'locally' : 'on a remote'}. Pick a different worktree name.`
)
}
throw new Error(
`Could not find an available worktree name for "${sanitizedName}". Pick a different worktree name.`
)
}
// Why: `ask` is a pre-create choice gate, not a post-create side effect.
// Resolve it before mutating git state so missing UI input cannot strand
// a real worktree on disk while the renderer reports "create failed". The
// actual run/skip decision is recomputed after the worktree exists against
// the worktree-bound setup script.
const primarySetupScript = getEffectiveHooks(repo)?.scripts.setup
if (primarySetupScript) {
shouldRunSetupForCreate(repo, args.setupDecision)
}
const sparseDirectories = args.sparseCheckout
? normalizeSparseDirectories(args.sparseCheckout.directories)
: []
if (args.sparseCheckout && sparseDirectories.length === 0) {
throw new Error('Sparse checkout requires at least one repo-relative directory.')
}
let sparsePresetId: string | undefined
if (args.sparseCheckout?.presetId) {
const preset = store
.getSparsePresets(repo.id)
.find((entry) => entry.id === args.sparseCheckout?.presetId)
if (preset?.repoId === repo.id) {
try {
const presetDirectories = normalizeSparseDirectories(preset.directories)
// Why: use Set-based comparison so directory order does not affect
// attribution — matches the renderer's sparseDirectoriesMatch logic.
const presetSet = new Set(presetDirectories)
const directoriesMatch =
presetDirectories.length === sparseDirectories.length &&
sparseDirectories.every((entry) => presetSet.has(entry))
sparsePresetId = directoriesMatch ? preset.id : undefined
} catch {
// Why: corrupt preset data should not block creation or falsely label the new worktree.
}
}
}
if (legacyFetchPromise) {
await legacyFetchPromise
}
emitCreateWorktreeProgress(mainWindow, 'creating')
let preparedPushTarget: GitPushTarget | undefined
if (args.pushTarget) {
// Why: validate and fetch the contributor remote before creating the
// worktree. If this fails, retrying won't hit branch/path conflicts from a
// half-created worktree.
preparedPushTarget = await prepareWorktreePushTarget(repo.path, args.pushTarget)
}
await (sparseDirectories.length > 0
? addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
: addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
))
let configuredPushTarget: GitPushTarget | undefined
if (preparedPushTarget) {
// Why: fork-PR review worktrees should publish commits back to the PR
// author's branch. Configure the branch upstream immediately so the
// existing Push/Pull/Sync controls use the contributor remote instead of
// silently defaulting to origin.
configuredPushTarget = await configureCreatedWorktreePushTarget(
worktreePath,
branchName,
preparedPushTarget
)
}
// Re-list to get the freshly created worktree info
const gitWorktrees = await listWorktrees(repo.path)
const created = gitWorktrees.find((gw) => areWorktreePathsEqual(gw.path, worktreePath))
if (!created) {
throw new Error('Worktree created but not found in listing')
}
const worktreeId = `${repo.id}::${created.path}`
const now = Date.now()
const metaUpdates: Partial<WorktreeMeta> = {
// Why: path-derived worktree IDs can be reused after external deletion.
// Fresh creations must rotate instance identity so stale lineage cannot
// attach to the new occupant of the same path.
instanceId: randomUUID(),
// Stamp activity so the worktree sorts into its final position
// immediately — prevents scroll-to-reveal racing with a later
// bumpWorktreeActivity that would re-sort the list.
lastActivityAt: now,
// See createRemoteWorktree above: createdAt protects the newly-created
// worktree from ambient PTY bumps in other worktrees for CREATE_GRACE_MS.
createdAt: now,
baseRef: baseBranch,
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
...(requestedDisplayName
? { displayName: requestedDisplayName }
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
? { displayName: effectiveRequestedName }
: {}),
...(sparseDirectories.length > 0
? {
sparseDirectories,
sparseBaseRef: baseBranch,
sparsePresetId
}
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
}
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
const worktree = mergeWorktree(repo.id, created, meta)
// Why: the authorized-roots cache is consulted lazily on the next filesystem
// access (`ensureAuthorizedRootsCache` rebuilds on demand when dirty). We
// just invalidate the cache marker instead of blocking worktree creation on
// an immediate rebuild, which can spawn `git worktree list` per repo and
// adds 100ms+ to every create.
invalidateAuthorizedRootsCache()
// Why: create user-configured symlinks from the primary checkout into the
// new worktree before any setup script runs, so scripts that reuse shared
// state (e.g. `node_modules`, `.env`) see the links already in place.
// Gated on the experimental flag so disabling the feature globally skips
// the work even when a repo still has paths configured.
if (settings.experimentalWorktreeSymlinks && repo.symlinkPaths && repo.symlinkPaths.length > 0) {
await createWorktreeSymlinks(repo.path, created.path, repo.symlinkPaths)
}
// Why: the worktree's own `orca.yaml` (at the tip of the base branch) is
// authoritative for what runs post-creation. The repo-level trust already
// granted by the user in the pre-create flow covers execution of that
// script; we intentionally do not re-gate on content equality with the
// primary checkout's preview, because benign divergence (whitespace,
// comments, or any setup-script edit that has landed on the base branch
// but not yet been pulled into the primary checkout) was silently
// disabling setup with no UI signal. See #1280 for the original gate and
// the regression this replaced.
let setup: CreateWorktreeResult['setup']
const setupScript = getEffectiveHooks(repo, worktreePath)?.scripts.setup
let shouldLaunchSetup = false
if (setupScript) {
try {
shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision)
} catch (error) {
// Why: if the target branch introduces setup hooks that the primary
// checkout did not expose, the renderer may not have collected an ask
// decision. The worktree already exists, so skip setup instead of
// turning successful git creation into an IPC failure.
console.warn(`[hooks] setup hook skipped for ${worktreePath}:`, error)
}
}
if (setupScript && shouldLaunchSetup) {
try {
// Why: setup now runs in a visible terminal owned by the renderer so users
// can inspect failures, answer prompts, and rerun it. The main process only
// resolves policy and writes the runner script; it must not execute setup
// itself anymore or we would reintroduce the hidden background-hook behavior.
//
// Why: the git worktree already exists at this point. If runner generation
// fails, surfacing the error as a hard create failure would lie to the UI
// about the underlying git state and strand a real worktree on disk.
// Degrade to "created without setup launch" instead.
setup = createSetupRunnerScript(repo, worktreePath, setupScript)
} catch (error) {
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
}
}
if (runtime && optimisticBase && optimisticFetchPromise) {
initialBaseStatus = {
repoId: repo.id,
worktreeId,
status: 'checking',
base: optimisticBase.base,
remote: optimisticBase.remote
}
runtime.emitWorktreeBaseStatus(initialBaseStatus)
// Why: record the reconcile token BEFORE the rev-parse await so a racing
// worktree remove during the await isn't a no-op (its
// clearOptimisticReconcileToken would then run before the token exists,
// letting the post-await record install a fresh token whose reconcile
// would re-populate base status for a worktree that no longer exists).
const token = runtime.recordOptimisticReconcileToken(worktreeId)
try {
const createdBaseSha = await readCommitSha(created.path, 'HEAD')
void runtime
.reconcileWorktreeBaseStatus({
repoId: repo.id,
repoPath: repo.path,
worktreeId,
base: optimisticBase,
branchName,
createdBaseSha,
token,
fetchPromise: optimisticFetchPromise
})
.catch((error) => {
console.warn(`[worktree-base-status] reconcile failed for ${worktreeId}:`, error)
})
} catch (error) {
console.warn(`[worktree-base-status] failed to read created base for ${worktreeId}:`, error)
runtime.emitWorktreeBaseStatus({
repoId: repo.id,
worktreeId,
status: 'unknown',
base: optimisticBase.base,
remote: optimisticBase.remote
})
}
}
notifyWorktreesChanged(mainWindow, repo.id)
return {
worktree,
...(setup ? { setup } : {}),
...(initialBaseStatus ? { initialBaseStatus } : {})
}
}