From c8c7553ecf3d8dac2ba45f403a678a79c436acf0 Mon Sep 17 00:00:00 2001 From: Jinwoo Hong <73622457+Jinwoo-H@users.noreply.github.com> Date: Wed, 20 May 2026 23:50:02 -0400 Subject: [PATCH] Fix SSH setup hooks for remote worktrees (#2477) Co-authored-by: Orca --- src/main/hooks.ts | 24 +++- src/main/ipc/worktree-remote.ts | 102 ++++++++++++++++- src/main/ipc/worktrees.test.ts | 130 ++++++++++++++++++++++ src/main/runtime/orca-runtime.ts | 4 +- src/renderer/src/lib/setup-runner.test.ts | 12 +- src/shared/setup-runner-command.ts | 3 + 6 files changed, 265 insertions(+), 10 deletions(-) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index 08bf04695..3b0df6b62 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -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 { + 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, diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index aba7fd59c..35b08c130 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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 { const target = parseGitHubOwnerRepo(remoteUrl) @@ -484,6 +497,48 @@ async function configureCreatedWorktreePushTargetSsh( return target } +async function readRemoteEffectiveHooks( + repo: Repo, + fsProvider: IFilesystemProvider, + hooksRootPath: string +): Promise> { + 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 { + 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( diff --git a/src/main/ipc/worktrees.test.ts b/src/main/ipc/worktrees.test.ts index 26e84f85d..e26b09124 100644 --- a/src/main/ipc/worktrees.test.ts +++ b/src/main/ipc/worktrees.test.ts @@ -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', diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 0b762785e..d0fd90fae 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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 } : {}), diff --git a/src/renderer/src/lib/setup-runner.test.ts b/src/renderer/src/lib/setup-runner.test.ts index f78591d11..a2491d8d2 100644 --- a/src/renderer/src/lib/setup-runner.test.ts +++ b/src/renderer/src/lib/setup-runner.test.ts @@ -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' + ) + }) }) diff --git a/src/shared/setup-runner-command.ts b/src/shared/setup-runner-command.ts index 69f3406c1..fd7b80184 100644 --- a/src/shared/setup-runner-command.ts +++ b/src/shared/setup-runner-command.ts @@ -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)}`