fix(worktrees): respect Windows shell for setup runners (#6967)
* Honor configured shells during worktree setup
* Align setup launch paths with selected Windows shells
* Carry setup shell selection through deferred launches
* Prove Windows setup shell routing at its real adapters
* Ground remote PowerShell proof in the real writer
* Preserve Git Bash across deferred setup launches
* Harden Windows setup runner shell selection
- Resolve remote PowerShell binary without local pwsh probe: for SSH/remote
Windows worktrees, isPwshAvailable() reflects only the LOCAL host, so an
'auto' implementation could route the remote runner to a pwsh.exe the remote
lacks. Add resolveSetupRunnerShell(..., { probeLocalPwsh: false }) so remote
auto keeps the always-present powershell.exe; explicit pwsh.exe still honored.
- Preserve native exit codes in the PowerShell runner by checking
$LASTEXITCODE before $?, so a failing native command surfaces its real code
instead of a generic exit 1; $? still catches cmdlet soft-failures.
- Write the PowerShell runner with a UTF-8 BOM so Windows PowerShell 5.1 (the
new default powershell.exe) reads it as UTF-8 instead of ANSI, preventing
non-ASCII setup-script corruption.
- Add unit tests for the remote-probe behavior.
* Restore setup-shell scope narrowing over the rebase
The force-pushed rebase dropped five review-fix commits that were already
on this branch; this reapplies their combined effect on top of the new
base and the hardening commit:
- Keep SSH setup shell selection remote-owned (no local terminalWindowsShell
or pwsh routing for remote hosts; supersedes the probeLocalPwsh guard)
- Preserve cmd setup compatibility outside POSIX shells (no .ps1 runner
family, so the BOM/exit-code hardening is no longer applicable)
- Route WSL setup runners from the project runtime
- Avoid blocking PowerShell probes during setup creation
- Correct SSH and WSL background setup fixtures
* Satisfy the changed-code gates for the setup-shell runner
- createWorktreeRunnerScript took 7 positional parameters, tripping the
changed-code max-params gate; move it to a single options object.
- hooks-runner.test.ts deep-equals the createSetupRunnerScript result, so
assert the cmd shell now returned for native Windows worktrees.
* Carry the setup launch shell through observed and issue runners
- buildObservedSetupCommand takes the runner's launch shell so WSL-routed
Windows-drive setup replays use /mnt/c instead of Git Bash /c
- resolveSetupRunnerShell gates the posix runner on the same Git Bash
resolution the PTY uses, so a missing or non-MSYS bash keeps the cmd runner
- issue-command runners carry their launch shell, and the renderer passes it
when building the queued command
- treat a bare `bash` shell setting as POSIX like `bash.exe`
Co-authored-by: Orca <help@stably.ai>
* fix(worktrees): close counsel P1 gaps for Windows setup shells
Route windowless/headless creates through the shell-aware setup runner when a
PTY controller is available, existence-check explicit Git Bash paths before
committing to .sh runners, thread the resolved shell into issue-command
runners, and document the intentional Git Bash interpreter flip with a narrow
scope table.
* Convert setup env to MSYS form and harden the bare cmd runner launch
C3: a Git Bash setup runner now receives ORCA_*/CONDUCTOR_*/GHOSTX_* path
values in /c/... form, matching the runner path and the shell's own HOME/PWD.
C5: extension-less `bash` resolves to Git Bash everywhere, matching how
resolveWindowsShellStartupFamily already classifies it.
C7: runner paths carrying characters that cannot be quoted on a cmd command
line launch through a delayed-expansion PowerShell shim instead, and the batch
runner disables inherited delayed expansion so `!` in setup lines survives.
Co-authored-by: Orca <help@stably.ai>
* docs: note MSYS ORCA_* paths and bare bash Git Bash resolution
Keep the setup-shell release note aligned with C3 env conversion and C5 bare
bash resolution so the published claim matches runtime behavior.
* revert: drop windows-setup-shell doc allowlist and AGENTS link
Keep the counsel P1/P2 product fixes without expanding the docs allowlist
or AGENTS.md guidance surface.
* fix(plugins): contain Parcel unsubscribe rejections under Vitest
Dev plugin watchers fire-and-forget unsubscribe, and in-process Parcel
can reject when temp watch roots are already deleted. Catch those
rejections so they cannot fail the suite as unhandled errors.
* fix(plugins): keep in-process unsubscribe rejection surface
Swallowing Parcel unsubscribe errors broke mocked unsubscribe tests
that return non-Promises and expect rejections. Contain failures only
in PluginDevWatcher fire-and-forget paths.
---------
Co-authored-by: OrcaWin <alpha-eng@stably.ai>
Co-authored-by: Brennan Benson <79079362+brennanb2025@users.noreply.github.com>
Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
d426e35be3
commit
8c5371ebad
|
|
@ -20,11 +20,27 @@ function quoteWindowsArg(value) {
|
|||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
// Why: under a Git Bash setup runner, Orca exports ORCA_WORKTREE_PATH in MSYS form (/c/...), which
|
||||
// cmd.exe cannot resolve. This is the migration pattern for any setup script feeding a native exe.
|
||||
function posixShellPathToNativeWindowsPath(value) {
|
||||
const driveMatch = value.match(/^\/([A-Za-z])\/(.*)$/)
|
||||
if (driveMatch) {
|
||||
return `${driveMatch[1].toUpperCase()}:\\${driveMatch[2].replace(/\//g, '\\')}`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function spawnOptionalSetup(spawn, setupPath, worktreePath, platform, env) {
|
||||
if (platform === 'win32') {
|
||||
const nativeWorktreePath = posixShellPathToNativeWindowsPath(worktreePath)
|
||||
spawn(
|
||||
env.ComSpec || 'cmd.exe',
|
||||
['/d', '/s', '/c', `call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(worktreePath)}`],
|
||||
[
|
||||
'/d',
|
||||
'/s',
|
||||
'/c',
|
||||
`call ${quoteWindowsArg(setupPath)} ${quoteWindowsArg(nativeWorktreePath)}`
|
||||
],
|
||||
{
|
||||
stdio: 'inherit',
|
||||
windowsVerbatimArguments: true
|
||||
|
|
|
|||
|
|
@ -53,10 +53,86 @@ describe('Git Bash path discovery', () => {
|
|||
).toBe('C:\\Users\\alice\\AppData\\Local\\Programs\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
// Why: resolveWindowsGitBashShellPath has no platform guard of its own, so passing options
|
||||
// explicitly keeps these assertions meaningful on non-Windows CI hosts.
|
||||
it('honors an explicit bash.exe path for future user-configurable launch paths', () => {
|
||||
expect(resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe')).toBe(
|
||||
'D:\\PortableGit\\bin\\bash.exe'
|
||||
)
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: (path) => path === 'D:\\PortableGit\\bin\\bash.exe'
|
||||
})
|
||||
).toBe('D:\\PortableGit\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
it('rejects an explicit Git Bash path that is no longer installed', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('D:\\PortableGit\\bin\\bash.exe', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves a bare bash entry through Git Bash discovery', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('bash', {
|
||||
platform: 'win32',
|
||||
env: { ProgramFiles: 'C:\\Program Files' },
|
||||
exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe'
|
||||
})
|
||||
).toBe('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
it('returns null for a bare bash entry when Git Bash is not installed', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('bash', {
|
||||
platform: 'win32',
|
||||
env: { ProgramFiles: 'C:\\Program Files' },
|
||||
exists: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('resolves an extension-less Git Bash path to the bash.exe it names', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: (path) => path === 'C:\\Program Files\\Git\\bin\\bash.exe'
|
||||
})
|
||||
).toBe('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
})
|
||||
|
||||
it('returns null for an extension-less Git Bash path with no bash.exe beside it', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => false
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not treat an extension-less non-Git bash path as Git Bash', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('does not probe a bash-prefixed path that is not bash itself', () => {
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\Program Files\\Git\\bin\\bash.old', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => true
|
||||
})
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('recognizes Git Bash executable paths case-insensitively', () => {
|
||||
|
|
@ -64,7 +140,13 @@ describe('Git Bash path discovery', () => {
|
|||
})
|
||||
|
||||
it('does not classify arbitrary bash.exe paths as Git Bash', () => {
|
||||
expect(resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe')).toBeNull()
|
||||
expect(
|
||||
resolveWindowsGitBashShellPath('C:\\msys64\\usr\\bin\\bash.exe', {
|
||||
platform: 'win32',
|
||||
env: {},
|
||||
exists: () => true
|
||||
})
|
||||
).toBeNull()
|
||||
expect(isWindowsGitBashShellPath('C:\\cygwin64\\bin\\bash.exe')).toBe(false)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -126,13 +126,24 @@ export function resolveWindowsGitBashShellPath(
|
|||
return resolveGitBashPath(options)
|
||||
}
|
||||
|
||||
// Why: resolveWindowsShellStartupFamily classifies extension-less `bash` as POSIX too, so both
|
||||
// spellings must resolve here or setup/PTY shell selection disagrees with the quoting family.
|
||||
const shellBasename = pathWin32.basename(trimmed).toLowerCase()
|
||||
if (shellBasename !== 'bash.exe') {
|
||||
if (shellBasename !== 'bash.exe' && shellBasename !== 'bash') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (pathWin32.isAbsolute(trimmed) || trimmed.includes('\\') || trimmed.includes('/')) {
|
||||
return isGitForWindowsBashPath(trimmed) ? trimmed : null
|
||||
// Why: an uninstalled/stale configured path must resolve to null like the discovery
|
||||
// branch above, so setup does not commit to a bash the PTY will never spawn.
|
||||
const exists = options.exists ?? existsSync
|
||||
if (shellBasename === 'bash') {
|
||||
// Why: Git for Windows ships only bash.exe, so an extension-less path is a request for it.
|
||||
// This branch synthesizes a path the user never typed, so it must confirm the file is there.
|
||||
const candidate = `${trimmed}.exe`
|
||||
return isGitForWindowsBashPath(candidate) && exists(candidate) ? candidate : null
|
||||
}
|
||||
return isGitForWindowsBashPath(trimmed) && exists(trimmed) ? trimmed : null
|
||||
}
|
||||
|
||||
return resolveGitBashPath(options)
|
||||
|
|
|
|||
|
|
@ -57,13 +57,15 @@ describe('createSetupRunnerScript', () => {
|
|||
ORCA_ROOT_PATH: '/test/repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\repo\\feature\\',
|
||||
ORCA_WORKSPACE_NAME: 'feature'
|
||||
})
|
||||
}),
|
||||
// Why: native Windows worktrees without a configured setup shell keep the cmd runner.
|
||||
shell: { family: 'cmd' }
|
||||
})
|
||||
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
||||
'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd',
|
||||
[
|
||||
'@echo off',
|
||||
'setlocal EnableExtensions',
|
||||
'setlocal EnableExtensions DisableDelayedExpansion',
|
||||
'call pnpm install',
|
||||
'if errorlevel 1 exit /b %errorlevel%',
|
||||
'call pnpm build',
|
||||
|
|
@ -80,6 +82,110 @@ describe('createSetupRunnerScript', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('converts path env vars to MSYS form for a native Windows Git Bash runner', async () => {
|
||||
const fs = await import('node:fs')
|
||||
const originalPlatform = process.platform
|
||||
|
||||
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
const { createSetupRunnerScript } = await import('./hooks')
|
||||
const result = createSetupRunnerScript(
|
||||
{ ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' },
|
||||
'C:\\repo\\feature',
|
||||
'pnpm install',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
)
|
||||
|
||||
expect(result).toEqual({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
|
||||
envVars: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: '/c/Users/jinwo/git/orca',
|
||||
ORCA_WORKTREE_PATH: '/c/repo/feature',
|
||||
CONDUCTOR_ROOT_PATH: '/c/Users/jinwo/git/orca',
|
||||
GHOSTX_ROOT_PATH: '/c/Users/jinwo/git/orca',
|
||||
// Why: a display name, never a path — it must survive the conversion untouched.
|
||||
ORCA_WORKSPACE_NAME: 'feature'
|
||||
}),
|
||||
shell: { family: 'posix' }
|
||||
})
|
||||
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
||||
'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
|
||||
'#!/usr/bin/env bash\nset -e\npnpm install\n',
|
||||
'utf-8'
|
||||
)
|
||||
// Why: chmod over a native Windows path is meaningless; only the WSL branch sets the bit.
|
||||
expect(vi.mocked(fs.chmodSync)).not.toHaveBeenCalledWith(
|
||||
'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh',
|
||||
0o755
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('leaves non-path setup env values alone under a Git Bash runner', async () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.sh')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
const { createSetupRunnerScript } = await import('./hooks')
|
||||
const { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } =
|
||||
await import('../shared/terminal-git-credential-guard')
|
||||
const result = createSetupRunnerScript(
|
||||
makeRepo(),
|
||||
'C:\\repo\\feature',
|
||||
'pnpm install',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
)
|
||||
|
||||
expect(result.envVars[TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV]).toBe('guard')
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native Windows env vars in Windows form for the default cmd runner', async () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
const { createSetupRunnerScript } = await import('./hooks')
|
||||
const result = createSetupRunnerScript(
|
||||
{ ...makeRepo(), path: 'C:\\Users\\jinwo\\git\\orca' },
|
||||
'C:\\repo\\feature',
|
||||
'pnpm install'
|
||||
)
|
||||
|
||||
expect(result.envVars).toEqual(
|
||||
expect.objectContaining({
|
||||
ORCA_ROOT_PATH: 'C:\\Users\\jinwo\\git\\orca',
|
||||
ORCA_WORKTREE_PATH: 'C:\\repo\\feature'
|
||||
})
|
||||
)
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves exclamation marks in Windows runner script lines', async () => {
|
||||
const { buildWindowsRunnerScript } = await import('./hooks')
|
||||
|
||||
const runner = buildWindowsRunnerScript('echo hello!world!')
|
||||
|
||||
// Why: launchers invoke the runner under cmd /v:on, so the runner must disable delayed
|
||||
// expansion itself or `!world!` is consumed as a variable reference.
|
||||
expect(runner).toContain('setlocal EnableExtensions DisableDelayedExpansion')
|
||||
expect(runner).toContain('call echo hello!world!')
|
||||
})
|
||||
|
||||
it('derives ORCA_WORKSPACE_NAME from a POSIX worktree path', async () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
|
|
@ -256,4 +362,55 @@ describe('createIssueCommandRunnerScript', () => {
|
|||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('carries the WSL launch shell for a Windows-drive worktree routed through WSL', async () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
execFileSyncMock.mockReturnValue('/mnt/c/repo/.git/orca/issue-command-runner.sh')
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
|
||||
try {
|
||||
const { createIssueCommandRunnerScript } = await import('./hooks')
|
||||
const result = createIssueCommandRunnerScript(
|
||||
makeRepo(),
|
||||
'C:\\repo\\feature',
|
||||
'codex exec "long command"',
|
||||
{ wslDistro: 'Ubuntu' }
|
||||
)
|
||||
|
||||
// Why: the runner path is written back in native Windows form, so the launch needs /mnt again.
|
||||
expect(result.runnerScriptPath).toBe('C:\\repo\\.git\\orca\\issue-command-runner.sh')
|
||||
expect(result.shell).toEqual({ family: 'posix', executable: 'wsl.exe' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps native Windows issue runners on the cmd launch shell', async () => {
|
||||
const originalPlatform = process.platform
|
||||
|
||||
execFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd')
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
|
||||
try {
|
||||
const { createIssueCommandRunnerScript } = await import('./hooks')
|
||||
const result = createIssueCommandRunnerScript(makeRepo(), 'C:\\repo\\feature', 'pnpm install')
|
||||
|
||||
expect(result.shell).toEqual({ family: 'cmd' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -537,7 +537,9 @@ describe('runner script builders', () => {
|
|||
try {
|
||||
const result = buildWindowsRunnerScript(script)
|
||||
|
||||
expect(result.startsWith('@echo off\r\nsetlocal EnableExtensions\r\n')).toBe(true)
|
||||
expect(
|
||||
result.startsWith('@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n')
|
||||
).toBe(true)
|
||||
expect(result).toContain('call pnpm install\r\nif errorlevel 1 exit /b %errorlevel%')
|
||||
expect(result).toContain('call npm run build\r\nif errorlevel 1 exit /b %errorlevel%')
|
||||
const usedLineSplit = splitSpy.mock.calls.some(
|
||||
|
|
@ -1175,6 +1177,7 @@ describe('runHook', () => {
|
|||
}
|
||||
)
|
||||
expect(result.runnerScriptPath).toContain('setup-runner.sh')
|
||||
expect(result.shell).toEqual({ family: 'posix', executable: 'wsl.exe' })
|
||||
expect(mkdirSyncMock).toHaveBeenCalled()
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
expect.stringContaining('setup-runner.sh'),
|
||||
|
|
@ -1252,6 +1255,116 @@ describe('createSetupRunnerScript', () => {
|
|||
}
|
||||
}) as unknown as Repo
|
||||
|
||||
it('writes POSIX setup runners for Git Bash on native Windows paths', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.sh\n')
|
||||
const fs = await import('node:fs')
|
||||
const writeFileSyncMock = vi.mocked(fs.writeFileSync)
|
||||
const chmodSyncMock = vi.mocked(fs.chmodSync)
|
||||
writeFileSyncMock.mockClear()
|
||||
chmodSyncMock.mockClear()
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
const { createSetupRunnerScript } = await import('./hooks')
|
||||
const result = createSetupRunnerScript(
|
||||
makeRepo(),
|
||||
'C:\\repo-worktree',
|
||||
'pnpm install\r\nnpm run build',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
)
|
||||
|
||||
expect(gitExecFileSyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--git-path', 'orca/setup-runner.sh'],
|
||||
{ cwd: 'C:\\repo-worktree' }
|
||||
)
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
'#!/usr/bin/env bash\nset -e\npnpm install\nnpm run build\n',
|
||||
'utf-8'
|
||||
)
|
||||
expect(chmodSyncMock).not.toHaveBeenCalled()
|
||||
expect(result).toMatchObject({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix' }
|
||||
})
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves cmd.exe setup runner semantics for configured cmd users', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n')
|
||||
const fs = await import('node:fs')
|
||||
const writeFileSyncMock = vi.mocked(fs.writeFileSync)
|
||||
writeFileSyncMock.mockClear()
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
const { createSetupRunnerScript } = await import('./hooks')
|
||||
const result = createSetupRunnerScript(
|
||||
makeRepo(),
|
||||
'C:\\repo-worktree',
|
||||
'pnpm install\nnpm run build',
|
||||
undefined,
|
||||
{ family: 'cmd' }
|
||||
)
|
||||
|
||||
expect(gitExecFileSyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--git-path', 'orca/setup-runner.cmd'],
|
||||
{ cwd: 'C:\\repo-worktree' }
|
||||
)
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
'C:\\repo\\.git\\orca\\setup-runner.cmd',
|
||||
expect.stringContaining('call pnpm install\r\nif errorlevel 1 exit /b %errorlevel%'),
|
||||
'utf-8'
|
||||
)
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
'C:\\repo\\.git\\orca\\setup-runner.cmd',
|
||||
expect.stringContaining('call npm run build\r\nif errorlevel 1 exit /b %errorlevel%'),
|
||||
'utf-8'
|
||||
)
|
||||
expect(result.shell).toEqual({ family: 'cmd' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps POSIX runner behavior on POSIX platforms', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
|
||||
const fs = await import('node:fs')
|
||||
const writeFileSyncMock = vi.mocked(fs.writeFileSync)
|
||||
const chmodSyncMock = vi.mocked(fs.chmodSync)
|
||||
writeFileSyncMock.mockClear()
|
||||
chmodSyncMock.mockClear()
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'linux' })
|
||||
|
||||
try {
|
||||
const { createSetupRunnerScript } = await import('./hooks')
|
||||
const result = createSetupRunnerScript(makeRepo(), '/test/worktree', 'pnpm install')
|
||||
|
||||
expect(gitExecFileSyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--git-path', 'orca/setup-runner.sh'],
|
||||
{ cwd: '/test/worktree' }
|
||||
)
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
'/test/repo/.git/orca/setup-runner.sh',
|
||||
'#!/usr/bin/env bash\nset -e\npnpm install\n',
|
||||
'utf-8'
|
||||
)
|
||||
expect(chmodSyncMock).toHaveBeenCalledWith('/test/repo/.git/orca/setup-runner.sh', 0o755)
|
||||
expect(result.shell).toBeUndefined()
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('omits waitForAgentStartup unless the repo explicitly waits for setup', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
|
||||
|
|
@ -1285,6 +1398,199 @@ describe('createSetupRunnerScript', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('createIssueCommandRunnerScript', () => {
|
||||
const makeRepo = () =>
|
||||
({
|
||||
id: 'test-id',
|
||||
path: '/test/repo',
|
||||
displayName: 'Test Repo',
|
||||
badgeColor: '#000',
|
||||
addedAt: Date.now(),
|
||||
hookSettings: { mode: 'auto', scripts: { setup: '', archive: '' } }
|
||||
}) as unknown as Repo
|
||||
|
||||
it('writes a POSIX issue-command runner when setup resolves to Git Bash', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.sh\n')
|
||||
const fs = await import('node:fs')
|
||||
const writeFileSyncMock = vi.mocked(fs.writeFileSync)
|
||||
writeFileSyncMock.mockClear()
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
const { createIssueCommandRunnerScript } = await import('./hooks')
|
||||
const result = createIssueCommandRunnerScript(
|
||||
makeRepo(),
|
||||
'C:\\repo-worktree',
|
||||
'gh issue view 42',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
)
|
||||
|
||||
expect(gitExecFileSyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--git-path', 'orca/issue-command-runner.sh'],
|
||||
{ cwd: 'C:\\repo-worktree' }
|
||||
)
|
||||
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
||||
'C:\\repo\\.git\\orca\\issue-command-runner.sh',
|
||||
'#!/usr/bin/env bash\nset -e\ngh issue view 42\n',
|
||||
'utf-8'
|
||||
)
|
||||
expect(result.shell).toEqual({ family: 'posix' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the cmd issue-command runner when no setup shell is resolved', async () => {
|
||||
gitExecFileSyncMock.mockReset()
|
||||
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\issue-command-runner.cmd\n')
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
|
||||
try {
|
||||
const { createIssueCommandRunnerScript } = await import('./hooks')
|
||||
const result = createIssueCommandRunnerScript(
|
||||
makeRepo(),
|
||||
'C:\\repo-worktree',
|
||||
'gh issue view 42'
|
||||
)
|
||||
|
||||
expect(gitExecFileSyncMock).toHaveBeenCalledWith(
|
||||
['rev-parse', '--git-path', 'orca/issue-command-runner.cmd'],
|
||||
{ cwd: 'C:\\repo-worktree' }
|
||||
)
|
||||
expect(result.shell).toEqual({ family: 'cmd' })
|
||||
} finally {
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSetupRunnerShell', () => {
|
||||
const installedGitBash = {
|
||||
resolveGitBashShellPath: () => 'C:\\Program Files\\Git\\bin\\bash.exe'
|
||||
}
|
||||
|
||||
it('maps git-bash to POSIX setup launch metadata on Windows', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell({ terminalWindowsShell: 'git-bash' }, 'win32', installedGitBash)
|
||||
).toEqual({
|
||||
family: 'posix'
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to the cmd runner when the git-bash setting has no installed Git Bash', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell({ terminalWindowsShell: 'git-bash' }, 'win32', {
|
||||
resolveGitBashShellPath: () => null
|
||||
})
|
||||
).toEqual({ family: 'cmd' })
|
||||
})
|
||||
|
||||
it('keeps the cmd runner for a non-Git bash such as Cygwin', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell({ terminalWindowsShell: 'C:\\cygwin64\\bin\\bash.exe' }, 'win32', {
|
||||
resolveGitBashShellPath: () => null
|
||||
})
|
||||
).toEqual({ family: 'cmd' })
|
||||
})
|
||||
|
||||
it('keeps the cmd runner for a bare bash whose flavor cannot be resolved', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell({ terminalWindowsShell: 'bash' }, 'win32', {
|
||||
resolveGitBashShellPath: () => null
|
||||
})
|
||||
).toEqual({ family: 'cmd' })
|
||||
})
|
||||
|
||||
it('uses the POSIX runner for a bare bash that resolves to Git Bash', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell({ terminalWindowsShell: 'bash' }, 'win32', installedGitBash)
|
||||
).toEqual({ family: 'posix' })
|
||||
})
|
||||
|
||||
it('uses the POSIX runner for an extension-less Git Bash path', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell(
|
||||
{ terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash' },
|
||||
'win32',
|
||||
installedGitBash
|
||||
)
|
||||
).toEqual({ family: 'posix' })
|
||||
})
|
||||
|
||||
it('preserves the existing cmd runner for PowerShell terminals', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell(
|
||||
{
|
||||
terminalWindowsShell: 'powershell.exe',
|
||||
terminalWindowsPowerShellImplementation: 'pwsh.exe'
|
||||
},
|
||||
'win32',
|
||||
installedGitBash
|
||||
)
|
||||
).toEqual({ family: 'cmd' })
|
||||
})
|
||||
|
||||
it('preserves cmd setup compatibility when a Windows-host project has a WSL shell setting', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell({ terminalWindowsShell: 'wsl.exe' }, 'win32', installedGitBash)
|
||||
).toEqual({ family: 'cmd' })
|
||||
})
|
||||
|
||||
it('classifies an installed explicit Git Bash executable as a POSIX runner', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
const { resolveWindowsGitBashShellPath } = await import('./git-bash')
|
||||
|
||||
expect(
|
||||
resolveSetupRunnerShell(
|
||||
{ terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe' },
|
||||
'win32',
|
||||
{
|
||||
resolveGitBashShellPath: (shell) =>
|
||||
resolveWindowsGitBashShellPath(shell, { platform: 'win32', exists: () => true })
|
||||
}
|
||||
)
|
||||
).toEqual({ family: 'posix' })
|
||||
})
|
||||
|
||||
it('falls back to the cmd runner when the explicit Git Bash path no longer exists', async () => {
|
||||
const { resolveSetupRunnerShell } = await import('./hooks')
|
||||
const { resolveWindowsGitBashShellPath } = await import('./git-bash')
|
||||
|
||||
// Regression: a stale configured path used to commit setup to a .sh runner the
|
||||
// PTY could never spawn, hanging wait-for-setup until the 2h timeout.
|
||||
expect(
|
||||
resolveSetupRunnerShell(
|
||||
{ terminalWindowsShell: 'C:\\Program Files\\Git\\bin\\bash.exe' },
|
||||
'win32',
|
||||
{
|
||||
resolveGitBashShellPath: (shell) =>
|
||||
resolveWindowsGitBashShellPath(shell, { platform: 'win32', exists: () => false })
|
||||
}
|
||||
)
|
||||
).toEqual({ family: 'cmd' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldRunSetupForCreate', () => {
|
||||
const makeRepo = (setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default') =>
|
||||
({
|
||||
|
|
|
|||
|
|
@ -8,6 +8,9 @@ import { resolveHookCommandSourcePolicy } from '../shared/hook-command-source-po
|
|||
import { shouldWaitForSetupBeforeAgentStartup } from '../shared/setup-agent-startup-policy'
|
||||
import { TERMINAL_GIT_CREDENTIAL_GUARD_POLICY_ENV } from '../shared/terminal-git-credential-guard'
|
||||
import { parseOrcaYaml } from '../shared/orca-yaml'
|
||||
import { nativeWindowsPathToPosixShellPath } from '../shared/setup-runner-command'
|
||||
import { resolveWindowsShellStartupFamily } from '../shared/windows-terminal-shell'
|
||||
import { resolveWindowsGitBashShellPath } from './git-bash'
|
||||
import { gitExecFileSync, promptGuardShellEnv } from './git/runner'
|
||||
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
|
||||
import { addWorktreeSetupWslInteropEnv } from './pty/wsl-orca-env'
|
||||
|
|
@ -21,6 +24,7 @@ import type {
|
|||
WorktreeSetupLaunch
|
||||
} from '../shared/types'
|
||||
import type { ProjectExecutionRuntimeResolution } from '../shared/project-execution-runtime'
|
||||
import type { SetupRunnerShell } from '../shared/setup-runner-command'
|
||||
|
||||
const HOOK_TIMEOUT = 120_000 // 2 minutes
|
||||
|
||||
|
|
@ -28,6 +32,8 @@ export type HookRuntimeTarget = {
|
|||
wslDistro?: string | null
|
||||
}
|
||||
|
||||
type SetupRunnerShellSettings = Record<string, unknown> | undefined
|
||||
|
||||
function getHookShell(): string | undefined {
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.ComSpec || 'cmd.exe'
|
||||
|
|
@ -322,6 +328,15 @@ export function getSetupCommandSource(
|
|||
return null
|
||||
}
|
||||
|
||||
// Why: kept in sync with pty/wsl-orca-env.ts, which path-translates the same keys for WSL.
|
||||
// ORCA_WORKSPACE_NAME is a display name and the credential-guard policy is an enum — never paths.
|
||||
const SETUP_RUNNER_PATH_ENV_KEYS = [
|
||||
'ORCA_ROOT_PATH',
|
||||
'ORCA_WORKTREE_PATH',
|
||||
'CONDUCTOR_ROOT_PATH',
|
||||
'GHOSTX_ROOT_PATH'
|
||||
] as const
|
||||
|
||||
function getSetupEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
|
||||
return {
|
||||
ORCA_ROOT_PATH: repo.path,
|
||||
|
|
@ -383,7 +398,9 @@ function getHookWslContext(
|
|||
}
|
||||
|
||||
export function buildWindowsRunnerScript(script: string): string {
|
||||
let runnerScript = '@echo off\r\nsetlocal EnableExtensions\r\n'
|
||||
// Why: launchers invoke this runner under `cmd /v:on`, and EnableExtensions does not reset an
|
||||
// inherited delayed-expansion state — without this, every `!` in a user setup line is eaten.
|
||||
let runnerScript = '@echo off\r\nsetlocal EnableExtensions DisableDelayedExpansion\r\n'
|
||||
|
||||
for (const rawLine of iterateLfScriptLines(script)) {
|
||||
const command = rawLine.trim()
|
||||
|
|
@ -420,16 +437,20 @@ export function createSetupRunnerScript(
|
|||
repo: Repo,
|
||||
worktreePath: string,
|
||||
script: string,
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
|
||||
setupShell?: SetupRunnerShell
|
||||
): WorktreeSetupLaunch {
|
||||
return createWorktreeRunnerScript(
|
||||
return createWorktreeRunnerScript({
|
||||
repo,
|
||||
worktreePath,
|
||||
script,
|
||||
'setup-runner',
|
||||
getHookRuntimeTarget(projectRuntime),
|
||||
shouldWaitForSetupBeforeAgentStartup(repo.hookSettings?.setupAgentStartupPolicy)
|
||||
)
|
||||
runnerBaseName: 'setup-runner',
|
||||
runtimeTarget: getHookRuntimeTarget(projectRuntime),
|
||||
waitForAgentStartup: shouldWaitForSetupBeforeAgentStartup(
|
||||
repo.hookSettings?.setupAgentStartupPolicy
|
||||
),
|
||||
setupShell
|
||||
})
|
||||
}
|
||||
|
||||
export function getSetupRunnerEnvVars(repo: Repo, worktreePath: string): Record<string, string> {
|
||||
|
|
@ -469,32 +490,55 @@ export function createIssueCommandRunnerScript(
|
|||
repo: Repo,
|
||||
worktreePath: string,
|
||||
command: string,
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget
|
||||
projectRuntime?: ProjectExecutionRuntimeResolution | HookRuntimeTarget,
|
||||
setupShell?: SetupRunnerShell
|
||||
): WorktreeSetupLaunch {
|
||||
// Why: writing long commands into a runner script avoids the PTY line editor wrapping/truncating them.
|
||||
return createWorktreeRunnerScript(
|
||||
return createWorktreeRunnerScript({
|
||||
repo,
|
||||
worktreePath,
|
||||
command,
|
||||
'issue-command-runner',
|
||||
getHookRuntimeTarget(projectRuntime)
|
||||
)
|
||||
script: command,
|
||||
runnerBaseName: 'issue-command-runner',
|
||||
runtimeTarget: getHookRuntimeTarget(projectRuntime),
|
||||
// Why: issue commands run in the same terminal as setup, so a Git Bash setup
|
||||
// runner must not be paired with a cmd issue runner in one session.
|
||||
setupShell
|
||||
})
|
||||
}
|
||||
|
||||
function createWorktreeRunnerScript(
|
||||
repo: Repo,
|
||||
worktreePath: string,
|
||||
script: string,
|
||||
runnerBaseName: 'setup-runner' | 'issue-command-runner',
|
||||
runtimeTarget?: HookRuntimeTarget,
|
||||
function createWorktreeRunnerScript(args: {
|
||||
repo: Repo
|
||||
worktreePath: string
|
||||
script: string
|
||||
runnerBaseName: 'setup-runner' | 'issue-command-runner'
|
||||
runtimeTarget?: HookRuntimeTarget
|
||||
waitForAgentStartup?: boolean
|
||||
): WorktreeSetupLaunch {
|
||||
setupShell?: SetupRunnerShell
|
||||
}): WorktreeSetupLaunch {
|
||||
const {
|
||||
repo,
|
||||
worktreePath,
|
||||
script,
|
||||
runnerBaseName,
|
||||
runtimeTarget,
|
||||
waitForAgentStartup,
|
||||
setupShell
|
||||
} = args
|
||||
const envVars = getSetupRunnerEnvVars(repo, worktreePath)
|
||||
// Why: WSL worktrees are Linux fs even though process.platform is 'win32'; use bash for WSL, .cmd for native Windows.
|
||||
const wslWorktree = isWslPath(worktreePath) || Boolean(runtimeTarget?.wslDistro)
|
||||
const useWindowsFormat = process.platform === 'win32' && !wslWorktree
|
||||
const nativeWindowsWorktree = process.platform === 'win32' && !wslWorktree
|
||||
const runnerShell: SetupRunnerShell = nativeWindowsWorktree
|
||||
? (setupShell ?? { family: 'cmd' })
|
||||
: { family: 'posix' }
|
||||
const launchShell: SetupRunnerShell | undefined = nativeWindowsWorktree
|
||||
? runnerShell
|
||||
: process.platform === 'win32' && runtimeTarget?.wslDistro
|
||||
? { family: 'posix', executable: 'wsl.exe' }
|
||||
: undefined
|
||||
// Why: linked worktrees use a `.git` file, so resolve the real per-worktree gitdir via git rev-parse --git-path.
|
||||
const gitRelPath = useWindowsFormat ? `orca/${runnerBaseName}.cmd` : `orca/${runnerBaseName}.sh`
|
||||
const runnerExtension = runnerShell.family === 'cmd' ? 'cmd' : 'sh'
|
||||
const gitRelPath = `orca/${runnerBaseName}.${runnerExtension}`
|
||||
let runnerScriptPath = getGitPath(worktreePath, gitRelPath, runtimeTarget)
|
||||
|
||||
// Why: git runs inside WSL and returns a Linux path; convert to a UNC path so the Windows fs calls can reach it.
|
||||
|
|
@ -507,12 +551,14 @@ function createWorktreeRunnerScript(
|
|||
|
||||
mkdirSync(dirname(runnerScriptPath), { recursive: true })
|
||||
|
||||
if (useWindowsFormat) {
|
||||
if (runnerShell.family === 'cmd') {
|
||||
writeFileSync(runnerScriptPath, buildWindowsRunnerScript(script), 'utf-8')
|
||||
} else {
|
||||
writeFileSync(runnerScriptPath, buildPosixRunnerScript(script), 'utf-8')
|
||||
// Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL.
|
||||
chmodSync(runnerScriptPath, 0o755)
|
||||
if (!nativeWindowsWorktree) {
|
||||
// Why: chmod over a UNC path to the WSL filesystem sets the execute bit correctly inside WSL.
|
||||
chmodSync(runnerScriptPath, 0o755)
|
||||
}
|
||||
}
|
||||
|
||||
// Why: setup script runs inside WSL bash, so translate the Windows UNC env-var paths to Linux paths.
|
||||
|
|
@ -520,15 +566,68 @@ function createWorktreeRunnerScript(
|
|||
for (const key of Object.keys(envVars)) {
|
||||
envVars[key] = toLinuxPath(envVars[key])
|
||||
}
|
||||
} else if (nativeWindowsWorktree && runnerShell.family === 'posix') {
|
||||
// Why: a Git Bash runner already receives its own path as /c/..., and the shell exports HOME
|
||||
// and PWD the same way, so leaving ORCA_* in C:\ form would make them the lone exception.
|
||||
// Only path-valued keys convert; the workspace name and policy values are not paths.
|
||||
for (const key of SETUP_RUNNER_PATH_ENV_KEYS) {
|
||||
const value = envVars[key]
|
||||
if (value) {
|
||||
envVars[key] = nativeWindowsPathToPosixShellPath(value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
runnerScriptPath,
|
||||
envVars,
|
||||
// Why: WSL git returns /mnt paths that Node converts back to C:\ for file
|
||||
// writes; retain the runtime signal so launch converts them to /mnt again.
|
||||
// Issue-command runners take the same resolved shell, so one session never
|
||||
// mixes a bash setup runner with a cmd issue runner.
|
||||
...(launchShell ? { shell: launchShell } : {}),
|
||||
...(waitForAgentStartup === true ? { waitForAgentStartup: true } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveSetupRunnerShell(
|
||||
settings: SetupRunnerShellSettings,
|
||||
platform: NodeJS.Platform = process.platform,
|
||||
options: { resolveGitBashShellPath?: (shell: string) => string | null } = {}
|
||||
): SetupRunnerShell | undefined {
|
||||
if (platform !== 'win32') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
const terminalWindowsShell = settings?.terminalWindowsShell
|
||||
const configuredShell =
|
||||
typeof terminalWindowsShell === 'string' && terminalWindowsShell.trim()
|
||||
? terminalWindowsShell.trim()
|
||||
: 'powershell.exe'
|
||||
const shellBasename = configuredShell.replaceAll('\\', '/').split('/').pop()?.toLowerCase()
|
||||
const family = resolveWindowsShellStartupFamily(configuredShell)
|
||||
if (family === 'posix' && shellBasename !== 'wsl.exe' && shellBasename !== 'wsl') {
|
||||
// Why: the PTY resolves Git Bash independently and falls back to PowerShell when it
|
||||
// is missing, so gate the .sh runner on the same resolution. This also keeps
|
||||
// non-Git bash flavors (Cygwin's /cygdrive, the System32 WSL shim's /mnt) off the
|
||||
// MSYS-only /c/... form the posix runner emits.
|
||||
const resolveGitBashShellPath =
|
||||
options.resolveGitBashShellPath ??
|
||||
((shell: string) => resolveWindowsGitBashShellPath(shell, { platform }))
|
||||
if (resolveGitBashShellPath(configuredShell)) {
|
||||
// Note: Git Bash users with batch-syntax orca.yaml setup content get a bash
|
||||
// interpreter from here on. The flip is intentional and documented in
|
||||
// docs/reference/windows-setup-shell.md.
|
||||
return { family: 'posix' }
|
||||
}
|
||||
}
|
||||
|
||||
// Why: existing Windows setup scripts were authored for Orca's cmd runner;
|
||||
// PowerShell, wsl.exe-as-terminal, and Windows-host projects can invoke it
|
||||
// without changing syntax, so they intentionally stay on the cmd runner.
|
||||
return { family: 'cmd' }
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a named hook script in the given working directory.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ import {
|
|||
getSetupRunnerEnvVars,
|
||||
loadHooks,
|
||||
parseOrcaYaml,
|
||||
resolveSetupRunnerShell,
|
||||
shouldRunSetupForCreate
|
||||
} from '../hooks'
|
||||
import { requireSshGitProvider } from '../providers/ssh-git-dispatch'
|
||||
|
|
@ -178,6 +179,13 @@ function appendWorktreeCreateWarning(current: string | undefined, next: string):
|
|||
return current ? `${current} Also ${next[0]?.toLowerCase() ?? ''}${next.slice(1)}` : next
|
||||
}
|
||||
|
||||
function getSetupRunnerCommandPlatformForLaunch(
|
||||
setup: CreateWorktreeResult['setup'],
|
||||
fallbackPlatform: 'windows' | 'posix'
|
||||
): 'windows' | 'posix' {
|
||||
return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform)
|
||||
}
|
||||
|
||||
function validateWorkspaceLineageParentBeforeCreate(
|
||||
store: Store,
|
||||
parentWorkspace: CreateWorktreeArgs['parentWorkspace'],
|
||||
|
|
@ -266,14 +274,15 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
|||
let sequencedStartup = startup
|
||||
let wrappedSetupCommandStr: string | undefined
|
||||
if (startup && setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
const platform = getSetupRunnerCommandPlatformForLaunch(
|
||||
setup,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: setup.runnerScriptPath,
|
||||
startupCommand: startup.command,
|
||||
platform
|
||||
platform,
|
||||
shell: setup.shell
|
||||
})
|
||||
sequencedStartup = {
|
||||
...startup,
|
||||
|
|
@ -329,10 +338,11 @@ async function spawnLocalStartupAndSetupTerminals(args: {
|
|||
wrappedSetupCommandStr ??
|
||||
buildSetupRunnerCommand(
|
||||
setup.runnerScriptPath,
|
||||
getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
getSetupRunnerCommandPlatformForLaunch(
|
||||
setup,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
),
|
||||
setup.shell
|
||||
)
|
||||
const setupLaunchMode =
|
||||
(settings as Partial<Pick<GlobalSettings, 'setupScriptLaunchMode'>>)
|
||||
|
|
@ -1144,6 +1154,8 @@ async function createRemoteSetupRunnerScript(
|
|||
fsProvider: IFilesystemProvider
|
||||
): Promise<CreateWorktreeResult['setup']> {
|
||||
const useWindowsFormat = isWindowsAbsolutePathLike(worktreePath)
|
||||
// Why: SSH terminals choose their shell on the remote host; local Windows
|
||||
// preferences cannot safely select a remote runner format or launch command.
|
||||
const runnerRelativePath = useWindowsFormat ? 'orca/setup-runner.cmd' : 'orca/setup-runner.sh'
|
||||
const { stdout } = await gitProvider.exec(
|
||||
['rev-parse', '--git-path', runnerRelativePath],
|
||||
|
|
@ -2504,11 +2516,13 @@ export async function createLocalWorktree(
|
|||
try {
|
||||
// Why: main only writes the runner script and must not execute setup itself, or we reintroduce the old hidden background-hook behavior.
|
||||
// Why: worktree already exists, so a runner-gen failure degrades to "created without setup launch" rather than failing creation.
|
||||
// Why: both trailing args are optional — the shell is undefined off Windows.
|
||||
setup = createSetupRunnerScript(
|
||||
repo,
|
||||
worktreePath,
|
||||
setupScript,
|
||||
...localWorktreeGitOptionArgs
|
||||
localWorktreeGitOptionArgs[0],
|
||||
resolveSetupRunnerShell(settings)
|
||||
)
|
||||
} catch (error) {
|
||||
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ const {
|
|||
getDefaultTabsLaunchMock,
|
||||
createIssueCommandRunnerScriptMock,
|
||||
createSetupRunnerScriptMock,
|
||||
resolveSetupRunnerShellMock,
|
||||
shouldRunSetupForCreateMock,
|
||||
runHookMock,
|
||||
hasHooksFileMock,
|
||||
|
|
@ -49,6 +50,7 @@ const {
|
|||
getDefaultTabsLaunchMock: vi.fn(),
|
||||
createIssueCommandRunnerScriptMock: vi.fn(),
|
||||
createSetupRunnerScriptMock: vi.fn(),
|
||||
resolveSetupRunnerShellMock: vi.fn(),
|
||||
shouldRunSetupForCreateMock: vi.fn(),
|
||||
runHookMock: vi.fn(),
|
||||
hasHooksFileMock: vi.fn(),
|
||||
|
|
@ -110,6 +112,7 @@ vi.mock('../hooks', () => ({
|
|||
loadHooks: loadHooksMock,
|
||||
runHook: runHookMock,
|
||||
hasHooksFile: hasHooksFileMock,
|
||||
resolveSetupRunnerShell: resolveSetupRunnerShellMock,
|
||||
shouldRunSetupForCreate: shouldRunSetupForCreateMock
|
||||
}))
|
||||
|
||||
|
|
@ -177,6 +180,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
|||
getDefaultTabsLaunchMock.mockReset()
|
||||
createIssueCommandRunnerScriptMock.mockReset()
|
||||
createSetupRunnerScriptMock.mockReset()
|
||||
resolveSetupRunnerShellMock.mockReset()
|
||||
shouldRunSetupForCreateMock.mockReset()
|
||||
runHookMock.mockReset()
|
||||
hasHooksFileMock.mockReset()
|
||||
|
|
@ -230,6 +234,7 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
|||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
workspaceDir: 'C:\\workspaces'
|
||||
})
|
||||
resolveSetupRunnerShellMock.mockReturnValue(undefined)
|
||||
store.getWorktreeMeta.mockReturnValue(undefined)
|
||||
store.setWorktreeMeta.mockReturnValue({})
|
||||
resolveLocalGitUsernameMock.mockResolvedValue('')
|
||||
|
|
@ -345,6 +350,61 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('passes the configured Windows setup shell into local setup runner generation', async () => {
|
||||
const setupShell = { family: 'posix' as const }
|
||||
store.getSettings.mockReturnValue({
|
||||
branchPrefix: 'none',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
terminalWindowsShell: 'git-bash',
|
||||
workspaceDir: 'C:\\workspaces'
|
||||
})
|
||||
resolveSetupRunnerShellMock.mockReturnValue(setupShell)
|
||||
listWorktreesMock.mockResolvedValue([
|
||||
{
|
||||
path: 'C:/workspaces/improve-dashboard',
|
||||
head: 'abc123',
|
||||
branch: 'refs/heads/improve-dashboard',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
getEffectiveHooksMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
createSetupRunnerScriptMock.mockReturnValue({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: setupShell,
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\workspaces\\improve-dashboard'
|
||||
}
|
||||
})
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-1',
|
||||
name: 'improve-dashboard',
|
||||
setupDecision: 'run'
|
||||
})
|
||||
|
||||
expect(resolveSetupRunnerShellMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ terminalWindowsShell: 'git-bash' })
|
||||
)
|
||||
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'C:\\workspaces\\improve-dashboard',
|
||||
'pnpm install',
|
||||
undefined,
|
||||
setupShell
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
setup: {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: setupShell
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves create-time metadata on the next list when Windows path formatting differs', async () => {
|
||||
const worktreeEntry = {
|
||||
path: 'C:/workspaces/improve-dashboard',
|
||||
|
|
@ -439,4 +499,29 @@ describe('registerWorktreeHandlers – Windows path handling', () => {
|
|||
repoId: 'repo-1'
|
||||
})
|
||||
})
|
||||
it('gives the issue-command runner the same setup shell as the setup runner', () => {
|
||||
// Regression (C4): native Windows issue runners stayed .cmd even when setup
|
||||
// resolved to Git Bash, so same-session bash issue templates broke.
|
||||
resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' })
|
||||
createIssueCommandRunnerScriptMock.mockReturnValue({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\issue-command-runner.sh',
|
||||
envVars: {},
|
||||
shell: { family: 'posix' }
|
||||
})
|
||||
|
||||
handlers['hooks:createIssueCommandRunner'](null, {
|
||||
repoId: 'repo-1',
|
||||
worktreePath: 'C:\\workspaces\\improve-dashboard',
|
||||
command: 'gh issue view 42'
|
||||
})
|
||||
|
||||
expect(resolveSetupRunnerShellMock).toHaveBeenCalledWith(store.getSettings())
|
||||
expect(createIssueCommandRunnerScriptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'C:\\workspaces\\improve-dashboard',
|
||||
'gh issue view 42',
|
||||
expect.anything(),
|
||||
{ family: 'posix' }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ const {
|
|||
buildPosixRunnerScriptMock,
|
||||
buildWindowsRunnerScriptMock,
|
||||
getSetupRunnerEnvVarsMock,
|
||||
resolveSetupRunnerShellMock,
|
||||
runHookMock,
|
||||
hasHooksFileMock,
|
||||
loadHooksMock,
|
||||
|
|
@ -100,6 +101,7 @@ const {
|
|||
buildPosixRunnerScriptMock: vi.fn(),
|
||||
buildWindowsRunnerScriptMock: vi.fn(),
|
||||
getSetupRunnerEnvVarsMock: vi.fn(),
|
||||
resolveSetupRunnerShellMock: vi.fn(),
|
||||
runHookMock: vi.fn(),
|
||||
hasHooksFileMock: vi.fn(),
|
||||
loadHooksMock: vi.fn(),
|
||||
|
|
@ -199,6 +201,7 @@ vi.mock('../hooks', () => ({
|
|||
getSetupRunnerEnvVars: getSetupRunnerEnvVarsMock,
|
||||
loadHooks: loadHooksMock,
|
||||
parseOrcaYaml: parseOrcaYamlMock,
|
||||
resolveSetupRunnerShell: resolveSetupRunnerShellMock,
|
||||
runHook: runHookMock,
|
||||
hasHooksFile: hasHooksFileMock,
|
||||
shouldRunSetupForCreate: shouldRunSetupForCreateMock
|
||||
|
|
@ -369,6 +372,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
buildPosixRunnerScriptMock,
|
||||
buildWindowsRunnerScriptMock,
|
||||
getSetupRunnerEnvVarsMock,
|
||||
resolveSetupRunnerShellMock,
|
||||
shouldRunSetupForCreateMock,
|
||||
runHookMock,
|
||||
hasHooksFileMock,
|
||||
|
|
@ -484,6 +488,7 @@ describe('registerWorktreeHandlers', () => {
|
|||
(script: string) => `#!/usr/bin/env bash\nset -e\n${script.replace(/\r\n/g, '\n')}\n`
|
||||
)
|
||||
buildWindowsRunnerScriptMock.mockImplementation((script: string) => script)
|
||||
resolveSetupRunnerShellMock.mockReturnValue(undefined)
|
||||
getSetupRunnerEnvVarsMock.mockImplementation(
|
||||
(repoArg: { path: string }, worktreePath: string) => ({
|
||||
ORCA_ROOT_PATH: repoArg.path,
|
||||
|
|
@ -1226,10 +1231,11 @@ describe('registerWorktreeHandlers', () => {
|
|||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
createSetupRunnerScriptMock.mockReturnValueOnce({
|
||||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
|
||||
runnerScriptPath: 'C:\\workspace\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/workspace/repo',
|
||||
ORCA_WORKTREE_PATH: '/workspace/improve-dashboard'
|
||||
ORCA_ROOT_PATH: 'C:\\workspace\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\workspace\\improve-dashboard'
|
||||
},
|
||||
waitForAgentStartup: true
|
||||
})
|
||||
|
|
@ -1254,8 +1260,8 @@ describe('registerWorktreeHandlers', () => {
|
|||
|
||||
expect(result.setup).toEqual(
|
||||
expect.objectContaining({
|
||||
runnerScriptPath: '/workspace/repo/.git/orca/setup-runner.sh',
|
||||
command: expect.stringContaining('bash /workspace/repo/.git/orca/setup-runner.sh')
|
||||
runnerScriptPath: 'C:\\workspace\\repo\\.git\\orca\\setup-runner.sh',
|
||||
command: expect.stringContaining('bash /mnt/c/workspace/repo/.git/orca/setup-runner.sh')
|
||||
})
|
||||
)
|
||||
expect(result.setup?.command).toContain('printf')
|
||||
|
|
@ -4944,6 +4950,103 @@ describe('registerWorktreeHandlers', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('keeps Windows SSH setup runners independent from the local Git Bash setting', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
path: 'C:\\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' && args[1] === '--git-path') {
|
||||
return {
|
||||
stdout:
|
||||
'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd\n',
|
||||
stderr: ''
|
||||
}
|
||||
}
|
||||
if (args[0] === 'rev-parse') {
|
||||
throw new Error('missing local branch')
|
||||
}
|
||||
return { stdout: '', stderr: '' }
|
||||
}),
|
||||
fetchRemoteTrackingRef: vi.fn().mockResolvedValue(undefined),
|
||||
addWorktree: vi.fn().mockResolvedValue(undefined),
|
||||
listWorktrees: vi.fn().mockResolvedValue([
|
||||
{
|
||||
path: 'C:\\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)
|
||||
}
|
||||
store.getRepos.mockReturnValue([repo])
|
||||
store.getRepo.mockReturnValue(repo)
|
||||
store.getSettings.mockReturnValue({
|
||||
branchPrefix: 'none',
|
||||
nestWorkspaces: false,
|
||||
refreshLocalBaseRefOnWorktreeCreate: false,
|
||||
terminalWindowsShell: 'git-bash',
|
||||
workspaceDir: 'C:\\workspace'
|
||||
})
|
||||
getSshGitProviderMock.mockReturnValue(provider)
|
||||
getSshFilesystemProviderMock.mockReturnValue(fsProvider)
|
||||
getActiveMultiplexerMock.mockReturnValue({
|
||||
request: vi.fn().mockResolvedValue(undefined),
|
||||
notify: vi.fn()
|
||||
})
|
||||
store.setWorktreeMeta.mockImplementation((_worktreeId, meta) => meta)
|
||||
parseOrcaYamlMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
getEffectiveHooksFromConfigMock.mockReturnValue({ scripts: { setup: 'pnpm install' } })
|
||||
shouldRunSetupForCreateMock.mockReturnValue(true)
|
||||
resolveSetupRunnerShellMock.mockReturnValue({ family: 'posix' })
|
||||
|
||||
const result = await handlers['worktrees:create'](null, {
|
||||
repoId: 'repo-ssh',
|
||||
name: 'improve-dashboard',
|
||||
setupDecision: 'run'
|
||||
})
|
||||
|
||||
expect(provider.exec).toHaveBeenCalledWith(
|
||||
['rev-parse', '--git-path', 'orca/setup-runner.cmd'],
|
||||
'C:\\remote\\improve-dashboard'
|
||||
)
|
||||
expect(fsProvider.writeFile).toHaveBeenCalledWith(
|
||||
'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd',
|
||||
'pnpm install'
|
||||
)
|
||||
expect(resolveSetupRunnerShellMock).not.toHaveBeenCalled()
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
setup: {
|
||||
runnerScriptPath:
|
||||
'C:\\remote\\repo\\.git\\worktrees\\improve-dashboard\\orca\\setup-runner.cmd',
|
||||
envVars: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: 'C:\\remote\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\remote\\improve-dashboard'
|
||||
})
|
||||
}
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('creates sparse checkout metadata and remote sparse config for SSH worktrees', async () => {
|
||||
const repo = {
|
||||
id: 'repo-ssh',
|
||||
|
|
@ -6642,7 +6745,9 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'/workspace/improve-dashboard',
|
||||
'codex exec "long command"',
|
||||
{}
|
||||
{},
|
||||
// Why: issue runners take the resolved setup shell; it is undefined off Windows.
|
||||
undefined
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
runnerScriptPath: '/workspace/repo/.git/orca/issue-command-runner.sh',
|
||||
|
|
@ -7621,7 +7726,9 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup'
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(result).toMatchObject({
|
||||
worktree: expect.objectContaining({
|
||||
|
|
@ -7682,7 +7789,8 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup',
|
||||
{ wslDistro: 'Ubuntu' }
|
||||
{ wslDistro: 'Ubuntu' },
|
||||
undefined
|
||||
)
|
||||
expect(addWorktreeMock).toHaveBeenCalledWith(
|
||||
'/workspace/repo',
|
||||
|
|
@ -7719,7 +7827,9 @@ describe('registerWorktreeHandlers', () => {
|
|||
expect(createSetupRunnerScriptMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'/workspace/improve-dashboard',
|
||||
'pnpm worktree:setup # worktree'
|
||||
'pnpm worktree:setup # worktree',
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
|
|
|
|||
|
|
@ -91,6 +91,7 @@ import {
|
|||
loadHooks,
|
||||
parseOrcaYaml,
|
||||
readIssueCommand,
|
||||
resolveSetupRunnerShell,
|
||||
runHook,
|
||||
hasHooksFile,
|
||||
hasUnrecognizedOrcaYamlKeys,
|
||||
|
|
@ -3118,7 +3119,8 @@ export function registerWorktreeHandlers(
|
|||
repo,
|
||||
args.worktreePath,
|
||||
args.command,
|
||||
getLocalProjectWorktreeGitOptions(store, repo)
|
||||
getLocalProjectWorktreeGitOptions(store, repo),
|
||||
resolveSetupRunnerShell(store.getSettings())
|
||||
)
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { mkdtemp, mkdir, rm, symlink, truncate, writeFile } from 'node:fs/promis
|
|||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { disposeWatcherProcess } from '../ipc/parcel-watcher-process'
|
||||
import { fingerprintPluginConsent } from '../../shared/plugins/plugin-consent-fingerprint'
|
||||
import { pluginManifestSchema, type PluginManifest } from '../../shared/plugins/plugin-manifest'
|
||||
import {
|
||||
|
|
@ -43,6 +44,9 @@ function manifest(overrides: ManifestOverrides = {}): PluginManifest {
|
|||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks()
|
||||
// Why: PluginService may still be releasing in-process Parcel watches; drop
|
||||
// the shared vitest watcher state before deleting roots those watches own.
|
||||
disposeWatcherProcess()
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -47,6 +47,22 @@ describe('PluginDevWatcher', () => {
|
|||
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
|
||||
})
|
||||
|
||||
it('contains unsubscribe rejections so dispose cannot fail the host process', async () => {
|
||||
const unsubscribe = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new Error('Unable to remove watcher: Invalid argument'))
|
||||
const subscribePath = vi.fn(async () => ({ unsubscribe }))
|
||||
const devWatcher = new PluginDevWatcher(subscribePath)
|
||||
devWatcher.start(['/plugins/demo'], vi.fn())
|
||||
await vi.waitFor(() => expect(subscribePath).toHaveBeenCalledOnce())
|
||||
|
||||
expect(() => devWatcher.dispose()).not.toThrow()
|
||||
await vi.waitFor(() => expect(unsubscribe).toHaveBeenCalledOnce())
|
||||
// Why: give the rejected promise a turn so an uncaught rejection would surface.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
})
|
||||
|
||||
it('does not spin refreshes when a missing path cannot be subscribed', async () => {
|
||||
vi.useFakeTimers()
|
||||
const subscribePath = vi.fn().mockRejectedValue(new Error('missing path'))
|
||||
|
|
|
|||
|
|
@ -20,6 +20,13 @@ const subscribePluginPath: SubscribePluginPath = (path, onEvent, onInterruption)
|
|||
}
|
||||
)
|
||||
|
||||
// Why: Parcel unsubscribe rejects when the watch root is already gone (common
|
||||
// in tests that rm temp dirs). Fire-and-forget callers must not leave that as
|
||||
// an unhandled rejection that fails the Vitest process.
|
||||
function releaseSubscription(subscription: WatcherProcessSubscription): void {
|
||||
void subscription.unsubscribe().catch(() => undefined)
|
||||
}
|
||||
|
||||
/** Owns debounced manifest/panel refresh watchers for mutable dev plugins. */
|
||||
export class PluginDevWatcher {
|
||||
private readonly subscriptions: WatcherProcessSubscription[] = []
|
||||
|
|
@ -40,7 +47,7 @@ export class PluginDevWatcher {
|
|||
failedBeforeReady = true
|
||||
if (subscription) {
|
||||
this.removeSubscription(subscription)
|
||||
void subscription.unsubscribe()
|
||||
releaseSubscription(subscription)
|
||||
}
|
||||
onWatcherError?.()
|
||||
this.scheduleRefresh(refresh)
|
||||
|
|
@ -65,7 +72,7 @@ export class PluginDevWatcher {
|
|||
.then((created) => {
|
||||
subscription = created
|
||||
if (generation !== this.generation || failedBeforeReady) {
|
||||
void created.unsubscribe()
|
||||
releaseSubscription(created)
|
||||
return
|
||||
}
|
||||
this.subscriptions.push(created)
|
||||
|
|
@ -85,7 +92,7 @@ export class PluginDevWatcher {
|
|||
this.refreshTimer = null
|
||||
}
|
||||
for (const subscription of this.subscriptions.splice(0)) {
|
||||
void subscription.unsubscribe()
|
||||
releaseSubscription(subscription)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import {
|
|||
hasHooksFile,
|
||||
loadHooks,
|
||||
parseOrcaYaml,
|
||||
resolveSetupRunnerShell,
|
||||
runHook,
|
||||
shouldRunSetupForCreate
|
||||
} from '../hooks'
|
||||
|
|
@ -466,6 +467,7 @@ vi.mock('../hooks', () => ({
|
|||
ORCA_WORKTREE_PATH: worktreePath
|
||||
}),
|
||||
loadHooks: vi.fn().mockReturnValue(null),
|
||||
resolveSetupRunnerShell: vi.fn().mockReturnValue(undefined),
|
||||
runHook: vi.fn().mockResolvedValue({ success: true, output: '' }),
|
||||
shouldRunSetupForCreate: vi
|
||||
.fn()
|
||||
|
|
@ -683,6 +685,7 @@ function resetRuntimeTestMocks(): void {
|
|||
vi.mocked(getEffectiveHooksFromConfig).mockReset()
|
||||
vi.mocked(getDefaultTabsLaunch).mockReset()
|
||||
vi.mocked(loadHooks).mockReset()
|
||||
vi.mocked(resolveSetupRunnerShell).mockReset()
|
||||
vi.mocked(hasHooksFile).mockReset()
|
||||
vi.mocked(parseOrcaYaml).mockReset()
|
||||
vi.mocked(runHook).mockReset()
|
||||
|
|
@ -692,6 +695,7 @@ function resetRuntimeTestMocks(): void {
|
|||
vi.mocked(getEffectiveHooksFromConfig).mockReturnValue(null)
|
||||
vi.mocked(getDefaultTabsLaunch).mockReturnValue(undefined)
|
||||
vi.mocked(loadHooks).mockReturnValue(null)
|
||||
vi.mocked(resolveSetupRunnerShell).mockReturnValue(undefined)
|
||||
vi.mocked(hasHooksFile).mockReturnValue(false)
|
||||
vi.mocked(parseOrcaYaml).mockReturnValue(null)
|
||||
computeWorktreePathMock.mockReset()
|
||||
|
|
@ -37741,6 +37745,7 @@ describe('OrcaRuntimeService', () => {
|
|||
expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }),
|
||||
'/tmp/workspaces/runtime-hook-test',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
|
|
@ -37832,6 +37837,85 @@ describe('OrcaRuntimeService', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('passes the selected Windows setup shell into runtime runner generation', async () => {
|
||||
setPlatform('win32')
|
||||
const runtimeStore = {
|
||||
...store,
|
||||
getSettings: () => ({
|
||||
...store.getSettings(),
|
||||
terminalWindowsShell: 'git-bash'
|
||||
})
|
||||
}
|
||||
const runtime = new OrcaRuntimeService(runtimeStore as never)
|
||||
const activateWorktree = vi.fn()
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree,
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-activate')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-activate')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(resolveSetupRunnerShell).mockReturnValue({ family: 'posix' })
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-activate'
|
||||
}
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValueOnce([
|
||||
{
|
||||
path: 'C:/workspaces/runtime-hook-activate',
|
||||
head: 'def',
|
||||
branch: 'runtime-hook-activate',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-hook-activate',
|
||||
runHooks: true,
|
||||
activate: true
|
||||
})
|
||||
|
||||
expect(createSetupRunnerScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }),
|
||||
'C:\\workspaces\\runtime-hook-activate',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
)
|
||||
expect(result.setup).toMatchObject({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix' }
|
||||
})
|
||||
expect(activateWorktree).toHaveBeenCalledWith(
|
||||
'repo-1',
|
||||
expect.any(String),
|
||||
result.setup,
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
})
|
||||
|
||||
it('follows normal setup policy for CLI-created worktrees without activating them', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
|
|
@ -37896,6 +37980,7 @@ describe('OrcaRuntimeService', () => {
|
|||
expect.objectContaining({ id: 'repo-1', path: '/tmp/repo' }),
|
||||
'/tmp/workspaces/runtime-hook-skip',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
undefined
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
|
|
@ -37949,6 +38034,224 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('uses returned WSL setup shell metadata when runtime spawns setup', async () => {
|
||||
setPlatform('win32')
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-created-worktree' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-primary' })
|
||||
.mockResolvedValueOnce({ id: 'pty-setup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
runtime.attachWindow(1)
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-wsl')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-wsl')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-wsl'
|
||||
}
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: 'C:/workspaces/runtime-hook-wsl',
|
||||
head: 'def',
|
||||
branch: 'runtime-hook-wsl',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-hook-wsl'
|
||||
})
|
||||
|
||||
expect(result.setup).toBeUndefined()
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'bash /mnt/c/repo/.git/orca/setup-runner.sh',
|
||||
env: expect.objectContaining({
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-wsl',
|
||||
ORCA_TAB_ID: expect.stringMatching(UUID_RE),
|
||||
ORCA_PANE_KEY: expect.any(String),
|
||||
ORCA_WORKTREE_ID: result.worktree.id
|
||||
}),
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('uses the shell-aware setup runner for windowless creates without a startup command', async () => {
|
||||
// Regression (C1): with no authoritative window and no startup command the
|
||||
// create fell back to runHook, which hardcodes cmd.exe on Windows and so ran
|
||||
// batch even when the configured terminal resolves to Git Bash.
|
||||
setPlatform('win32')
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-windowless' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-primary' })
|
||||
.mockResolvedValueOnce({ id: 'pty-setup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
// Deliberately no attachWindow: this is the windowless/CLI create path.
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('C:\\workspaces\\runtime-hook-windowless')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('C:\\workspaces\\runtime-hook-windowless')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(resolveSetupRunnerShell).mockReturnValue({ family: 'posix' })
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\workspaces\\runtime-hook-windowless'
|
||||
}
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: 'C:/workspaces/runtime-hook-windowless',
|
||||
head: 'def',
|
||||
branch: 'runtime-hook-windowless',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-hook-windowless',
|
||||
awaitTerminalProvisioning: true
|
||||
})
|
||||
|
||||
expect(createSetupRunnerScript).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'C:\\workspaces\\runtime-hook-windowless',
|
||||
'pnpm worktree:setup',
|
||||
undefined,
|
||||
{ family: 'posix' }
|
||||
)
|
||||
expect(runHook).not.toHaveBeenCalled()
|
||||
expect(result.setupReceipt).toMatchObject({ state: 'running' })
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
expect(spawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'bash /c/repo/.git/orca/setup-runner.sh',
|
||||
worktreeId: result.worktree.id
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('reports the in-process setup hook as running when nothing can launch the runner', async () => {
|
||||
// Regression (C1): the fire-and-forget hook is the last resort when there is
|
||||
// no PTY controller; reporting spawn_failed made callers retry a live hook.
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-hook-no-pty')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-hook-no-pty')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(runHook).mockResolvedValue({ success: true, output: '' })
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-hook-no-pty',
|
||||
head: 'def',
|
||||
branch: 'runtime-hook-no-pty',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
const result = await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-hook-no-pty',
|
||||
awaitTerminalProvisioning: true
|
||||
})
|
||||
|
||||
expect(createSetupRunnerScript).not.toHaveBeenCalled()
|
||||
expect(runHook).toHaveBeenCalledWith(
|
||||
'setup',
|
||||
'/tmp/workspaces/runtime-hook-no-pty',
|
||||
expect.objectContaining({ id: 'repo-1' }),
|
||||
'/tmp/workspaces/runtime-hook-no-pty',
|
||||
undefined
|
||||
)
|
||||
expect(result.setupReceipt).toMatchObject({ state: 'running' })
|
||||
})
|
||||
|
||||
it('sequences setup before startup for opted-in local headless worktree creates', async () => {
|
||||
const waitRepo = {
|
||||
...store.getRepo('repo-1')!,
|
||||
|
|
@ -38001,7 +38304,8 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-headless-startup-setup'
|
||||
|
|
@ -38038,6 +38342,8 @@ describe('OrcaRuntimeService', () => {
|
|||
const nonceMatch = startupCommand.match(/if \[ "\$seen" = ([0-9a-f-]+) \]/)
|
||||
expect(nonceMatch?.[1]).toBeTruthy()
|
||||
expect(startupCommand).toContain('exec claude')
|
||||
expect(startupCommand).toContain('/mnt/c/tmp/repo/.git/orca/setup-runner.sh')
|
||||
expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh')
|
||||
expect(setupCommand).toContain('printf')
|
||||
expect(setupCommand).toContain(`${nonceMatch![1]} "$status"`)
|
||||
expect(result.setup).toBeUndefined()
|
||||
|
|
@ -38120,6 +38426,75 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('observes setup completion through the launch shell the runner was written for', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const revealTerminalSession = vi.fn().mockResolvedValue({ tabId: 'tab-observed-wsl-shell' })
|
||||
const spawn = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 'pty-observed-wsl-startup' })
|
||||
.mockResolvedValueOnce({ id: 'pty-observed-wsl-setup' })
|
||||
runtime.setPtyController({
|
||||
spawn,
|
||||
write: () => true,
|
||||
kill: () => true,
|
||||
getForegroundProcess: async () => null
|
||||
})
|
||||
runtime.setNotifier({
|
||||
worktreesChanged: vi.fn(),
|
||||
reposChanged: vi.fn(),
|
||||
activateWorktree: vi.fn(),
|
||||
createTerminal: vi.fn(),
|
||||
revealTerminalSession,
|
||||
splitTerminal: vi.fn(),
|
||||
renameTerminal: vi.fn(),
|
||||
focusTerminal: vi.fn(),
|
||||
closeTerminal: vi.fn(),
|
||||
sleepWorktree: vi.fn(),
|
||||
terminalFitOverrideChanged: vi.fn(),
|
||||
terminalDriverChanged: vi.fn()
|
||||
})
|
||||
|
||||
computeWorktreePathMock.mockReturnValue('/tmp/workspaces/runtime-observed-wsl-shell')
|
||||
ensurePathWithinWorkspaceMock.mockReturnValue('/tmp/workspaces/runtime-observed-wsl-shell')
|
||||
vi.mocked(getEffectiveHooks).mockReturnValue({
|
||||
scripts: {
|
||||
setup: 'pnpm worktree:setup'
|
||||
}
|
||||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-observed-wsl-shell'
|
||||
}
|
||||
})
|
||||
vi.mocked(listWorktrees).mockResolvedValue([
|
||||
{
|
||||
path: '/tmp/workspaces/runtime-observed-wsl-shell',
|
||||
head: 'def',
|
||||
branch: 'runtime-observed-wsl-shell',
|
||||
isBare: false,
|
||||
isMainWorktree: false
|
||||
}
|
||||
])
|
||||
|
||||
await runtime.createManagedWorktree({
|
||||
repoSelector: 'id:repo-1',
|
||||
name: 'runtime-observed-wsl-shell',
|
||||
setupDecision: 'run',
|
||||
startup: { command: 'claude' },
|
||||
observeSetupCompletion: true,
|
||||
awaitTerminalProvisioning: true
|
||||
})
|
||||
|
||||
await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2))
|
||||
const setupCommand = (spawn.mock.calls[1]![0] as { command: string }).command
|
||||
expect(setupCommand).toContain('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh')
|
||||
expect(setupCommand).toContain('__ORCA_SETUP_COMPLETE__:')
|
||||
})
|
||||
|
||||
it('creates the first terminal for CLI-created worktrees without activating them', async () => {
|
||||
const runtime = new OrcaRuntimeService(store)
|
||||
const activateWorktree = vi.fn()
|
||||
|
|
@ -39334,7 +39709,8 @@ describe('OrcaRuntimeService', () => {
|
|||
})
|
||||
vi.mocked(shouldRunSetupForCreate).mockReturnValue(true)
|
||||
vi.mocked(createSetupRunnerScript).mockReturnValue({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: '/tmp/repo',
|
||||
ORCA_WORKTREE_PATH: '/tmp/workspaces/runtime-startup-setup-retry'
|
||||
|
|
@ -39364,8 +39740,8 @@ describe('OrcaRuntimeService', () => {
|
|||
'repo-1',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
runnerScriptPath: '/tmp/repo/.git/orca/setup-runner.sh',
|
||||
command: expect.stringContaining('bash /tmp/repo/.git/orca/setup-runner.sh')
|
||||
runnerScriptPath: 'C:\\tmp\\repo\\.git\\orca\\setup-runner.sh',
|
||||
command: expect.stringContaining('bash /mnt/c/tmp/repo/.git/orca/setup-runner.sh')
|
||||
}),
|
||||
undefined,
|
||||
undefined
|
||||
|
|
|
|||
|
|
@ -840,6 +840,7 @@ import {
|
|||
loadHooks,
|
||||
parseOrcaYaml,
|
||||
readIssueCommand,
|
||||
resolveSetupRunnerShell,
|
||||
runHook,
|
||||
shouldRunSetupForCreate,
|
||||
writeIssueCommand
|
||||
|
|
@ -2637,6 +2638,13 @@ async function hasLocalWorktreeBaseRef(
|
|||
)
|
||||
}
|
||||
|
||||
function getSetupRunnerCommandPlatformForLaunch(
|
||||
setup: CreateWorktreeResult['setup'],
|
||||
fallbackPlatform: 'windows' | 'posix'
|
||||
): 'windows' | 'posix' {
|
||||
return getSetupRunnerCommandPlatformForPath(setup?.runnerScriptPath ?? '', fallbackPlatform)
|
||||
}
|
||||
|
||||
export class OrcaRuntimeService {
|
||||
private readonly runtimeId = randomUUID()
|
||||
private readonly startedAt = Date.now()
|
||||
|
|
@ -20566,13 +20574,18 @@ export class OrcaRuntimeService {
|
|||
? buildObservedSetupCommand(
|
||||
args.setup.runnerScriptPath,
|
||||
args.setupCommandPlatform,
|
||||
completionToken
|
||||
completionToken,
|
||||
args.setup.shell
|
||||
)
|
||||
: null
|
||||
const setupCommand =
|
||||
args.wrappedSetupCommand ??
|
||||
observedCommand?.command ??
|
||||
buildSetupRunnerCommand(args.setup.runnerScriptPath, args.setupCommandPlatform)
|
||||
buildSetupRunnerCommand(
|
||||
args.setup.runnerScriptPath,
|
||||
args.setupCommandPlatform,
|
||||
args.setup.shell
|
||||
)
|
||||
const setupEnv = { ...args.setup.envVars, ...observedCommand?.env }
|
||||
const shouldSplitSetup =
|
||||
primaryTerminalHandle &&
|
||||
|
|
@ -21507,17 +21520,27 @@ export class OrcaRuntimeService {
|
|||
: undefined
|
||||
}
|
||||
const shouldRunSetup = hooks?.scripts.setup && shouldRunSetupForCreate(repo, effectiveDecision)
|
||||
// Why: the in-process hook uses a hardcoded cmd/bash shell, so it can only run
|
||||
// when nothing downstream is able to launch the shell-aware runner script.
|
||||
let didStartInProcessSetupHook = false
|
||||
if (shouldRunSetup && hooks?.scripts.setup) {
|
||||
const shouldUseSetupRunner = this.authoritativeWindowId !== null || Boolean(effectiveStartup)
|
||||
const shouldUseSetupRunner =
|
||||
this.authoritativeWindowId !== null ||
|
||||
Boolean(effectiveStartup) ||
|
||||
Boolean(this.ptyController?.spawn)
|
||||
if (shouldUseSetupRunner) {
|
||||
try {
|
||||
// Why: setup+startup must share the terminal runner path even without
|
||||
// a renderer window, so the startup shell can wait on setup completion.
|
||||
// a renderer window, so the startup shell can wait on setup completion
|
||||
// and windowless creates resolve the same Windows setup shell.
|
||||
const runtimeTarget = this.getLocalGitExecutionOptionArgs(repo)[0]
|
||||
// Why: both trailing args are optional — the shell is undefined off Windows.
|
||||
setup = createSetupRunnerScript(
|
||||
repo,
|
||||
worktreePath,
|
||||
hooks.scripts.setup,
|
||||
this.getLocalGitExecutionOptionArgs(repo)[0]
|
||||
runtimeTarget,
|
||||
resolveSetupRunnerShell(settings)
|
||||
)
|
||||
} catch (error) {
|
||||
// Why: the git worktree is already real at this point. If runner
|
||||
|
|
@ -21526,6 +21549,7 @@ export class OrcaRuntimeService {
|
|||
console.error(`[hooks] Failed to prepare setup runner for ${worktreePath}:`, error)
|
||||
}
|
||||
} else {
|
||||
didStartInProcessSetupHook = true
|
||||
void runHook(
|
||||
'setup',
|
||||
worktreePath,
|
||||
|
|
@ -21571,14 +21595,15 @@ export class OrcaRuntimeService {
|
|||
let sequencedStartup = effectiveStartup
|
||||
let wrappedSetupCommandStr: string | undefined
|
||||
if (effectiveStartup && setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
const platform = getSetupRunnerCommandPlatformForLaunch(
|
||||
setup,
|
||||
process.platform === 'win32' ? 'windows' : 'posix'
|
||||
)
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: setup.runnerScriptPath,
|
||||
startupCommand: effectiveStartup.command,
|
||||
platform
|
||||
platform,
|
||||
shell: setup.shell
|
||||
})
|
||||
sequencedStartup = {
|
||||
...effectiveStartup,
|
||||
|
|
@ -21650,11 +21675,7 @@ export class OrcaRuntimeService {
|
|||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
primaryTerminalHandle: startupTerminalHandle,
|
||||
hasStartupTerminal: didSpawnStartup,
|
||||
setupCommandPlatform: setup
|
||||
? isWindowsAbsolutePathLike(setup.runnerScriptPath)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'),
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
// Why: carry the wait-for-agent wrapped setup command (#6298) so the
|
||||
// Setup tab runs the same script the sequenced agent waits on.
|
||||
|
|
@ -21705,11 +21726,7 @@ export class OrcaRuntimeService {
|
|||
...(defaultTabs ? { defaultTabs } : {}),
|
||||
primaryTerminalHandle: startupTerminalHandle,
|
||||
hasStartupTerminal: didSpawnStartup,
|
||||
setupCommandPlatform: setup
|
||||
? isWindowsAbsolutePathLike(setup.runnerScriptPath)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(setup, 'posix'),
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}),
|
||||
surfaceOwner: false
|
||||
|
|
@ -21776,7 +21793,9 @@ export class OrcaRuntimeService {
|
|||
? ('not_configured' as const)
|
||||
: effectiveDecision === 'skip' || !shouldRunSetup
|
||||
? ('skipped' as const)
|
||||
: didSpawnSetup
|
||||
: // Why: the in-process hook is already executing, so reporting
|
||||
// spawn_failed would strand callers that retry on it.
|
||||
didSpawnSetup || didStartInProcessSetupHook
|
||||
? ('running' as const)
|
||||
: ('spawn_failed' as const),
|
||||
...(setupTerminalHandle ? { terminalHandle: setupTerminalHandle } : {})
|
||||
|
|
@ -21927,11 +21946,12 @@ export class OrcaRuntimeService {
|
|||
let sequencedStartup = args.startup
|
||||
let wrappedSetupCommandStr: string | undefined
|
||||
if (args.startup && result.setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForPath(result.setup.runnerScriptPath, 'posix')
|
||||
const platform = getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix')
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: result.setup.runnerScriptPath,
|
||||
startupCommand: args.startup.command,
|
||||
platform
|
||||
platform,
|
||||
shell: result.setup.shell
|
||||
})
|
||||
sequencedStartup = {
|
||||
...args.startup,
|
||||
|
|
@ -21998,11 +22018,7 @@ export class OrcaRuntimeService {
|
|||
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
|
||||
primaryTerminalHandle: startupTerminalHandle,
|
||||
hasStartupTerminal: didSpawnStartup,
|
||||
setupCommandPlatform: result.setup
|
||||
? isWindowsAbsolutePathLike(result.setup.runnerScriptPath)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'),
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
// Why: carry the wait-for-agent wrapped setup command (#6298) so the
|
||||
// remote Setup tab runs the same script the sequenced agent waits on.
|
||||
|
|
@ -22058,11 +22074,7 @@ export class OrcaRuntimeService {
|
|||
...(result.defaultTabs ? { defaultTabs: result.defaultTabs } : {}),
|
||||
primaryTerminalHandle: startupTerminalHandle,
|
||||
hasStartupTerminal: didSpawnStartup,
|
||||
setupCommandPlatform: result.setup
|
||||
? isWindowsAbsolutePathLike(result.setup.runnerScriptPath)
|
||||
? 'windows'
|
||||
: 'posix'
|
||||
: 'posix',
|
||||
setupCommandPlatform: getSetupRunnerCommandPlatformForLaunch(result.setup, 'posix'),
|
||||
observeSetupCompletion: args.observeSetupCompletion,
|
||||
...(wrappedSetupCommandStr ? { wrappedSetupCommand: wrappedSetupCommandStr } : {}),
|
||||
surfaceOwner: false
|
||||
|
|
|
|||
|
|
@ -41,6 +41,29 @@ describe('orchestration setup completion signal', () => {
|
|||
expect(command).toContain('exit "$status"')
|
||||
})
|
||||
|
||||
it('routes a WSL-launched Windows-drive runner through its /mnt mount', () => {
|
||||
const { command } = buildObservedSetupCommand(
|
||||
'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
'windows',
|
||||
'token-mnt',
|
||||
{ family: 'posix', executable: 'wsl.exe' }
|
||||
)
|
||||
|
||||
expect(command).toContain('bash /mnt/c/repo/.git/orca/setup-runner.sh')
|
||||
expect(command).not.toContain('bash /c/repo')
|
||||
})
|
||||
|
||||
it('keeps a Git Bash runner on the MSYS drive form', () => {
|
||||
const { command } = buildObservedSetupCommand(
|
||||
'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
'windows',
|
||||
'token-git-bash',
|
||||
{ family: 'posix' }
|
||||
)
|
||||
|
||||
expect(command).toContain('bash /c/repo/.git/orca/setup-runner.sh')
|
||||
})
|
||||
|
||||
it('recognizes one completion signal across output chunk boundaries', () => {
|
||||
const onComplete = vi.fn()
|
||||
const scanner = createSetupCompletionScanner('token-chunks', onComplete)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import {
|
||||
resolveSetupRunnerCommand,
|
||||
type SetupRunnerCommandPlatform
|
||||
type SetupRunnerCommandPlatform,
|
||||
type SetupRunnerShell
|
||||
} from '../../../shared/setup-runner-command'
|
||||
|
||||
const SETUP_COMPLETION_PREFIX = '__ORCA_SETUP_COMPLETE__:'
|
||||
|
|
@ -10,9 +11,12 @@ const WINDOWS_SETUP_RUNNER_ENV = 'ORCA_SETUP_RUNNER_PATH'
|
|||
export function buildObservedSetupCommand(
|
||||
runnerScriptPath: string,
|
||||
platform: SetupRunnerCommandPlatform,
|
||||
completionToken: string
|
||||
completionToken: string,
|
||||
// Why: the observed command must reuse the shell the runner was written for, or a
|
||||
// WSL-routed Windows-drive runner gets Git Bash `/c/...` instead of `/mnt/c/...`.
|
||||
shell?: SetupRunnerShell
|
||||
): { command: string; env?: Record<string, string> } {
|
||||
const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform)
|
||||
const resolution = resolveSetupRunnerCommand(runnerScriptPath, platform, shell)
|
||||
if (resolution.shell === 'windows') {
|
||||
const script = [
|
||||
`$runner = $env:${WINDOWS_SETUP_RUNNER_ENV}`,
|
||||
|
|
|
|||
|
|
@ -95,7 +95,8 @@ import type {
|
|||
WorktreeBaseStatusEvent,
|
||||
WorktreeDefaultTabsLaunch,
|
||||
WorktreeHeadIdentity,
|
||||
WorktreeRemoteBranchConflictEvent
|
||||
WorktreeRemoteBranchConflictEvent,
|
||||
WorktreeSetupLaunch
|
||||
} from '../shared/types'
|
||||
import type { PtyModelRestoreNeededEvent } from '../shared/pty-model-restore-marker'
|
||||
import type { PtyListedSession } from '../shared/pty-listed-session'
|
||||
|
|
@ -2868,8 +2869,7 @@ const api = {
|
|||
repoId: string
|
||||
worktreePath: string
|
||||
command: string
|
||||
}): Promise<{ runnerScriptPath: string; envVars: Record<string, string> }> =>
|
||||
ipcRenderer.invoke('hooks:createIssueCommandRunner', args),
|
||||
}): Promise<WorktreeSetupLaunch> => ipcRenderer.invoke('hooks:createIssueCommandRunner', args),
|
||||
|
||||
readIssueCommand: (args: {
|
||||
repoId: string
|
||||
|
|
@ -3685,7 +3685,7 @@ const api = {
|
|||
callback: (data: {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
|
||||
setup?: WorktreeSetupLaunch
|
||||
startup?: { command: string; env?: Record<string, string> }
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
}) => void
|
||||
|
|
@ -3695,7 +3695,7 @@ const api = {
|
|||
data: {
|
||||
repoId: string
|
||||
worktreeId: string
|
||||
setup?: { runnerScriptPath: string; envVars: Record<string, string> }
|
||||
setup?: WorktreeSetupLaunch
|
||||
startup?: { command: string; env?: Record<string, string> }
|
||||
defaultTabs?: WorktreeDefaultTabsLaunch
|
||||
}
|
||||
|
|
|
|||
|
|
@ -687,6 +687,60 @@ describe('PtyHandler', () => {
|
|||
}
|
||||
})
|
||||
|
||||
// Why: both spellings classify as a POSIX startup family, so the relay must not be the one host
|
||||
// that hard-fails a setting the local and daemon PTYs accept.
|
||||
it.each(['bash', 'bash.exe'])(
|
||||
'accepts the %s shell override and routes it through Git Bash resolution',
|
||||
async (shellOverride) => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
const resolveGitBashSpy = vi
|
||||
.spyOn(gitBash, 'resolveWindowsGitBashShellPath')
|
||||
.mockReturnValue('C:\\Program Files\\Git\\bin\\bash.exe')
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride })
|
||||
|
||||
expect(resolveGitBashSpy).toHaveBeenCalledWith(shellOverride)
|
||||
expect(mockPtySpawn).toHaveBeenCalledWith(
|
||||
'C:\\Program Files\\Git\\bin\\bash.exe',
|
||||
expect.any(Array),
|
||||
expect.any(Object)
|
||||
)
|
||||
} finally {
|
||||
resolveGitBashSpy.mockRestore()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
it('falls back to the literal bash override when Git Bash is not installed', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: 'win32'
|
||||
})
|
||||
const resolveGitBashSpy = vi
|
||||
.spyOn(gitBash, 'resolveWindowsGitBashShellPath')
|
||||
.mockReturnValue(null)
|
||||
try {
|
||||
await dispatcher.callRequest('pty.spawn', { cols: 80, rows: 24, shellOverride: 'bash' })
|
||||
|
||||
expect(mockPtySpawn).toHaveBeenCalledWith('bash', expect.any(Array), expect.any(Object))
|
||||
} finally {
|
||||
resolveGitBashSpy.mockRestore()
|
||||
Object.defineProperty(process, 'platform', {
|
||||
configurable: true,
|
||||
value: originalPlatform
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
it('resolves the Git Bash sentinel to the remote bash.exe path on Windows', async () => {
|
||||
const originalPlatform = process.platform
|
||||
Object.defineProperty(process, 'platform', {
|
||||
|
|
|
|||
|
|
@ -263,6 +263,10 @@ const ALLOWED_WINDOWS_SHELL_OVERRIDES = new Set([
|
|||
'cmd',
|
||||
'wsl.exe',
|
||||
'wsl',
|
||||
// Why: both spellings classify as a POSIX startup family, so rejecting them here made the relay
|
||||
// the one host that hard-failed a setting the local and daemon PTYs accept.
|
||||
'bash.exe',
|
||||
'bash',
|
||||
WINDOWS_GIT_BASH_SHELL
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -250,6 +250,37 @@ describe('launchWorktreeBackgroundTerminals', () => {
|
|||
)
|
||||
})
|
||||
|
||||
it('uses configured WSL setup commands for Windows bash runner paths', async () => {
|
||||
state.repos = [{ id: 'repo-1', connectionId: null }]
|
||||
state.worktreesByRepo['repo-1'] = [
|
||||
{
|
||||
id: 'wt-1',
|
||||
repoId: 'repo-1',
|
||||
path: 'C:\\repo\\worktree',
|
||||
displayName: 'Worktree'
|
||||
}
|
||||
]
|
||||
const { launchWorktreeBackgroundTerminals } =
|
||||
await import('./launch-worktree-background-terminals')
|
||||
|
||||
await launchWorktreeBackgroundTerminals({
|
||||
worktreeId: 'wt-1',
|
||||
setup: {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\wt\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: { ORCA_WORKTREE_PATH: 'C:\\repo\\worktree' }
|
||||
}
|
||||
})
|
||||
|
||||
expect(mockSpawn).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
command: 'bash /mnt/c/repo/.git/worktrees/wt/orca/setup-runner.sh',
|
||||
connectionId: null
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it('still attempts setup when a default tab fails to spawn', async () => {
|
||||
const spawnError = new Error('pty unavailable')
|
||||
mockSpawn
|
||||
|
|
|
|||
|
|
@ -9,9 +9,11 @@ import { singlePaneLayoutSnapshot } from '@/store/slices/terminal-helpers'
|
|||
import { retireUnownedTerminal } from '@/lib/retire-unowned-background-terminal'
|
||||
import { useAppStore } from '@/store'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import { isWindowsAbsolutePathLike } from '../../../shared/cross-platform-path'
|
||||
import { makePaneKey } from '../../../shared/stable-pane-id'
|
||||
import { buildSetupRunnerCommand } from '../../../shared/setup-runner-command'
|
||||
import {
|
||||
buildSetupRunnerCommand,
|
||||
getSetupRunnerCommandPlatformForPath
|
||||
} from '../../../shared/setup-runner-command'
|
||||
import type {
|
||||
TerminalLayoutSnapshot,
|
||||
Worktree,
|
||||
|
|
@ -120,9 +122,11 @@ function registerBackgroundPaneBuffer(tabId: string, leafId: string, ptyId: stri
|
|||
}
|
||||
|
||||
function buildSetupCommand(setup: WorktreeSetupLaunch): string {
|
||||
// Why: background setup tabs can launch later, so they must reuse the same shell chosen when the runner was written.
|
||||
return buildSetupRunnerCommand(
|
||||
setup.runnerScriptPath,
|
||||
isWindowsAbsolutePathLike(setup.runnerScriptPath) ? 'windows' : 'posix'
|
||||
getSetupRunnerCommandPlatformForPath(setup.runnerScriptPath, 'posix'),
|
||||
setup.shell
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2,8 +2,12 @@ import {
|
|||
buildSetupRunnerCommand as buildSharedSetupRunnerCommand,
|
||||
getSetupRunnerCommandPlatformForPath
|
||||
} from '../../../shared/setup-runner-command'
|
||||
import type { SetupRunnerShell } from '../../../shared/setup-runner-command'
|
||||
|
||||
export function buildSetupRunnerCommand(runnerScriptPath: string): string {
|
||||
export function buildSetupRunnerCommand(
|
||||
runnerScriptPath: string,
|
||||
shell?: SetupRunnerShell
|
||||
): string {
|
||||
// Why: the runner may live on a remote/WSL filesystem, so the shell follows
|
||||
// the runner path format rather than the local renderer OS.
|
||||
return buildSharedSetupRunnerCommand(
|
||||
|
|
@ -11,6 +15,7 @@ export function buildSetupRunnerCommand(runnerScriptPath: string): string {
|
|||
getSetupRunnerCommandPlatformForPath(
|
||||
runnerScriptPath,
|
||||
navigator.userAgent.includes('Windows') ? 'windows' : 'posix'
|
||||
)
|
||||
),
|
||||
shell
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,6 +88,29 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues setup through returned POSIX shell metadata on native Windows paths', () => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', {
|
||||
command: 'bash /c/repo/.git/orca/setup-runner.sh',
|
||||
env: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('creates a single tab without setup split when no setup is provided', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
|
|
@ -449,6 +472,29 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
expect(store.queueTabSetupSplit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues WSL setup launch commands with WSL path conversion on native Windows paths', () => {
|
||||
let createdIndex = 0
|
||||
const createTab = vi.fn(() => ({ id: `tab-${++createdIndex}` }))
|
||||
const store = createMockStore({ createTab })
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1'
|
||||
}
|
||||
})
|
||||
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith('tab-2', {
|
||||
command: 'bash /mnt/c/repo/.git/orca/setup-runner.sh',
|
||||
env: {
|
||||
ORCA_ROOT_PATH: 'C:\\repo',
|
||||
ORCA_WORKTREE_PATH: 'C:\\worktrees\\wt-1'
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('queues a startup command when agent launch is provided', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
|
|
@ -734,6 +780,35 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('keeps WSL setup shell metadata when gating startup behind setup completion', () => {
|
||||
setSetupScriptLaunchMode('split-vertical')
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(
|
||||
store,
|
||||
'wt-1',
|
||||
{ command: 'claude' },
|
||||
{
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: { ORCA_ROOT_PATH: 'C:\\repo' },
|
||||
waitForAgentStartup: true
|
||||
}
|
||||
)
|
||||
|
||||
expect(store.queueTabStartupCommand).toHaveBeenCalledWith(
|
||||
'tab-1',
|
||||
expect.objectContaining({
|
||||
command: expect.stringContaining('/mnt/c/repo/.git/orca/setup-runner.sh')
|
||||
})
|
||||
)
|
||||
expect(store.queueTabSetupSplit).toHaveBeenCalledWith('tab-1', {
|
||||
command: expect.stringContaining('bash /mnt/c/repo/.git/orca/setup-runner.sh'),
|
||||
env: { ORCA_ROOT_PATH: 'C:\\repo' },
|
||||
direction: 'vertical'
|
||||
})
|
||||
})
|
||||
|
||||
it('forwards telemetry on the queued startup so main can fire agent_started', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
|
|
@ -827,6 +902,21 @@ describe('ensureWorktreeHasInitialTerminal', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('queues an issue command split through returned WSL shell metadata', () => {
|
||||
const store = createMockStore()
|
||||
|
||||
ensureWorktreeHasInitialTerminal(store, 'wt-1', undefined, undefined, {
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\issue-command-runner.sh',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
envVars: { ORCA_ROOT_PATH: 'C:\\repo' }
|
||||
})
|
||||
|
||||
expect(store.queueTabIssueCommandSplit).toHaveBeenCalledWith('tab-1', {
|
||||
command: 'bash /mnt/c/repo/.git/orca/issue-command-runner.sh',
|
||||
env: { ORCA_ROOT_PATH: 'C:\\repo' }
|
||||
})
|
||||
})
|
||||
|
||||
it('queues both setup split and issue command split when both are provided', () => {
|
||||
setSetupScriptLaunchMode('split-vertical')
|
||||
const store = createMockStore()
|
||||
|
|
|
|||
|
|
@ -112,6 +112,13 @@ export type IssueCommandLaunch =
|
|||
| WorktreeSetupLaunch
|
||||
| { command: string; env?: Record<string, string> }
|
||||
|
||||
function getSetupRunnerCommandPlatformForLaunch(setup: WorktreeSetupLaunch): 'windows' | 'posix' {
|
||||
return getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
navigator.userAgent.includes('Windows') ? 'windows' : 'posix'
|
||||
)
|
||||
}
|
||||
|
||||
type WorktreeActivationStore = Partial<WorktreeRuntimeOwnerState> & {
|
||||
tabsByWorktree: Record<string, { id: string }[]>
|
||||
defaultTerminalTabsAppliedByWorktreeId: Record<string, true>
|
||||
|
|
@ -430,14 +437,12 @@ export function ensureWorktreeHasInitialTerminal(
|
|||
let wrappedSetupCommandStr: string | undefined
|
||||
|
||||
if (startup && setup?.waitForAgentStartup === true) {
|
||||
const platform = getSetupRunnerCommandPlatformForPath(
|
||||
setup.runnerScriptPath,
|
||||
navigator.userAgent.includes('Windows') ? 'windows' : 'posix'
|
||||
)
|
||||
const platform = getSetupRunnerCommandPlatformForLaunch(setup)
|
||||
const sequenced = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: setup.runnerScriptPath,
|
||||
startupCommand: startup.command,
|
||||
platform
|
||||
platform,
|
||||
shell: setup.shell
|
||||
})
|
||||
sequencedStartup = {
|
||||
...startup,
|
||||
|
|
@ -670,7 +675,9 @@ function queueSetupAndIssueCommands(
|
|||
const mode = useAppStore.getState().settings?.setupScriptLaunchMode ?? 'new-tab'
|
||||
const setupCommand = {
|
||||
command:
|
||||
wrappedSetupCommandStr ?? setup.command ?? buildSetupRunnerCommand(setup.runnerScriptPath),
|
||||
wrappedSetupCommandStr ??
|
||||
setup.command ??
|
||||
buildSetupRunnerCommand(setup.runnerScriptPath, setup.shell),
|
||||
env: setup.envVars
|
||||
}
|
||||
if (mode === 'new-tab') {
|
||||
|
|
@ -699,7 +706,7 @@ function queueSetupAndIssueCommands(
|
|||
const queuedIssueCommand =
|
||||
'runnerScriptPath' in issueCommand
|
||||
? {
|
||||
command: buildSetupRunnerCommand(issueCommand.runnerScriptPath),
|
||||
command: buildSetupRunnerCommand(issueCommand.runnerScriptPath, issueCommand.shell),
|
||||
env: issueCommand.envVars
|
||||
}
|
||||
: { command: issueCommand.command, env: issueCommand.env }
|
||||
|
|
|
|||
|
|
@ -165,6 +165,24 @@ describe('createSequencedSetupAgentCommands', () => {
|
|||
expect(result.startupCommand).toContain('[ "$seen" = nonce-remote ]')
|
||||
})
|
||||
|
||||
it('preserves WSL shell metadata when sequencing native Windows runners', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.sh',
|
||||
startupCommand: 'claude',
|
||||
platform: 'windows',
|
||||
shell: { family: 'posix', executable: 'wsl.exe' },
|
||||
nonce: 'nonce-wsl-shell'
|
||||
})
|
||||
|
||||
expect(result.setupCommand).toContain('bash /mnt/c/repo/.git/orca/setup-runner.sh')
|
||||
expect(result.setupCommand).toContain(
|
||||
'/mnt/c/repo/.git/orca/setup-runner.sh.nonce-wsl-shell.done'
|
||||
)
|
||||
expect(result.startupCommand).toContain(
|
||||
'/mnt/c/repo/.git/orca/setup-runner.sh.nonce-wsl-shell.done'
|
||||
)
|
||||
})
|
||||
|
||||
it('wraps native Windows runners in a cmd-pinned setup and startup gate', () => {
|
||||
const result = createSequencedSetupAgentCommands({
|
||||
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd',
|
||||
|
|
|
|||
|
|
@ -2,7 +2,8 @@ import { encodePowerShellCommand } from './powershell-command-encoding'
|
|||
import {
|
||||
resolveSetupRunnerCommand,
|
||||
type SetupRunnerCommandPlatform,
|
||||
type SetupRunnerCommandShell
|
||||
type SetupRunnerCommandShell,
|
||||
type SetupRunnerShell
|
||||
} from './setup-runner-command'
|
||||
|
||||
const DEFAULT_WAIT_TIMEOUT_SECONDS = 2 * 60 * 60
|
||||
|
|
@ -34,11 +35,12 @@ export function createSequencedSetupAgentCommands(args: {
|
|||
runnerScriptPath: string
|
||||
startupCommand: string
|
||||
platform: SetupRunnerCommandPlatform
|
||||
shell?: SetupRunnerShell
|
||||
nonce?: string
|
||||
waitTimeoutSeconds?: number
|
||||
}): SequencedSetupAgentCommands {
|
||||
const nonce = args.nonce ?? createSetupAgentSequenceNonce()
|
||||
const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform)
|
||||
const resolution = resolveSetupRunnerCommand(args.runnerScriptPath, args.platform, args.shell)
|
||||
// Why: overlapping gated launches of the same setup runner must not race on
|
||||
// a shared completion marker.
|
||||
const markerPath = `${resolution.runnerScriptPathForShell}.${nonce}.done`
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
buildSetupRunnerCommand,
|
||||
getSetupRunnerCommandPlatformForPath
|
||||
getSetupRunnerCommandPlatformForPath,
|
||||
nativeWindowsPathToPosixShellPath,
|
||||
resolveSetupRunnerCommand
|
||||
} from './setup-runner-command'
|
||||
|
||||
describe('buildSetupRunnerCommand', () => {
|
||||
|
|
@ -28,6 +30,137 @@ describe('buildSetupRunnerCommand', () => {
|
|||
buildSetupRunnerCommand('//server/share/repo/.git/orca/setup-runner.cmd', 'windows')
|
||||
).toBe('cmd.exe /c "//server/share/repo/.git/orca/setup-runner.cmd"')
|
||||
})
|
||||
|
||||
it('uses POSIX launch semantics for native Windows runners when the setup shell is POSIX', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', {
|
||||
family: 'posix'
|
||||
})
|
||||
).toBe('bash /c/repo/.git/orca/setup-runner.sh')
|
||||
})
|
||||
|
||||
it('uses the active WSL shell with WSL paths for native Windows POSIX runners', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows', {
|
||||
family: 'posix',
|
||||
executable: 'wsl.exe'
|
||||
})
|
||||
).toBe('bash /mnt/c/repo/.git/orca/setup-runner.sh')
|
||||
})
|
||||
|
||||
it('keeps cmd.exe launch semantics for cmd setup runners', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.cmd', 'windows', {
|
||||
family: 'cmd'
|
||||
})
|
||||
).toBe('cmd.exe /c "C:\\repo\\.git\\orca\\setup-runner.cmd"')
|
||||
})
|
||||
|
||||
it('infers generated POSIX runner shell semantics from extension when metadata is absent', () => {
|
||||
expect(buildSetupRunnerCommand('C:\\repo\\.git\\orca\\setup-runner.sh', 'windows')).toBe(
|
||||
'bash /c/repo/.git/orca/setup-runner.sh'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildSetupRunnerCommand cmd metacharacter guard', () => {
|
||||
const cmdRunner = (segment: string) => `C:\\repo${segment}\\.git\\orca\\setup-runner.cmd`
|
||||
const decodePowerShellCommand = (command: string): string => {
|
||||
const encoded = command.match(/-EncodedCommand (\S+)$/)?.[1]
|
||||
expect(encoded).toBeTruthy()
|
||||
const bytes = atob(encoded as string)
|
||||
let decoded = ''
|
||||
for (let index = 0; index < bytes.length; index += 2) {
|
||||
decoded += String.fromCharCode(bytes.charCodeAt(index) | (bytes.charCodeAt(index + 1) << 8))
|
||||
}
|
||||
return decoded
|
||||
}
|
||||
|
||||
it.each(['%', '&', '|', '<', '>', '^', '(', ')', '!', ',', ';', '=', '$', '`'])(
|
||||
'hardens the launch when the runner path contains %s',
|
||||
(character) => {
|
||||
const command = buildSetupRunnerCommand(cmdRunner(`\\a${character}b`), 'windows', {
|
||||
family: 'cmd'
|
||||
})
|
||||
|
||||
expect(command).toMatch(
|
||||
/^powershell\.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand [A-Za-z0-9+/=]+$/
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
it.each([
|
||||
['plain', 'C:\\repo\\.git\\orca\\setup-runner.cmd'],
|
||||
['spaces', 'C:\\Program Files\\repo\\.git\\orca\\setup-runner.cmd'],
|
||||
['single quote', "C:\\o'brien\\.git\\orca\\setup-runner.cmd"],
|
||||
['brackets and dash', 'C:\\repo-[2]\\.git\\orca\\setup-runner.cmd']
|
||||
])('keeps the plain cmd launch for a %s path', (_label, runnerScriptPath) => {
|
||||
expect(buildSetupRunnerCommand(runnerScriptPath, 'windows', { family: 'cmd' })).toBe(
|
||||
`cmd.exe /c "${runnerScriptPath}"`
|
||||
)
|
||||
})
|
||||
|
||||
it('passes the runner path through the environment rather than the cmd argument string', () => {
|
||||
const runnerScriptPath = cmdRunner('\\100%%\\a&b')
|
||||
const script = decodePowerShellCommand(
|
||||
buildSetupRunnerCommand(runnerScriptPath, 'windows', { family: 'cmd' })
|
||||
)
|
||||
|
||||
expect(script).toContain(`$runner = '${runnerScriptPath}'`)
|
||||
expect(script).toContain('$processInfo.EnvironmentVariables["ORCA_SETUP_RUNNER"] = $runner')
|
||||
expect(script).toContain('/d /s /v:on /c ""!ORCA_SETUP_RUNNER!""')
|
||||
// Why: the whole point of the guard is that the hostile path never reaches cmd as syntax.
|
||||
expect(script).not.toContain(`/c ""${runnerScriptPath}""`)
|
||||
expect(script).toContain('$processInfo.UseShellExecute = $false')
|
||||
})
|
||||
|
||||
it('escapes single quotes when embedding the path in the PowerShell literal', () => {
|
||||
const script = decodePowerShellCommand(
|
||||
buildSetupRunnerCommand("C:\\o'brien&co\\.git\\orca\\setup-runner.cmd", 'windows', {
|
||||
family: 'cmd'
|
||||
})
|
||||
)
|
||||
|
||||
expect(script).toContain("$runner = 'C:\\o''brien&co\\.git\\orca\\setup-runner.cmd'")
|
||||
})
|
||||
|
||||
it('leaves runnerScriptPathForShell untouched so marker paths keep the native form', () => {
|
||||
const runnerScriptPath = cmdRunner('\\a&b')
|
||||
|
||||
expect(resolveSetupRunnerCommand(runnerScriptPath, 'windows', { family: 'cmd' })).toMatchObject(
|
||||
{
|
||||
runnerScriptPathForShell: runnerScriptPath,
|
||||
shell: 'windows'
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['native POSIX runner', 'C:\\repo\\a&b\\.git\\orca\\setup-runner.sh', undefined],
|
||||
['WSL UNC runner', '\\\\wsl.localhost\\Ubuntu\\home\\a&b\\orca\\setup-runner.sh', undefined]
|
||||
])('does not disturb the %s launch', (_label, runnerScriptPath) => {
|
||||
expect(buildSetupRunnerCommand(runnerScriptPath, 'windows')).toMatch(/^bash /)
|
||||
})
|
||||
|
||||
it('does not disturb the wsl.exe POSIX launch', () => {
|
||||
expect(
|
||||
buildSetupRunnerCommand('C:\\repo\\a&b\\.git\\orca\\setup-runner.sh', 'windows', {
|
||||
family: 'posix',
|
||||
executable: 'wsl.exe'
|
||||
})
|
||||
).toBe("bash '/mnt/c/repo/a&b/.git/orca/setup-runner.sh'")
|
||||
})
|
||||
})
|
||||
|
||||
describe('nativeWindowsPathToPosixShellPath', () => {
|
||||
it('converts a drive path to the MSYS form Git Bash uses', () => {
|
||||
expect(nativeWindowsPathToPosixShellPath('C:\\Users\\jin\\repo')).toBe('/c/Users/jin/repo')
|
||||
})
|
||||
|
||||
it('is idempotent, so a double-applied conversion cannot corrupt a value', () => {
|
||||
const once = nativeWindowsPathToPosixShellPath('D:\\repo\\worktrees\\feature')
|
||||
expect(nativeWindowsPathToPosixShellPath(once)).toBe(once)
|
||||
})
|
||||
})
|
||||
|
||||
describe('getSetupRunnerCommandPlatformForPath', () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,16 @@
|
|||
import { isWindowsAbsolutePathLike } from './cross-platform-path'
|
||||
import {
|
||||
buildWindowsCmdRunnerDelayedLaunchCommand,
|
||||
windowsRunnerPathNeedsCmdGuard
|
||||
} from './windows-cmd-runner-delayed-launch'
|
||||
|
||||
export type SetupRunnerCommandPlatform = 'windows' | 'posix'
|
||||
export type SetupRunnerShellFamily = 'posix' | 'cmd'
|
||||
export type SetupRunnerCommandShell = 'posix' | 'windows'
|
||||
export type SetupRunnerShell = {
|
||||
family: SetupRunnerShellFamily
|
||||
executable?: string
|
||||
}
|
||||
|
||||
export type SetupRunnerCommandResolution = {
|
||||
command: string
|
||||
|
|
@ -11,9 +20,10 @@ export type SetupRunnerCommandResolution = {
|
|||
|
||||
export function buildSetupRunnerCommand(
|
||||
runnerScriptPath: string,
|
||||
platform: SetupRunnerCommandPlatform
|
||||
platform: SetupRunnerCommandPlatform,
|
||||
shell?: SetupRunnerShell
|
||||
): string {
|
||||
return resolveSetupRunnerCommand(runnerScriptPath, platform).command
|
||||
return resolveSetupRunnerCommand(runnerScriptPath, platform, shell).command
|
||||
}
|
||||
|
||||
export function getSetupRunnerCommandPlatformForPath(
|
||||
|
|
@ -31,7 +41,8 @@ export function getSetupRunnerCommandPlatformForPath(
|
|||
|
||||
export function resolveSetupRunnerCommand(
|
||||
runnerScriptPath: string,
|
||||
platform: SetupRunnerCommandPlatform
|
||||
platform: SetupRunnerCommandPlatform,
|
||||
shell?: SetupRunnerShell
|
||||
): SetupRunnerCommandResolution {
|
||||
if (platform === 'windows') {
|
||||
if (isWslUncPath(runnerScriptPath)) {
|
||||
|
|
@ -49,8 +60,30 @@ export function resolveSetupRunnerCommand(
|
|||
shell: 'posix'
|
||||
}
|
||||
}
|
||||
if (shell?.family === 'posix' || /\.sh$/i.test(runnerScriptPath)) {
|
||||
// Why: WSL shells need /mnt/... paths, while Git Bash expects /c/... when replaying deferred setup scripts.
|
||||
if (isWslExecutable(shell?.executable)) {
|
||||
const wslPath = nativeWindowsPathToWslShellPath(runnerScriptPath)
|
||||
return {
|
||||
command: `bash ${quotePosixArg(wslPath)}`,
|
||||
runnerScriptPathForShell: wslPath,
|
||||
shell: 'posix'
|
||||
}
|
||||
}
|
||||
// Why: queued setup launches can outlive the process that generated them, so convert native paths before handing off to POSIX shells.
|
||||
const posixPath = nativeWindowsPathToPosixShellPath(runnerScriptPath)
|
||||
return {
|
||||
command: `bash ${quotePosixArg(posixPath)}`,
|
||||
runnerScriptPathForShell: posixPath,
|
||||
shell: 'posix'
|
||||
}
|
||||
}
|
||||
return {
|
||||
command: `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`,
|
||||
// Why: some path characters survive no amount of quoting on a cmd command line, so those
|
||||
// paths take a delayed-expansion launcher instead. Every other path keeps the plain form.
|
||||
command: windowsRunnerPathNeedsCmdGuard(runnerScriptPath)
|
||||
? buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath)
|
||||
: `cmd.exe /c ${quoteWindowsArg(runnerScriptPath)}`,
|
||||
runnerScriptPathForShell: runnerScriptPath,
|
||||
shell: 'windows'
|
||||
}
|
||||
|
|
@ -85,3 +118,24 @@ function quotePosixArg(value: string): string {
|
|||
function quoteWindowsArg(value: string): string {
|
||||
return `"${value.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
export function nativeWindowsPathToPosixShellPath(value: string): string {
|
||||
const driveMatch = value.match(/^([A-Za-z]):[\\/](.*)$/)
|
||||
if (driveMatch) {
|
||||
return `/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}`
|
||||
}
|
||||
return value.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function nativeWindowsPathToWslShellPath(value: string): string {
|
||||
const driveMatch = value.match(/^([A-Za-z]):[\\/](.*)$/)
|
||||
if (driveMatch) {
|
||||
return `/mnt/${driveMatch[1].toLowerCase()}/${driveMatch[2].replace(/\\/g, '/')}`
|
||||
}
|
||||
return value.replace(/\\/g, '/')
|
||||
}
|
||||
|
||||
function isWslExecutable(value: string | undefined): boolean {
|
||||
const basename = value?.trim().replaceAll('\\', '/').split('/').pop()?.toLowerCase() ?? ''
|
||||
return basename === 'wsl.exe' || basename === 'wsl'
|
||||
}
|
||||
|
|
|
|||
|
|
@ -51,6 +51,7 @@ import type { StatusBarUsageMode } from './status-bar-usage-mode'
|
|||
import type { PersistedNativeChatSessionOptions } from './native-chat-session-options'
|
||||
import type { CodexResetCreditAttemptLedger } from './codex-reset-credit-attempt-ledger'
|
||||
import type { TaskSourceContext } from './task-source-context'
|
||||
import type { SetupRunnerShell } from './setup-runner-command'
|
||||
|
||||
// Re-exported for backward compat with renderer call sites that import
|
||||
// `WorkspaceCreateTelemetrySource` from '../../../shared/types'.
|
||||
|
|
@ -2166,6 +2167,7 @@ export type RepoHookSettings = {
|
|||
export type WorktreeSetupLaunch = {
|
||||
runnerScriptPath: string
|
||||
envVars: Record<string, string>
|
||||
shell?: SetupRunnerShell
|
||||
command?: string
|
||||
waitForAgentStartup?: boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,42 @@
|
|||
import { encodePowerShellCommand } from './powershell-command-encoding'
|
||||
|
||||
// Why: `cmd.exe /c "<path>"` is typed into the terminal's shell, so the path is parsed twice.
|
||||
// cmd expands %VAR% even inside quotes (no escape exists on the command line), and PowerShell
|
||||
// only re-quotes a native-command argument that contains whitespace — so a space-free path
|
||||
// carrying any of these reaches cmd unquoted, or is rewritten by PowerShell's own expandable
|
||||
// -string rules ($ interpolation, ` escapes) before cmd ever sees it.
|
||||
const WINDOWS_RUNNER_PATH_CMD_GUARD_PATTERN = /[%&|<>^()!,;=$`]/
|
||||
|
||||
export function windowsRunnerPathNeedsCmdGuard(runnerScriptPath: string): boolean {
|
||||
return WINDOWS_RUNNER_PATH_CMD_GUARD_PATTERN.test(runnerScriptPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launches a native Windows runner script whose path cannot be quoted safely on a
|
||||
* `cmd.exe /c` command line. The path travels as an environment variable and is
|
||||
* substituted by delayed expansion, which cmd does not re-scan for metacharacters.
|
||||
*/
|
||||
export function buildWindowsCmdRunnerDelayedLaunchCommand(runnerScriptPath: string): string {
|
||||
const script = [
|
||||
`$runner = ${quotePowerShellString(runnerScriptPath)}`,
|
||||
// Why: an empty value would silently degrade to `cmd /c ""`, which exits 0 without running setup.
|
||||
'if ([string]::IsNullOrEmpty($runner)) { exit 1 }',
|
||||
'$processInfo = [System.Diagnostics.ProcessStartInfo]::new()',
|
||||
'$processInfo.FileName = $env:ComSpec',
|
||||
"if (-not $processInfo.FileName) { $processInfo.FileName = 'cmd.exe' }",
|
||||
// Why: /s strips exactly the outer quote pair, leaving "!ORCA_SETUP_RUNNER!" for /v:on to substitute verbatim.
|
||||
'$processInfo.Arguments = \'/d /s /v:on /c ""!ORCA_SETUP_RUNNER!""\'',
|
||||
// Why: no redirection means stdio is inherited, so setup output still reaches the ConPTY.
|
||||
'$processInfo.UseShellExecute = $false',
|
||||
'$processInfo.EnvironmentVariables["ORCA_SETUP_RUNNER"] = $runner',
|
||||
'$process = [System.Diagnostics.Process]::Start($processInfo)',
|
||||
'$process.WaitForExit()',
|
||||
'exit $process.ExitCode'
|
||||
].join('; ')
|
||||
|
||||
return `powershell.exe -NoProfile -NonInteractive -ExecutionPolicy Bypass -EncodedCommand ${encodePowerShellCommand(script)}`
|
||||
}
|
||||
|
||||
function quotePowerShellString(value: string): string {
|
||||
return `'${value.replace(/'/g, "''")}'`
|
||||
}
|
||||
|
|
@ -26,4 +26,10 @@ describe('resolveWindowsShellStartupFamily', () => {
|
|||
expect(resolveWindowsShellStartupFamily('wsl.exe')).toBe('posix')
|
||||
expect(resolveWindowsShellStartupFamily('C:\\Program Files\\Git\\bin\\bash.exe')).toBe('posix')
|
||||
})
|
||||
|
||||
it('maps extension-less bash and wsl entries to POSIX quoting', () => {
|
||||
expect(resolveWindowsShellStartupFamily('bash')).toBe('posix')
|
||||
expect(resolveWindowsShellStartupFamily('wsl')).toBe('posix')
|
||||
expect(resolveWindowsShellStartupFamily('C:\\Program Files\\Git\\bin\\bash')).toBe('posix')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -30,7 +30,13 @@ export function resolveWindowsShellStartupFamily(
|
|||
}
|
||||
// Why: wsl.exe and bash.exe (Git for Windows) launch POSIX shells, so queued
|
||||
// commands must use POSIX quoting and `cd '<cwd>'` rather than cmd/PowerShell.
|
||||
if (basename === 'wsl.exe' || basename === 'wsl' || basename === 'bash.exe') {
|
||||
// Extension-less forms reach the same executables through PATHEXT.
|
||||
if (
|
||||
basename === 'wsl.exe' ||
|
||||
basename === 'wsl' ||
|
||||
basename === 'bash.exe' ||
|
||||
basename === 'bash'
|
||||
) {
|
||||
return 'posix'
|
||||
}
|
||||
return 'powershell'
|
||||
|
|
|
|||
Loading…
Reference in New Issue