Fix SSH setup hooks for remote worktrees (#2477)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-20 23:50:02 -04:00 committed by GitHub
parent fd1cbdf3c5
commit c8c7553ecf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 265 additions and 10 deletions

View File

@ -297,10 +297,11 @@ function hasLegacyLocalScripts(repo: Repo): boolean {
)
}
export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null {
const hooksRoot = worktreePath ?? repo.path
const yamlHooks = loadHooks(hooksRoot)
const yamlFileExists = hasHooksFile(hooksRoot)
export function getEffectiveHooksFromConfig(
repo: Repo,
yamlHooks: OrcaHooks | null,
yamlFileExists: boolean
): OrcaHooks | null {
const localSetup = repo.hookSettings?.scripts.setup
const localArchive = repo.hookSettings?.scripts.archive
const rawPolicy = repo.hookSettings?.commandSourcePolicy
@ -329,6 +330,11 @@ export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks
}
}
export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null {
const hooksRoot = worktreePath ?? repo.path
return getEffectiveHooksFromConfig(repo, loadHooks(hooksRoot), hasHooksFile(hooksRoot))
}
export function getEffectiveSetupRunPolicy(repo: Repo): SetupRunPolicy {
return repo.hookSettings?.setupRunPolicy ?? getDefaultRepoHookSettings().setupRunPolicy!
}
@ -399,7 +405,7 @@ function getGitPath(cwd: string, relativePath: string): string {
}).trim()
}
function buildWindowsRunnerScript(script: string): string {
export function buildWindowsRunnerScript(script: string): string {
const lines = script.replace(/\r?\n/g, '\n').split('\n')
const runnerLines = ['@echo off', 'setlocal EnableExtensions']
@ -430,6 +436,14 @@ export function createSetupRunnerScript(
return createWorktreeRunnerScript(repo, worktreePath, script, 'setup-runner')
}
export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
return getSetupEnvVars(repo, worktreePath)
}
export function buildPosixRunnerScript(script: string): string {
return `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n`
}
export function createIssueCommandRunnerScript(
repo: Repo,
worktreePath: string,

View File

@ -8,7 +8,7 @@
// cohesive flow would split awkwardly.
import type { BrowserWindow } from 'electron'
import { join } from 'path'
import { join, posix, win32 } from 'path'
import { existsSync } from 'fs'
import { randomUUID } from 'crypto'
import type { Store } from '../persistence'
@ -29,11 +29,22 @@ 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 {
buildPosixRunnerScript,
buildWindowsRunnerScript,
createSetupRunnerScript,
getEffectiveHooks,
getEffectiveHooksFromConfig,
getSetupRunnerEnvVars,
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 { isTuiAgent } from '../../shared/tui-agent-config'
import { isWindowsAbsolutePathLike } from '../../shared/cross-platform-path'
import {
sanitizeWorktreeName,
sanitizeWorktreeDisplayName,
@ -48,6 +59,8 @@ import { getRepoIdFromWorktreeId } from '../../shared/worktree-id'
import { invalidateAuthorizedRootsCache } 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'
async function findRemoteForUrl(repoPath: string, remoteUrl: string): Promise<string | null> {
const target = parseGitHubOwnerRepo(remoteUrl)
@ -484,6 +497,48 @@ async function configureCreatedWorktreePushTargetSsh(
return target
}
async function readRemoteEffectiveHooks(
repo: Repo,
fsProvider: IFilesystemProvider,
hooksRootPath: string
): Promise<ReturnType<typeof getEffectiveHooksFromConfig>> {
try {
const result = await fsProvider.readFile(joinWorktreeRelativePath(hooksRootPath, 'orca.yaml'))
const yamlHooks = result.isBinary ? null : parseOrcaYaml(result.content)
return getEffectiveHooksFromConfig(repo, yamlHooks, true)
} catch {
return getEffectiveHooksFromConfig(repo, null, false)
}
}
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,
@ -642,6 +697,14 @@ export async function createRemoteWorktree(
}
}
const fsProvider = getSshFilesystemProvider(repo.connectionId!)
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
@ -759,8 +822,41 @@ export async function createRemoteWorktree(
// local-only until that protocol work is in scope. Remote repos with
// `symlinkPaths` configured have them silently ignored here.
let setup: CreateWorktreeResult['setup']
if (fsProvider) {
const hooks = await readRemoteEffectiveHooks(repo, fsProvider, created.path)
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 }
return {
worktree,
...(setup ? { setup } : {})
}
}
export async function createLocalWorktree(

View File

@ -19,7 +19,12 @@ const {
getEffectiveHooksMock,
createIssueCommandRunnerScriptMock,
createSetupRunnerScriptMock,
getEffectiveHooksFromConfigMock,
parseOrcaYamlMock,
shouldRunSetupForCreateMock,
buildPosixRunnerScriptMock,
buildWindowsRunnerScriptMock,
getSetupRunnerEnvVarsMock,
runHookMock,
hasHooksFileMock,
loadHooksMock,
@ -27,6 +32,7 @@ const {
ensurePathWithinWorkspaceMock,
gitExecFileAsyncMock,
getSshGitProviderMock,
getSshFilesystemProviderMock,
getActiveMultiplexerMock
} = vi.hoisted(() => ({
handleMock: vi.fn(),
@ -46,7 +52,12 @@ const {
getEffectiveHooksMock: vi.fn(),
createIssueCommandRunnerScriptMock: vi.fn(),
createSetupRunnerScriptMock: vi.fn(),
getEffectiveHooksFromConfigMock: vi.fn(),
parseOrcaYamlMock: vi.fn(),
shouldRunSetupForCreateMock: vi.fn(),
buildPosixRunnerScriptMock: vi.fn(),
buildWindowsRunnerScriptMock: vi.fn(),
getSetupRunnerEnvVarsMock: vi.fn(),
runHookMock: vi.fn(),
hasHooksFileMock: vi.fn(),
loadHooksMock: vi.fn(),
@ -54,6 +65,7 @@ const {
ensurePathWithinWorkspaceMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderMock: vi.fn(),
getSshFilesystemProviderMock: vi.fn(),
getActiveMultiplexerMock: vi.fn()
}))
@ -105,15 +117,24 @@ vi.mock('../providers/ssh-git-dispatch', () => ({
}
}))
vi.mock('../providers/ssh-filesystem-dispatch', () => ({
getSshFilesystemProvider: getSshFilesystemProviderMock
}))
vi.mock('./ssh', () => ({
getActiveMultiplexer: getActiveMultiplexerMock
}))
vi.mock('../hooks', () => ({
buildPosixRunnerScript: buildPosixRunnerScriptMock,
buildWindowsRunnerScript: buildWindowsRunnerScriptMock,
createIssueCommandRunnerScript: createIssueCommandRunnerScriptMock,
createSetupRunnerScript: createSetupRunnerScriptMock,
getEffectiveHooks: getEffectiveHooksMock,
getEffectiveHooksFromConfig: getEffectiveHooksFromConfigMock,
getSetupRunnerEnvVars: getSetupRunnerEnvVarsMock,
loadHooks: loadHooksMock,
parseOrcaYaml: parseOrcaYamlMock,
runHook: runHookMock,
hasHooksFile: hasHooksFileMock,
shouldRunSetupForCreate: shouldRunSetupForCreateMock
@ -202,8 +223,13 @@ describe('registerWorktreeHandlers', () => {
getWorkItemMock,
getPullRequestPushTargetMock,
getEffectiveHooksMock,
getEffectiveHooksFromConfigMock,
parseOrcaYamlMock,
createIssueCommandRunnerScriptMock,
createSetupRunnerScriptMock,
buildPosixRunnerScriptMock,
buildWindowsRunnerScriptMock,
getSetupRunnerEnvVarsMock,
shouldRunSetupForCreateMock,
runHookMock,
hasHooksFileMock,
@ -212,6 +238,7 @@ describe('registerWorktreeHandlers', () => {
ensurePathWithinWorkspaceMock,
gitExecFileAsyncMock,
getSshGitProviderMock,
getSshFilesystemProviderMock,
getActiveMultiplexerMock,
mainWindow.webContents.send,
store.getRepos,
@ -277,7 +304,22 @@ describe('registerWorktreeHandlers', () => {
// don't trip on undefined.
gitExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
getEffectiveHooksMock.mockReturnValue(null)
getEffectiveHooksFromConfigMock.mockReturnValue(null)
parseOrcaYamlMock.mockReturnValue(null)
shouldRunSetupForCreateMock.mockReturnValue(false)
buildPosixRunnerScriptMock.mockImplementation(
(script: string) => `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n`
)
buildWindowsRunnerScriptMock.mockImplementation((script: string) => script)
getSetupRunnerEnvVarsMock.mockImplementation(
(repoArg: { path: string }, worktreePath: string) => ({
ORCA_ROOT_PATH: repoArg.path,
ORCA_WORKTREE_PATH: worktreePath,
ORCA_WORKSPACE_NAME: worktreePath.split('/').at(-1) ?? '',
CONDUCTOR_ROOT_PATH: repoArg.path,
GHOSTX_ROOT_PATH: repoArg.path
})
)
createSetupRunnerScriptMock.mockReturnValue({
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
envVars: {
@ -874,6 +916,94 @@ describe('registerWorktreeHandlers', () => {
})
})
it('reads remote orca.yaml and returns a setup launch payload during SSH create', async () => {
const repo = {
id: 'repo-ssh',
path: '/remote/repo',
displayName: 'ssh',
badgeColor: '#000',
addedAt: 0,
connectionId: 'conn-1',
worktreeBaseRef: 'origin/main'
}
const provider = {
exec: vi.fn().mockImplementation(async (args: string[]) => {
if (args[0] === 'remote') {
return { stdout: 'origin\n', stderr: '' }
}
if (args[0] === 'rev-parse') {
return {
stdout: '/remote/repo/.git/worktrees/improve-dashboard/orca/setup-runner.sh\n',
stderr: ''
}
}
return { stdout: '', stderr: '' }
}),
addWorktree: vi.fn().mockResolvedValue(undefined),
listWorktrees: vi.fn().mockResolvedValue([
{
path: '/remote/improve-dashboard',
head: 'abc123',
branch: 'refs/heads/improve-dashboard',
isBare: false,
isMainWorktree: false
}
])
}
const fsProvider = {
readFile: vi.fn().mockResolvedValue({
content: 'scripts:\n setup: pnpm install\n',
isBinary: false
}),
createDir: vi.fn().mockResolvedValue(undefined),
writeFile: vi.fn().mockResolvedValue(undefined)
}
const mux = {
request: vi.fn().mockResolvedValue(undefined),
notify: vi.fn()
}
store.getRepos.mockReturnValue([repo])
store.getRepo.mockReturnValue(repo)
getSshGitProviderMock.mockReturnValue(provider)
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
getActiveMultiplexerMock.mockReturnValue(mux)
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
shouldRunSetupForCreateMock.mockReturnValue(true)
const result = await handlers['worktrees:create'](null, {
repoId: 'repo-ssh',
name: 'improve-dashboard',
setupDecision: 'run'
})
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/repo/orca.yaml')
expect(fsProvider.readFile).toHaveBeenCalledWith('/remote/improve-dashboard/orca.yaml')
expect(provider.exec).toHaveBeenCalledWith(
['rev-parse', '--git-path', 'orca/setup-runner.sh'],
'/remote/improve-dashboard'
)
expect(fsProvider.createDir).toHaveBeenCalledWith(
'/remote/repo/.git/worktrees/improve-dashboard/orca'
)
expect(fsProvider.writeFile).toHaveBeenCalledWith(
'/remote/repo/.git/worktrees/improve-dashboard/orca/setup-runner.sh',
'#!/usr/bin/env bash\nset -e\npnpm install\n'
)
expect(result).toEqual(
expect.objectContaining({
setup: {
runnerScriptPath: '/remote/repo/.git/worktrees/improve-dashboard/orca/setup-runner.sh',
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/remote/repo',
ORCA_WORKTREE_PATH: '/remote/improve-dashboard'
})
}
})
)
})
it('does not create an SSH worktree when remote-tracking base refresh fails', async () => {
const repo = {
id: 'repo-ssh',

View File

@ -6268,6 +6268,7 @@ export class OrcaRuntimeService {
workspaceStatus?: string
sparseCheckout?: { directories: string[]; presetId?: string }
pushTarget?: GitPushTarget
runHooks?: boolean
setupDecision?: 'run' | 'skip' | 'inherit'
createdWithAgent?: TuiAgent
startup?: WorktreeStartupLaunch
@ -6292,7 +6293,8 @@ export class OrcaRuntimeService {
...(args.displayName ? { displayName: args.displayName } : {}),
...(args.baseBranch ? { baseBranch: args.baseBranch } : {}),
...(args.branchNameOverride ? { branchNameOverride: args.branchNameOverride } : {}),
...(args.setupDecision ? { setupDecision: args.setupDecision } : {}),
...(args.runHooks ? { setupDecision: 'run' as const } : {}),
...(!args.runHooks && args.setupDecision ? { setupDecision: args.setupDecision } : {}),
...(args.sparseCheckout ? { sparseCheckout: args.sparseCheckout } : {}),
...(args.linkedIssue != null ? { linkedIssue: args.linkedIssue } : {}),
...(args.linkedPR != null ? { linkedPR: args.linkedPR } : {}),

View File

@ -16,7 +16,7 @@ describe('buildSetupRunnerCommand', () => {
buildSetupRunnerCommand(
'\\\\wsl.localhost\\Ubuntu\\home\\jin\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh'
)
).toBe("bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh")
).toBe('bash /home/jin/repo/.git/worktrees/feature/orca/setup-runner.sh')
})
it('uses cmd.exe for native Windows runner scripts', () => {
@ -28,4 +28,14 @@ describe('buildSetupRunnerCommand', () => {
'cmd.exe /c "C:\\repo\\.git\\orca\\setup-runner.cmd"'
)
})
it('uses bash for POSIX runner paths on Windows clients', () => {
vi.stubGlobal('navigator', {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'
})
expect(buildSetupRunnerCommand('/home/dev/repo/.git/orca/setup-runner.sh')).toBe(
'bash /home/dev/repo/.git/orca/setup-runner.sh'
)
})
})

View File

@ -5,6 +5,9 @@ export function buildSetupRunnerCommand(
platform: SetupRunnerCommandPlatform
): string {
if (platform === 'windows') {
if (runnerScriptPath.startsWith('/')) {
return `bash ${quotePosixArg(runnerScriptPath)}`
}
if (isWslUncPath(runnerScriptPath)) {
const linuxPath = wslUncToLinuxPath(runnerScriptPath)
return `bash ${quotePosixArg(linuxPath)}`