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

2057 lines
71 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 { posix, win32 } from 'path'
import { existsSync } from 'fs'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
import type {
CreateWorktreeArgs,
CreateWorktreeResult,
GitPushTarget,
GlobalSettings,
LocalBaseRefRefreshResult,
Repo,
Worktree,
WorktreeMeta
} from '../../shared/types'
import { getPRForBranch } from '../github/client'
import { listWorktrees, addWorktree, addSparseWorktree, parseWorktreeList } from '../git/worktree'
import type { AddWorktreeResult } 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 {
buildPosixRunnerScript,
buildWindowsRunnerScript,
createSetupRunnerScript,
getDefaultTabsLaunch,
getEffectiveHooks,
getEffectiveHooksFromConfig,
getSetupRunnerEnvVars,
loadHooks,
parseOrcaYaml,
shouldRunSetupForCreate
} from '../hooks'
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
import { getSshFilesystemProvider } from '../providers/ssh-filesystem-dispatch'
import { getActiveMultiplexer } from './ssh'
import type { SshGitProvider } from '../providers/ssh-git-provider'
import { TUI_AGENT_CONFIG, isTuiAgent } from '../../shared/tui-agent-config'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import { getSshGitUsername } from '../git/git-username'
import {
sanitizeWorktreeName,
sanitizeWorktreeDisplayName,
computeBranchName,
computeWorktreePath,
computeRemoteWorktreePath,
computeWorkspaceRoot,
ensurePathWithinWorkspace,
getWorktreeCreationLayout,
getWorktreePathSettings,
hasRepoWorktreeBasePath,
shouldSetDisplayName,
mergeWorktree,
areWorktreePathsEqual
} from './worktree-logic'
import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { invalidateAuthorizedRootsCache, isENOENT } from './filesystem-auth'
import { createWorktreeSymlinks } from './worktree-symlinks'
import { normalizeSparseDirectories } from './sparse-checkout-directories'
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
import type { IFilesystemProvider } from '../providers/types'
import { buildSetupRunnerCommand } from '../../shared/setup-runner-command'
import { createWorktreeCreateTimingRecorder } from '../worktree-create-timing'
import {
markCodexProjectTrusted,
markCopilotFolderTrusted,
markCursorWorkspaceTrusted
} from '../agent-trust-presets'
const SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS = 30_000
const SSH_WORKTREE_CREATE_FETCH_CACHE_MAX = 512
const sshWorktreeCreateFetchInflight = new Map<string, Promise<void>>()
const sshWorktreeCreateFetchCompletedAt = new Map<string, number>()
const sshWorktreeCreateFetchQueueTail = new Map<string, Promise<void>>()
const sshWorktreeCreateBasePlanInflight = new Map<
string,
Promise<RemoteWorktreeCreateBasePlan | null>
>()
type RemoteWorktreeCreateBasePlan = {
baseBranch: string
remoteTrackingBase: RemoteTrackingBase | null
}
type StagedStartupResult = {
startupTerminal?: CreateWorktreeResult['startupTerminal']
didSpawnSetup: boolean
warning?: string
}
function appendWorktreeCreateWarning(current: string | undefined, next: string): string {
return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next
}
async function spawnLocalStartupAndSetupTerminals(args: {
runtime: OrcaRuntimeService | undefined
worktree: Pick<Worktree, 'id' | 'path'>
startup: CreateWorktreeArgs['startup']
setup: CreateWorktreeResult['setup']
defaultTabs: CreateWorktreeResult['defaultTabs']
settings: GlobalSettings
createdWithAgent: CreateWorktreeArgs['createdWithAgent']
}): Promise<StagedStartupResult> {
const { runtime, worktree, startup, setup, defaultTabs, settings, createdWithAgent } = args
if (!runtime || !startup || defaultTabs?.tabs.length) {
return { didSpawnSetup: false }
}
let warning: string | undefined
let startupTerminalHandle: string | null = null
let startupTerminal: CreateWorktreeResult['startupTerminal']
try {
// Why: after `git worktree add` and metadata registration, a runtime-owned
// PTY can begin booting the selected agent while setup runs in a sibling
// terminal. Earlier than this, the worktree path is not yet safe for agents.
if (isTuiAgent(createdWithAgent)) {
const preset = TUI_AGENT_CONFIG[createdWithAgent].preflightTrust
try {
if (preset === 'cursor') {
markCursorWorkspaceTrusted(worktree.path)
} else if (preset === 'copilot') {
markCopilotFolderTrusted(worktree.path)
} else if (preset === 'codex') {
markCodexProjectTrusted(worktree.path)
}
} catch {
// Best-effort: launch still proceeds and the agent can ask interactively.
}
}
const terminal = await runtime.createTerminal(`id:${worktree.id}`, {
command: startup.command,
env: startup.env,
telemetry: startup.telemetry,
activate: true
})
startupTerminalHandle = terminal.handle
startupTerminal = {
spawned: true,
surface: terminal.surface
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
warning = `Failed to create the startup terminal for ${worktree.path}: ${message}`
console.warn(`[worktree-create] ${warning}`)
return { didSpawnSetup: false, warning }
}
let didSpawnSetup = false
if (setup) {
try {
const setupCommand = buildSetupRunnerCommand(
setup.runnerScriptPath,
process.platform === 'win32' ? 'windows' : 'posix'
)
const setupLaunchMode =
(settings as Partial<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
.setupScriptLaunchMode ?? 'new-tab'
if (setupLaunchMode === 'split-vertical' || setupLaunchMode === 'split-horizontal') {
if (!startupTerminalHandle) {
throw new Error('startup_terminal_missing')
}
await runtime.splitTerminal(startupTerminalHandle, {
direction: setupLaunchMode === 'split-horizontal' ? 'horizontal' : 'vertical',
command: setupCommand,
env: setup.envVars,
activate: false
})
} else {
await runtime.createTerminal(`id:${worktree.id}`, {
title: 'Setup',
command: setupCommand,
env: setup.envVars,
activate: false
})
}
didSpawnSetup = true
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const nextWarning = `failed to create the setup terminal for ${worktree.path}: ${message}`
warning = appendWorktreeCreateWarning(warning, nextWarning)
console.warn(`[worktree-create] ${warning}`)
}
}
return {
...(startupTerminal ? { startupTerminal } : {}),
didSpawnSetup,
...(warning ? { warning } : {})
}
}
function setBoundedSshWorktreeCreateFetchEntry(
map: Map<string, number>,
key: string,
value: number
): void {
if (map.has(key)) {
map.delete(key)
}
map.set(key, value)
while (map.size > SSH_WORKTREE_CREATE_FETCH_CACHE_MAX) {
const oldest = map.keys().next()
if (oldest.done) {
return
}
map.delete(oldest.value)
}
}
function getSshWorktreeCreateBaseFetchKey(repo: Repo, base: RemoteTrackingBase): string {
return `${repo.connectionId ?? 'ssh'}::${repo.path}::base:${base.remote}:${base.branch}`
}
function getSshWorktreeCreateRemoteFetchKey(repo: Repo, remote: string): string {
return `${repo.connectionId ?? 'ssh'}::${repo.path}::remote:${remote}`
}
function getSshWorktreeCreateRemoteQueueKey(repo: Repo, remote: string): string {
return `${repo.connectionId ?? 'ssh'}::${repo.path}::queue:${remote}`
}
function getSshWorktreeCreateBasePlanKey(
repo: Repo,
requestedBaseBranch: string | undefined
): string {
const baseKey = requestedBaseBranch || repo.worktreeBaseRef || 'default'
return `${repo.connectionId ?? 'ssh'}::${repo.path}::plan:${baseKey}`
}
function getFreshSshWorktreeCreateFetchCompletedAt(key: string): number | null {
const lastAt = sshWorktreeCreateFetchCompletedAt.get(key)
if (lastAt === undefined) {
return null
}
if (Date.now() - lastAt < SSH_WORKTREE_CREATE_FETCH_FRESHNESS_MS) {
setBoundedSshWorktreeCreateFetchEntry(sshWorktreeCreateFetchCompletedAt, key, lastAt)
return lastAt
}
sshWorktreeCreateFetchCompletedAt.delete(key)
return null
}
function rememberSshWorktreeCreateFetchCompletedAt(key: string): void {
setBoundedSshWorktreeCreateFetchEntry(sshWorktreeCreateFetchCompletedAt, key, Date.now())
}
function enqueueSshWorktreeCreateFetch(
queueKey: string,
fetch: () => Promise<void>
): Promise<void> {
const previous = sshWorktreeCreateFetchQueueTail.get(queueKey)
const promise = previous ? previous.then(fetch, fetch) : fetch()
sshWorktreeCreateFetchQueueTail.set(queueKey, promise)
const clearQueueTail = (): void => {
if (sshWorktreeCreateFetchQueueTail.get(queueKey) === promise) {
sshWorktreeCreateFetchQueueTail.delete(queueKey)
}
}
promise.then(clearQueueTail, clearQueueTail)
return promise
}
async function getOrStartSshWorktreeCreateFetch(
key: string,
queueKey: string,
fetch: () => Promise<void>
): Promise<void> {
if (getFreshSshWorktreeCreateFetchCompletedAt(key) !== null) {
return
}
const existing = sshWorktreeCreateFetchInflight.get(key)
if (existing) {
return existing
}
const promise = enqueueSshWorktreeCreateFetch(queueKey, async () => {
if (getFreshSshWorktreeCreateFetchCompletedAt(key) !== null) {
return
}
await fetch()
// Why: SSH creation has no OrcaRuntimeService instance to share, but
// repeated creates on the same target should still reuse recent fetches.
rememberSshWorktreeCreateFetchCompletedAt(key)
}).finally(() => {
if (sshWorktreeCreateFetchInflight.get(key) === promise) {
sshWorktreeCreateFetchInflight.delete(key)
}
})
sshWorktreeCreateFetchInflight.set(key, promise)
return promise
}
async function refreshRemoteTrackingBaseForWorktreeCreate(
provider: SshGitProvider,
repo: Repo,
base: RemoteTrackingBase
): Promise<void> {
return getOrStartSshWorktreeCreateFetch(
getSshWorktreeCreateBaseFetchKey(repo, base),
getSshWorktreeCreateRemoteQueueKey(repo, base.remote),
() => provider.fetchRemoteTrackingRef(repo.path, base.remote, base.branch, base.ref)
)
}
async function fetchRemoteForWorktreeCreate(
provider: SshGitProvider,
repo: Repo,
remote: string
): Promise<void> {
return getOrStartSshWorktreeCreateFetch(
getSshWorktreeCreateRemoteFetchKey(repo, remote),
getSshWorktreeCreateRemoteQueueKey(repo, remote),
() => provider.exec(['fetch', remote], repo.path).then(() => undefined)
)
}
export function __resetSshWorktreeCreateFetchCacheForTests(): void {
sshWorktreeCreateFetchInflight.clear()
sshWorktreeCreateFetchCompletedAt.clear()
sshWorktreeCreateFetchQueueTail.clear()
sshWorktreeCreateBasePlanInflight.clear()
}
async function unsetRemoteWorktreeCreationBase(
provider: SshGitProvider,
worktreePath: string,
branchName: string
): Promise<void> {
try {
await provider.exec(
['config', '--local', '--unset-all', `branch.${branchName}.base`],
worktreePath
)
} catch {
// Best-effort SSH sparse cleanup; keep the sparse setup error as the
// actionable failure and let removeWorktree handle the partial checkout.
}
}
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 resolveCreateBranchName(
repoPath: string,
branchNameOverride: string | undefined,
sanitizedName: string,
settings: { branchPrefix: string; branchPrefixCustom?: string },
username: string | null
): Promise<string> {
if (!branchNameOverride) {
return computeBranchName(sanitizedName, settings, username)
}
if (branchNameOverride.startsWith('-')) {
throw new Error('Branch name must not start with "-"')
}
await gitExecFileAsync(['check-ref-format', '--branch', branchNameOverride], { cwd: repoPath })
return branchNameOverride
}
async function resolveCreateBranchNameSsh(
provider: SshGitProvider,
repoPath: string,
branchNameOverride: string | undefined,
sanitizedName: string,
settings: { branchPrefix: string; branchPrefixCustom?: string },
username: string | null
): Promise<string> {
if (!branchNameOverride) {
return computeBranchName(sanitizedName, settings, username)
}
if (branchNameOverride.startsWith('-')) {
throw new Error('Branch name must not start with "-"')
}
await provider.exec(['check-ref-format', '--branch', branchNameOverride], repoPath)
return branchNameOverride
}
function normalizeLocalBranchName(branchName: string | undefined): string {
return branchName?.replace(/^refs\/heads\//, '') ?? ''
}
async function canCheckoutExistingLocalBranch(
repoPath: string,
branchName: string,
baseBranch: string
): Promise<boolean> {
let localHead = ''
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
{
cwd: repoPath
}
)
localHead = stdout.trim()
} catch {
return false
}
if (normalizeLocalBranchName(baseBranch) !== branchName) {
if (!localHead) {
return false
}
try {
const { stdout } = await gitExecFileAsync(
['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`],
{ cwd: repoPath }
)
if (stdout.trim() !== localHead) {
return false
}
} catch {
return false
}
}
const worktrees = await listWorktrees(repoPath)
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
}
async function canCheckoutExistingLocalBranchSsh(
provider: SshGitProvider,
repoPath: string,
branchName: string,
baseBranch: string
): Promise<boolean> {
let localHead = ''
try {
const { stdout } = await provider.exec(
['rev-parse', '--verify', '--quiet', `refs/heads/${branchName}^{commit}`],
repoPath
)
localHead = stdout.trim()
} catch {
return false
}
if (normalizeLocalBranchName(baseBranch) !== branchName) {
if (!localHead) {
return false
}
try {
const { stdout } = await provider.exec(
['rev-parse', '--verify', '--quiet', `${baseBranch}^{commit}`],
repoPath
)
if (stdout.trim() !== localHead) {
return false
}
} catch {
return false
}
}
const worktrees = await provider.listWorktrees(repoPath)
return !worktrees.some((worktree) => normalizeLocalBranchName(worktree.branch) === branchName)
}
async function listSshRemoteNames(provider: SshGitProvider, repoPath: string): Promise<string[]> {
try {
const { stdout } = await provider.exec(['remote'], repoPath)
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.sort((a, b) => b.length - a.length)
} catch {
return []
}
}
function isAllowedSshRemoteBaseRef(refName: string, allowedBaseRef: string): boolean {
if (!allowedBaseRef) {
return false
}
const normalizedAllowedRef = allowedBaseRef.startsWith('refs/remotes/')
? allowedBaseRef
: `refs/remotes/${allowedBaseRef}`
return refName === normalizedAllowedRef
}
function resolveSshRemoteBranchName(refName: string, remoteNames: string[]): string {
const remotePrefix = 'refs/remotes/'
if (!refName.startsWith(remotePrefix)) {
return refName
}
const remoteAndBranch = refName.slice(remotePrefix.length)
const remote = remoteNames.find((candidate) => remoteAndBranch.startsWith(`${candidate}/`))
if (remote) {
return remoteAndBranch.slice(remote.length + 1)
}
return remoteAndBranch.split('/').slice(1).join('/') || remoteAndBranch
}
async function hasSshRemoteBranchConflict(
provider: SshGitProvider,
repoPath: string,
branchName: string,
allowedBaseRef: string
): Promise<boolean> {
const remoteNames = await listSshRemoteNames(provider, repoPath)
try {
const { stdout } = await provider.exec(
['for-each-ref', '--format=%(refname)', 'refs/remotes'],
repoPath
)
return stdout.split(/\r?\n/).some((line) => {
const refName = line.trim()
if (!refName || /^refs\/remotes\/.+\/HEAD$/.test(refName)) {
return false
}
if (isAllowedSshRemoteBaseRef(refName, allowedBaseRef)) {
return false
}
// Why: `git branch --all --list feature/x` does not match
// `remotes/origin/feature/x`; parse remote refs directly instead.
return resolveSshRemoteBranchName(refName, remoteNames) === branchName
})
} catch {
return false
}
}
type SelectedPrBranchInput = Pick<
CreateWorktreeArgs,
'branchNameOverride' | 'linkedPR' | 'pushTarget'
>
function isSelectedGitHubPrBranchOverride(
args: SelectedPrBranchInput,
branchName: string
): boolean {
return typeof args.linkedPR === 'number' && args.branchNameOverride === branchName
}
function isMatchingSelectedGitHubPr(
existingPR: Awaited<ReturnType<typeof getPRForBranch>>,
args: SelectedPrBranchInput,
branchName: string
): boolean {
return Boolean(
existingPR &&
isSelectedGitHubPrBranchOverride(args, branchName) &&
existingPR.number === args.linkedPR
)
}
function isAllowedPushTargetRemoteConflict(
conflictKind: 'local' | 'remote' | null,
branchName: string,
args: SelectedPrBranchInput
): boolean {
return (
conflictKind === 'remote' &&
isSelectedGitHubPrBranchOverride(args, branchName) &&
args.pushTarget?.branchName === branchName
)
}
async function remotePathExists(
fsProvider: IFilesystemProvider | null | undefined,
pathValue: string
): Promise<boolean> {
if (!fsProvider) {
return false
}
try {
await fsProvider.stat(pathValue)
return true
} catch (error) {
if (isENOENT(error)) {
return false
}
throw error
}
}
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,
store?: WorktreePushTargetStore,
repoId?: string
): Promise<GitPushTarget> {
await validateGitPushTarget(repoPath, target)
const { remoteCreated: _ignoredRemoteCreated, ...sanitizedTarget } = target
let remoteName = target.remoteName
let remoteCreated = false
if (target.remoteUrl) {
const existingRemote = await findRemoteForUrl(repoPath, target.remoteUrl)
if (existingRemote) {
remoteName = existingRemote
// Why: if a later PR worktree reuses an Orca-created fork remote, it
// must inherit ownership so deleting the final user can remove it.
remoteCreated = store
? isPushTargetRemoteCreatedByKnownWorktree(
store,
{
...target,
remoteName: existingRemote
},
repoId
)
: false
} else {
remoteName = await ensureUniqueRemoteName(repoPath, target.remoteName)
await gitExecFileAsync(['remote', 'add', remoteName, target.remoteUrl], { cwd: repoPath })
remoteCreated = true
}
}
await gitExecFileAsync(
[
'fetch',
remoteName,
`+refs/heads/${target.branchName}:refs/remotes/${remoteName}/${target.branchName}`
],
{ cwd: repoPath }
)
return {
...sanitizedTarget,
remoteName,
...(remoteCreated ? { remoteCreated: true } : {})
}
}
type GitRemoteExec = (args: string[], cwd: string) => Promise<{ stdout: string; stderr?: string }>
type WorktreePushTargetStore = Pick<Store, 'getAllWorktreeMeta'>
function sameGitHubRemoteUrl(left: string, right: string): boolean {
if (left === right) {
return true
}
const parsedLeft = parseGitHubOwnerRepo(left)
const parsedRight = parseGitHubOwnerRepo(right)
return Boolean(
parsedLeft &&
parsedRight &&
parsedLeft.owner.toLowerCase() === parsedRight.owner.toLowerCase() &&
parsedLeft.repo.toLowerCase() === parsedRight.repo.toLowerCase()
)
}
function isPushTargetUsedByAnotherWorktree(
store: WorktreePushTargetStore,
removedWorktreeId: string,
target: GitPushTarget
): boolean {
const removedRepoId = getRepoIdFromWorktreeId(removedWorktreeId)
return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => {
// Why: git remotes are repo-local; matching metadata from another repo
// must not pin this repo's fork remote forever.
const belongsToSameRepo = getRepoIdFromWorktreeId(worktreeId) === removedRepoId
if (worktreeId === removedWorktreeId || !belongsToSameRepo || !meta.pushTarget) {
return false
}
const otherRemoteUrl = meta.pushTarget.remoteUrl
const targetRemoteUrl = target.remoteUrl
return (
meta.pushTarget.remoteName === target.remoteName ||
(typeof otherRemoteUrl === 'string' &&
typeof targetRemoteUrl === 'string' &&
sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl))
)
})
}
function isPushTargetRemoteCreatedByKnownWorktree(
store: WorktreePushTargetStore,
target: GitPushTarget,
repoId?: string
): boolean {
return Object.entries(store.getAllWorktreeMeta()).some(([worktreeId, meta]) => {
if (repoId && getRepoIdFromWorktreeId(worktreeId) !== repoId) {
return false
}
if (!meta.pushTarget?.remoteCreated) {
return false
}
const otherRemoteUrl = meta.pushTarget.remoteUrl
const targetRemoteUrl = target.remoteUrl
return (
meta.pushTarget.remoteName === target.remoteName ||
(typeof otherRemoteUrl === 'string' &&
typeof targetRemoteUrl === 'string' &&
sameGitHubRemoteUrl(otherRemoteUrl, targetRemoteUrl))
)
})
}
async function hasBranchConfigUsingRemote(
execGit: GitRemoteExec,
repoPath: string,
target: GitPushTarget
): Promise<boolean> {
try {
const { stdout } = await execGit(
['config', '--get-regexp', '^branch\\..*\\.(remote|pushRemote)$'],
repoPath
)
return stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
.some((line) => {
const value = line.split(/\s+/).slice(1).join(' ')
return value === target.remoteName || value === target.remoteUrl
})
} catch {
return false
}
}
async function cleanupUnusedWorktreePushTargetRemoteWithExec(
repoPath: string,
removedWorktreeId: string,
target: GitPushTarget | undefined,
store: WorktreePushTargetStore,
execGit: GitRemoteExec
): Promise<void> {
if (
!target?.remoteCreated ||
!target.remoteUrl ||
target.remoteName === 'origin' ||
target.remoteName === 'upstream'
) {
return
}
if (isPushTargetUsedByAnotherWorktree(store, removedWorktreeId, target)) {
return
}
if (await hasBranchConfigUsingRemote(execGit, repoPath, target)) {
return
}
let configuredRemoteUrl: string
try {
configuredRemoteUrl = (
await execGit(['remote', 'get-url', target.remoteName], repoPath)
).stdout.trim()
} catch {
return
}
if (!sameGitHubRemoteUrl(configuredRemoteUrl, target.remoteUrl)) {
return
}
await execGit(['remote', 'remove', target.remoteName], repoPath)
}
export async function cleanupUnusedWorktreePushTargetRemote(
repoPath: string,
removedWorktreeId: string,
target: GitPushTarget | undefined,
store: WorktreePushTargetStore
): Promise<void> {
try {
await cleanupUnusedWorktreePushTargetRemoteWithExec(
repoPath,
removedWorktreeId,
target,
store,
(args, cwd) => gitExecFileAsync(args, { cwd })
)
} catch (error) {
console.warn(`[worktrees] Failed to clean up fork PR remote for ${removedWorktreeId}`, error)
}
}
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,
store?: WorktreePushTargetStore,
repoId?: string
): Promise<GitPushTarget> {
assertGitPushTargetShape(target)
const { remoteCreated: _ignoredRemoteCreated, ...sanitizedTarget } = target
await provider.exec(['check-ref-format', '--branch', target.branchName], repoPath)
let remoteName = target.remoteName
let remoteCreated = false
if (target.remoteUrl) {
const existingRemote = await findRemoteForUrlSsh(provider, repoPath, target.remoteUrl)
if (existingRemote) {
remoteName = existingRemote
// Why: if a later PR worktree reuses an Orca-created fork remote, it
// must inherit ownership so deleting the final user can remove it.
remoteCreated = store
? isPushTargetRemoteCreatedByKnownWorktree(
store,
{
...target,
remoteName: existingRemote
},
repoId
)
: false
} else {
remoteName = await ensureUniqueRemoteNameSsh(provider, repoPath, target.remoteName)
await provider.exec(['remote', 'add', remoteName, target.remoteUrl], repoPath)
remoteCreated = true
}
}
await provider.fetchRemoteTrackingRef(
repoPath,
remoteName,
target.branchName,
`refs/remotes/${remoteName}/${target.branchName}`
)
return { ...sanitizedTarget, remoteName, ...(remoteCreated ? { remoteCreated: true } : {}) }
}
export async function cleanupUnusedWorktreePushTargetRemoteSsh(
provider: SshGitProvider,
repoPath: string,
removedWorktreeId: string,
target: GitPushTarget | undefined,
store: WorktreePushTargetStore
): Promise<void> {
try {
await cleanupUnusedWorktreePushTargetRemoteWithExec(
repoPath,
removedWorktreeId,
target,
store,
(args, cwd) => provider.exec(args, cwd)
)
} catch (error) {
console.warn(
`[worktrees] Failed to clean up remote fork PR remote for ${removedWorktreeId}`,
error
)
}
}
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
}
async function readRemoteEffectiveHooks(
repo: Repo,
fsProvider: IFilesystemProvider,
hooksRootPath: string
): Promise<ReturnType<typeof getEffectiveHooksFromConfig>> {
return getEffectiveHooksFromConfig(repo, await readRemoteOrcaYaml(fsProvider, hooksRootPath))
}
async function readRemoteOrcaYaml(
fsProvider: IFilesystemProvider,
hooksRootPath: string
): Promise<ReturnType<typeof parseOrcaYaml>> {
try {
const result = await fsProvider.readFile(joinWorktreeRelativePath(hooksRootPath, 'orca.yaml'))
return result.isBinary ? null : parseOrcaYaml(result.content)
} catch {
return null
}
}
async function createRemoteSetupRunnerScript(
repo: Repo,
worktreePath: string,
script: string,
gitProvider: SshGitProvider,
fsProvider: IFilesystemProvider
): Promise<CreateWorktreeResult['setup']> {
const useWindowsFormat = isWindowsAbsolutePathLike(worktreePath)
const runnerRelativePath = useWindowsFormat ? 'orca/setup-runner.cmd' : 'orca/setup-runner.sh'
const { stdout } = await gitProvider.exec(
['rev-parse', '--git-path', runnerRelativePath],
worktreePath
)
const runnerScriptPath = stdout.trim()
const runnerDir = useWindowsFormat
? win32.dirname(runnerScriptPath)
: posix.dirname(runnerScriptPath)
await fsProvider.createDir(runnerDir)
await fsProvider.writeFile(
runnerScriptPath,
useWindowsFormat ? buildWindowsRunnerScript(script) : buildPosixRunnerScript(script)
)
return {
runnerScriptPath,
envVars: getSetupRunnerEnvVars(repo, worktreePath)
}
}
async function resolveRemoteTrackingBaseSsh(
provider: SshGitProvider,
repoPath: string,
baseBranch: string
): Promise<RemoteTrackingBase | null> {
let remotes: string[]
try {
const { stdout } = await provider.exec(['remote'], repoPath)
remotes = stdout
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
} catch {
return null
}
const remote = remotes
.filter((candidate) => baseBranch.startsWith(`${candidate}/`))
.sort((a, b) => b.length - a.length)[0]
if (!remote) {
return null
}
const branch = baseBranch.slice(remote.length + 1)
if (!branch) {
return null
}
return {
remote,
branch,
ref: `refs/remotes/${remote}/${branch}`,
base: baseBranch
}
}
async function resolveRemoteWorktreeCreateBase(
provider: SshGitProvider,
repo: Repo,
requestedBaseBranch: string | undefined
): Promise<string | null> {
let baseBranch = requestedBaseBranch || repo.worktreeBaseRef
if (baseBranch) {
return baseBranch
}
try {
const { stdout } = await provider.exec(
['symbolic-ref', 'refs/remotes/origin/HEAD', '--short'],
repo.path
)
baseBranch = stdout.trim()
} catch {
return null
}
return baseBranch || null
}
async function resolveRemoteWorktreeCreateBasePlan(
provider: SshGitProvider,
repo: Repo,
requestedBaseBranch: string | undefined
): Promise<RemoteWorktreeCreateBasePlan | null> {
const baseBranch = await resolveRemoteWorktreeCreateBase(provider, repo, requestedBaseBranch)
if (!baseBranch) {
return null
}
return {
baseBranch,
remoteTrackingBase: await resolveRemoteTrackingBaseSsh(provider, repo.path, baseBranch)
}
}
function getOrStartRemoteWorktreeCreateBasePlan(
provider: SshGitProvider,
repo: Repo,
requestedBaseBranch: string | undefined
): Promise<RemoteWorktreeCreateBasePlan | null> {
const key = getSshWorktreeCreateBasePlanKey(repo, requestedBaseBranch)
const existing = sshWorktreeCreateBasePlanInflight.get(key)
if (existing) {
return existing
}
const promise = resolveRemoteWorktreeCreateBasePlan(provider, repo, requestedBaseBranch).finally(
() => {
if (sshWorktreeCreateBasePlanInflight.get(key) === promise) {
sshWorktreeCreateBasePlanInflight.delete(key)
}
}
)
sshWorktreeCreateBasePlanInflight.set(key, promise)
return promise
}
export async function prefetchRemoteWorktreeCreateBase(
provider: SshGitProvider,
repo: Repo,
args: { baseBranch?: string }
): Promise<void> {
const basePlan = await getOrStartRemoteWorktreeCreateBasePlan(provider, repo, args.baseBranch)
if (!basePlan) {
return
}
if (basePlan.remoteTrackingBase) {
await refreshRemoteTrackingBaseForWorktreeCreate(provider, repo, basePlan.remoteTrackingBase)
return
}
// Why: mirrors createRemoteWorktree's legacy local-base fallback so
// prefetch and create share one process-local SSH fetch cache.
const fallbackRemote = basePlan.baseBranch.includes('/')
? basePlan.baseBranch.split('/')[0]
: 'origin'
await fetchRemoteForWorktreeCreate(provider, repo, fallbackRemote)
}
async function refreshLocalBaseRefForRemoteWorktreeCreate(
provider: SshGitProvider,
repoPath: string,
remoteTrackingBase: RemoteTrackingBase
): Promise<LocalBaseRefRefreshResult> {
const resultBase = {
baseRef: remoteTrackingBase.base,
localBranch: remoteTrackingBase.branch
}
const fullRef = `refs/heads/${remoteTrackingBase.branch}`
try {
await provider.exec(
['merge-base', '--is-ancestor', remoteTrackingBase.branch, remoteTrackingBase.ref],
repoPath
)
} catch {
return { ...resultBase, status: 'skipped_not_fast_forward' }
}
try {
const { stdout: worktreeListOutput } = await provider.exec(
['worktree', 'list', '--porcelain'],
repoPath
)
const worktrees = parseWorktreeList(worktreeListOutput)
const ownerWorktree = worktrees.find((wt) => wt.branch === fullRef)
if (ownerWorktree) {
const { stdout: status } = await provider.exec(
['status', '--porcelain', '--untracked-files=no'],
ownerWorktree.path
)
if (status.trim()) {
return {
...resultBase,
status: 'skipped_dirty_worktree',
ownerWorktreePath: ownerWorktree.path
}
}
await provider.exec(['reset', '--hard', remoteTrackingBase.ref], ownerWorktree.path)
return { ...resultBase, status: 'updated', ownerWorktreePath: ownerWorktree.path }
}
await provider.exec(['update-ref', fullRef, remoteTrackingBase.ref], repoPath)
return { ...resultBase, status: 'updated' }
} catch {
return { ...resultBase, status: 'skipped_error' }
}
}
export function notifyWorktreesChanged(mainWindow: BrowserWindow, repoId: string): void {
if (!mainWindow.isDestroyed()) {
mainWindow.webContents.send('worktrees:changed', { repoId })
}
}
// Why: two-phase spinner. Main process fires `'fetching'` before waiting on
// pre-create fetch work and `'creating'` immediately before `git worktree add`.
// Renderer swaps its spinner label in response; fallback is the static
// "Creating worktree..." label if no event arrives.
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> {
const timing = createWorktreeCreateTimingRecorder()
const provider = requireSshGitProvider(repo.connectionId!)
const fsProvider = getSshFilesystemProvider(repo.connectionId!)
const settings = store.getSettings()
const worktreePathSettings = getWorktreePathSettings(repo, settings)
let effectiveRequestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
let effectiveSanitizedName = sanitizedName
const requestedDisplayName = args.displayName
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Why: SSH targets cannot use the local `gh` account, and git email/name are
// commit author identity rather than hosted-account usernames.
const username = await getSshGitUsername(provider, repo.path)
const branchName = await resolveCreateBranchNameSsh(
provider,
repo.path,
args.branchNameOverride,
sanitizedName,
settings,
username
)
let remotePath = computeRemoteWorktreePath(sanitizedName, repo.path, worktreePathSettings, {
useConfiguredAbsolutePath: hasRepoWorktreeBasePath(repo)
})
// 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.
const basePlan = await getOrStartRemoteWorktreeCreateBasePlan(provider, repo, args.baseBranch)
if (!basePlan) {
throw new Error(
'Could not resolve a default base ref for this repo. Pick a base branch explicitly and try again.'
)
}
const { baseBranch, remoteTrackingBase } = basePlan
const checkoutExistingBranch = await canCheckoutExistingLocalBranchSsh(
provider,
repo.path,
branchName,
baseBranch
)
if (!checkoutExistingBranch) {
if (await hasSshRemoteBranchConflict(provider, repo.path, branchName, baseBranch)) {
throw new Error(
`Branch "${branchName}" already exists on a remote. Pick a different worktree name.`
)
}
}
let remotePathResolved = !args.branchNameOverride
for (let suffix = 1; args.branchNameOverride && suffix < 100; suffix += 1) {
effectiveSanitizedName = suffix === 1 ? sanitizedName : `${sanitizedName}-${suffix}`
effectiveRequestedName =
suffix === 1
? args.name
: args.name.trim()
? `${args.name}-${suffix}`
: effectiveSanitizedName
remotePath = computeRemoteWorktreePath(
effectiveSanitizedName,
repo.path,
worktreePathSettings,
{
useConfiguredAbsolutePath: hasRepoWorktreeBasePath(repo)
}
)
if (!(await remotePathExists(fsProvider, remotePath))) {
remotePathResolved = true
break
}
}
if (!remotePathResolved) {
throw new Error(
`Could not find an available remote worktree path for "${sanitizedName}". Pick a different worktree name.`
)
}
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)
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 (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 {
// Why: local or otherwise non-remote-tracking bases preserve legacy
// best-effort fetch behavior. Only remote-tracking bases must fail closed,
// because creating from them after a failed refresh silently makes stale worktrees.
const fallbackRemote = baseBranch.includes('/') ? baseBranch.split('/')[0] : 'origin'
try {
await fetchRemoteForWorktreeCreate(provider, repo, fallbackRemote)
} catch {
/* best-effort */
}
}
const localBaseRefRefresh =
settings.refreshLocalBaseRefOnWorktreeCreate && !checkoutExistingBranch && remoteTrackingBase
? await refreshLocalBaseRefForRemoteWorktreeCreate(provider, repo.path, remoteTrackingBase)
: undefined
if (fsProvider) {
const primaryHooks = await readRemoteEffectiveHooks(repo, fsProvider, repo.path)
if (primaryHooks?.scripts.setup) {
shouldRunSetupForCreate(repo, args.setupDecision)
}
}
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,
store,
repo.id
)
}
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 timing.time('git_worktree_add', async () =>
provider.addWorktree(
repo.path,
branchName,
remotePath,
checkoutExistingBranch
? { checkoutExistingBranch }
: { base: baseBranch, ...(sparseDirectories.length > 0 ? { noCheckout: true } : {}) }
)
)
} 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
}
if (sparseDirectories.length > 0) {
try {
// Why: SSH providers expose generic git exec, so the remote sparse flow
// can mirror local addSparseWorktree without adding a relay method.
await provider.exec(['sparse-checkout', 'init', '--cone'], remotePath)
await provider.exec(['sparse-checkout', 'set', '--', ...sparseDirectories], remotePath)
await provider.exec(['checkout', branchName], remotePath)
} catch (err) {
if (!checkoutExistingBranch) {
await unsetRemoteWorktreeCreationBase(provider, remotePath, branchName)
}
await provider
.removeWorktree(remotePath, true, {
deleteBranch: !checkoutExistingBranch,
// Why: sparse setup failed before the user could work in the new
// branch, so rollback should remove the just-created remote branch.
forceBranchDelete: !checkoutExistingBranch
})
.catch(() => undefined)
throw err
}
}
// Re-list to get the created worktree info
const gitWorktrees = await timing.time('list_created_worktree', async () =>
provider.listWorktrees(repo.path)
)
const created = gitWorktrees.find(
(gw) => gw.branch?.endsWith(branchName) || gw.path.endsWith(effectiveSanitizedName)
)
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,
orcaCreatedAt: now,
orcaCreationSource: 'ssh',
orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings),
baseRef: baseBranch,
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
...(configuredPushTarget ? { pushTarget: configuredPushTarget } : {}),
...(requestedDisplayName
? { displayName: requestedDisplayName }
: shouldSetDisplayName(effectiveRequestedName, branchName, effectiveSanitizedName)
? { displayName: effectiveRequestedName }
: {}),
...(isTuiAgent(args.createdWithAgent) ? { createdWithAgent: args.createdWithAgent } : {}),
...(args.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent)
? { pendingFirstAgentMessageRename: true }
: {}),
...(sparseDirectories.length > 0
? {
sparseDirectories,
sparseBaseRef: baseBranch,
sparsePresetId
}
: {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
}
const { worktree } = timing.timeSync('persist_metadata', () => {
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
return { 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.
let setup: CreateWorktreeResult['setup']
let defaultTabs: CreateWorktreeResult['defaultTabs']
if (fsProvider) {
await timing.time('prepare_setup', async () => {
const yamlHooks = await readRemoteOrcaYaml(fsProvider, created.path)
const hooks = getEffectiveHooksFromConfig(repo, yamlHooks)
try {
defaultTabs = getDefaultTabsLaunch(yamlHooks, repo, args.setupDecision)
} catch (error) {
// Why: default tab commands share setup's run policy. If the target branch
// adds commands without a renderer decision, create the tabs but don't run them.
console.warn(`[hooks] default tab commands skipped for ${created.path}:`, error)
defaultTabs = yamlHooks?.defaultTabs
? { tabs: yamlHooks.defaultTabs, runCommands: false }
: undefined
}
const setupScript = hooks?.scripts.setup
let shouldLaunchSetup = false
if (setupScript) {
try {
shouldLaunchSetup = shouldRunSetupForCreate(repo, args.setupDecision)
} catch (error) {
// Why: the remote worktree already exists. If the created branch adds
// a setup hook without a renderer decision, skip setup instead of
// reporting successful git creation as failed.
console.warn(`[hooks] setup hook skipped for ${created.path}:`, error)
}
}
if (setupScript && shouldLaunchSetup) {
try {
setup = await createRemoteSetupRunnerScript(
repo,
created.path,
setupScript,
provider,
fsProvider
)
} catch (error) {
console.error(`[hooks] Failed to prepare setup runner for ${created.path}:`, error)
}
}
})
}
notifyWorktreesChanged(mainWindow, repo.id)
return {
worktree,
...(setup ? { setup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(localBaseRefRefresh ? { localBaseRefRefresh } : {}),
timing: timing.finish()
}
}
export async function createLocalWorktree(
args: CreateWorktreeArgs,
repo: Repo,
store: Store,
mainWindow: BrowserWindow,
runtime?: OrcaRuntimeService
): Promise<CreateWorktreeResult> {
const timing = createWorktreeCreateTimingRecorder()
const settings = store.getSettings()
const worktreePathSettings = getWorktreePathSettings(repo, settings)
const username = getGitUsername(repo.path)
const requestedName = args.name
const sanitizedName = sanitizeWorktreeName(args.name)
const requestedDisplayName = args.displayName
? sanitizeWorktreeDisplayName(args.displayName)
: undefined
// Why: resolve the base before branch/path selection so remote-tracking bases
// can be refreshed before `git worktree add`. Creating first and repairing
// later races setup scripts, agents, and user edits.
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 remoteTrackingBase: RemoteTrackingBase | null = null
let remoteTrackingRefresh: {
base: RemoteTrackingBase
hadLocalBaseRef: boolean
promise: Promise<RemoteFetchResult>
} | null = null
let legacyFetchPromise: Promise<void> | null = null
if (runtime) {
remoteTrackingBase = await runtime.resolveRemoteTrackingBase(repo.path, baseBranch)
if (remoteTrackingBase) {
const hasLocalBaseRef = await runtime.hasRemoteTrackingRef(repo.path, remoteTrackingBase)
emitCreateWorktreeProgress(mainWindow, 'fetching')
remoteTrackingRefresh = {
base: remoteTrackingBase,
hadLocalBaseRef: hasLocalBaseRef,
promise: runtime.getOrStartRemoteTrackingBaseRefresh(repo.path, remoteTrackingBase)
}
} 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')
}
const workspaceRoot = computeWorkspaceRoot(repo.path, worktreePathSettings)
// Why: this validation does not depend on remote refs, so it can overlap a
// required remote-tracking base refresh.
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.
}
}
}
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 checkoutExistingBranch = false
let selectedExistingLocalBranchName: string | null = null
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 = await resolveCreateBranchName(
repo.path,
selectedExistingLocalBranchName
? selectedExistingLocalBranchName
: args.branchNameOverride
? args.branchNameOverride
: undefined,
effectiveSanitizedName,
settings,
username
)
checkoutExistingBranch = await canCheckoutExistingLocalBranch(repo.path, branchName, baseBranch)
if (checkoutExistingBranch && !selectedExistingLocalBranchName) {
// Why: suffix retries may need a new path, but an existing branch checkout
// must keep using the user-selected branch instead of creating a sibling.
selectedExistingLocalBranchName = branchName
}
lastBranchConflictKind = checkoutExistingBranch
? null
: await getBranchConflictKind(repo.path, branchName, baseBranch)
const allowedPushTargetRemoteConflict =
lastBranchConflictKind &&
isAllowedPushTargetRemoteConflict(lastBranchConflictKind, branchName, args)
if (lastBranchConflictKind) {
if (allowedPushTargetRemoteConflict) {
lastExistingPR = null
let lookupFailed = false
try {
lastExistingPR = await getPRForBranch(repo.path, branchName)
} catch {
lookupFailed = true
}
if (!lookupFailed && isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
lastBranchConflictKind = null
} else if (lastExistingPR) {
break
}
}
}
if (lastBranchConflictKind) {
// Why: PR resolver-provided branch names are exact branch identity.
// Retrying with a suffixed branch would silently detach the worktree
// from the PR being opened.
if (args.branchNameOverride) {
break
}
continue
}
// Why: `gh pr list` is a network round-trip that previously ran on every
// create, adding ~1–3s 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 && !checkoutExistingBranch) {
lastExistingPR = null
try {
lastExistingPR = await getPRForBranch(repo.path, branchName)
} catch {
// GitHub API may be unreachable, rate-limited, or token missing
}
if (lastExistingPR && !isMatchingSelectedGitHubPr(lastExistingPR, args, branchName)) {
if (args.branchNameOverride) {
break
}
continue
}
}
worktreePath = ensurePathWithinWorkspace(
computeWorktreePath(effectiveSanitizedName, repo.path, worktreePathSettings),
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.`
)
}
if (remoteTrackingRefresh) {
await timing.time('refresh_base_ref', async () => {
const result = await remoteTrackingRefresh.promise
if (!result.ok) {
throw new Error(
`Could not refresh base ref "${baseBranch}" from "${remoteTrackingRefresh.base.remote}". Check your network and try again.`
)
}
if (
!remoteTrackingRefresh.hadLocalBaseRef &&
!(await runtime?.hasRemoteTrackingRef(repo.path, remoteTrackingRefresh.base))
) {
throw new Error(`Base ref "${baseBranch}" was not found after fetching.`)
}
})
}
if (legacyFetchPromise) {
await timing.time('refresh_base_ref', async () => {
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, store, repo.id)
}
const existingBranchOption = { checkoutExistingBranch }
const addResult: AddWorktreeResult =
(await timing.time('git_worktree_add', async () =>
sparseDirectories.length > 0
? checkoutExistingBranch
? addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
existingBranchOption
)
: addSparseWorktree(
repo.path,
worktreePath,
branchName,
sparseDirectories,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate
)
: checkoutExistingBranch
? addWorktree(
repo.path,
worktreePath,
branchName,
baseBranch,
settings.refreshLocalBaseRefOnWorktreeCreate,
false,
existingBranchOption
)
: 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 timing.time('list_created_worktree', async () =>
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,
orcaCreatedAt: now,
orcaCreationSource: 'desktop',
orcaCreationWorkspaceLayout: getWorktreeCreationLayout(repo, settings),
baseRef: baseBranch,
...(checkoutExistingBranch ? { preserveBranchOnDelete: true } : {}),
...(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.pendingFirstAgentMessageRename === true && isTuiAgent(args.createdWithAgent)
? { pendingFirstAgentMessageRename: true }
: {}),
...(args.linkedIssue !== undefined ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR !== undefined ? { linkedPR: args.linkedPR } : {}),
...(args.linkedLinearIssue !== undefined ? { linkedLinearIssue: args.linkedLinearIssue } : {}),
...(args.manualOrder !== undefined ? { manualOrder: args.manualOrder } : {}),
...(args.linkedGitLabIssue !== undefined ? { linkedGitLabIssue: args.linkedGitLabIssue } : {}),
...(args.linkedGitLabMR !== undefined ? { linkedGitLabMR: args.linkedGitLabMR } : {}),
...(args.workspaceStatus !== undefined ? { workspaceStatus: args.workspaceStatus } : {})
}
const { worktree } = timing.timeSync('persist_metadata', () => {
const meta = store.setWorktreeMeta(worktreeId, metaUpdates)
return { 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.
const symlinkPaths = repo.symlinkPaths ?? []
if (settings.experimentalWorktreeSymlinks && symlinkPaths.length > 0) {
await timing.time('create_symlinks', async () => {
await createWorktreeSymlinks(repo.path, created.path, 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']
let defaultTabs: CreateWorktreeResult['defaultTabs']
await timing.time('prepare_setup', async () => {
const createdYamlHooks = loadHooks(worktreePath)
const createdEffectiveHooks = getEffectiveHooksFromConfig(repo, createdYamlHooks)
try {
defaultTabs = getDefaultTabsLaunch(createdYamlHooks, repo, args.setupDecision)
} catch (error) {
// Why: default tab commands share setup's run policy. If the target branch
// adds commands without a renderer decision, create the tabs but don't run them.
console.warn(`[hooks] default tab commands skipped for ${worktreePath}:`, error)
defaultTabs = createdYamlHooks?.defaultTabs
? { tabs: createdYamlHooks.defaultTabs, runCommands: false }
: undefined
}
const setupScript = createdEffectiveHooks?.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)
}
}
})
const stagedStartup = await timing.time('spawn_startup_terminal', () =>
spawnLocalStartupAndSetupTerminals({
runtime,
worktree,
startup: args.startup,
setup,
defaultTabs,
settings,
createdWithAgent: args.createdWithAgent
})
)
notifyWorktreesChanged(mainWindow, repo.id)
return {
worktree,
...(setup && !stagedStartup.didSpawnSetup ? { setup } : {}),
...(defaultTabs ? { defaultTabs } : {}),
...(addResult.localBaseRefRefresh
? { localBaseRefRefresh: addResult.localBaseRefRefresh }
: {}),
...(stagedStartup.startupTerminal ? { startupTerminal: stagedStartup.startupTerminal } : {}),
...(stagedStartup.warning ? { warning: stagedStartup.warning } : {}),
timing: timing.finish()
}
}