mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 16:03:47 +00:00
* fix(worktrees): stop silently switching existing Windows setup scripts to Git Bash #6967 derived the Windows setup-runner shell from `terminalWindowsShell`. On upgrade, any Windows user whose terminal preference resolved to Git Bash had their existing `orca.yaml` setup script (and issue command) handed to bash instead of cmd.exe. Scripts authored against the cmd runner — `copy`, `xcopy`, `set VAR=value`, `if errorlevel 1`, `%VAR%`, backslash paths — broke with no migration and no warning, and the failure looked like Orca broke the project. The conflation is also wrong in the steady state: a terminal preference is per-user, so two people on the same repo got different interpreters for the same orca.yaml and no project could write a setup script that worked for all of its Windows contributors. The interpreter is now a property of the script, declared the standard way: a leading `#!` line. Native Windows keeps the historical `.cmd` runner unless the script declares a POSIX shell, so no existing script changes behavior. `resolveSetupRunnerShell` keeps its role as the feasibility gate — a bash runner still requires the terminal to resolve to Git Bash, since the launch command is typed into that shell and uses MSYS `/c/...` paths. `buildWindowsRunnerScript` now drops a leading `#!` line rather than `call`ing it, so a declared-bash script that falls back to cmd (Git Bash missing) fails on a real setup line instead of aborting on errorlevel at line one. WSL worktrees, POSIX platforms, and SSH hosts are untouched. * fix(worktrees): keep the cmd setup runner launchable from a Git Bash pane Adversarial review of this PR found that pinning the runner format per script reopened issue #6896 one layer down. - `WorktreeSetupLaunch.shell` had been redefined to mean "the format the runner file was written in". `resolveSetupRunnerCommand` consumes it as "the shell that types the launch command", so a Git Bash terminal with a batch setup script produced `cmd.exe /c "C:\...\setup-runner.cmd"` typed into a bash pane, where MSYS rewrites the `/c` switch into a drive path: cmd opens interactively and setup never runs. `shell` is the terminal's family again; the runner file's .cmd/.sh extension carries the format, and a batch runner launched from a POSIX pane reuses the existing PowerShell ProcessStartInfo launcher. - The cmd runner dropped a leading `#!` line and ran the rest as batch, so a bash script reaching cmd (PowerShell/cmd terminal, or any SSH-to-Windows host) got its interpreter-agnostic prefix executed before failing mid-way. It now prints why and exits 1 without running anything. - A `#!` line's option flags were discarded: `#!/usr/bin/env -S bash -euo pipefail` lost pipefail because the runner is launched as `bash <path>`. The generated posix runner now replays declared flags via `set` and drops the duplicate interpreter line. - Docs cover the per-user setup command in repository hook settings, which goes through the same `#!` rule, and describe what the `#!` line does and does not select. Tests: composed launch command for a POSIX pane + cmd runner (hooks, shared runner command, setup sequencing gate, observed-setup signal), the cmd runner's shebang refusal, and shebang flag replay. Each fails with the source reverted. * fix(worktrees): replay only real `set` flags and keep the gate in the pane's shell Two round-2 review findings: - `#!/bin/bash -l` replayed `set -l`, which exits 2 and aborted the runner under its own `set -e` before a single setup line ran (all platforms). Only the flags `set` documents are replayed now; a bare `-o` with no option name is dropped instead of dumping the shell-option table. - The wait-for-setup gate picked its language from the runner file, so a batch runner launched from a Git Bash pane got the PowerShell gate while the agent startup command was already POSIX-quoted — `Invoke-Expression` cannot parse `'\''`. The gate now follows the pane; the runner still launches through the ProcessStartInfo launcher, never through bash. --------- Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
1820 lines
60 KiB
TypeScript
1820 lines
60 KiB
TypeScript
/* eslint-disable max-lines -- Why: hook parsing, shell selection, and execution-path regressions are tightly coupled, so these cases stay in one file to preserve the behavior matrix across platforms. */
|
|
import type { Repo } from '../shared/types'
|
|
import type * as GitRunner from './git/runner'
|
|
|
|
import { join } from 'node:path'
|
|
import { describe, expect, it, vi } from 'vitest'
|
|
import { getDefaultTabsLaunch, parseOrcaYaml } from './hooks'
|
|
|
|
// Mock fs and path used by loadHooks
|
|
vi.mock('fs', () => ({
|
|
readFileSync: vi.fn(),
|
|
existsSync: vi.fn(),
|
|
mkdirSync: vi.fn(),
|
|
writeFileSync: vi.fn(),
|
|
rmSync: vi.fn(),
|
|
chmodSync: vi.fn()
|
|
}))
|
|
|
|
const { execMock, execFileMock, gitExecFileSyncMock } = vi.hoisted(() => ({
|
|
execMock: vi.fn(),
|
|
execFileMock: vi.fn(),
|
|
gitExecFileSyncMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('child_process', () => ({
|
|
exec: execMock,
|
|
execFile: execFileMock,
|
|
execFileSync: vi.fn(),
|
|
// runner.ts imports spawn from child_process transitively.
|
|
spawn: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./git/runner', async () => ({
|
|
...(await vi.importActual<typeof GitRunner>('./git/runner')),
|
|
gitExecFileSync: gitExecFileSyncMock
|
|
}))
|
|
|
|
const TEST_REPO_PATH = join('/test/repo')
|
|
const TEST_WORKTREE_PATH = join('/test/worktree')
|
|
const TEST_REPO_ORCA_YAML_PATH = join(TEST_REPO_PATH, 'orca.yaml')
|
|
const TEST_WORKTREE_ORCA_YAML_PATH = join(TEST_WORKTREE_PATH, 'orca.yaml')
|
|
const TEST_ISSUE_COMMAND_PATH = join(TEST_REPO_PATH, '.orca', 'issue-command')
|
|
const TEST_GITIGNORE_PATH = join(TEST_REPO_PATH, '.gitignore')
|
|
|
|
describe('parseOrcaYaml', () => {
|
|
it('parses YAML with setup script only', () => {
|
|
const yaml = `scripts:\n setup: |\n echo "setting up"\n npm install\n`
|
|
const result = parseOrcaYaml(yaml)
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "setting up"\nnpm install'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('parses YAML with archive script only', () => {
|
|
const yaml = `scripts:\n archive: |\n echo "archiving"\n`
|
|
const result = parseOrcaYaml(yaml)
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
archive: 'echo "archiving"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('parses YAML with both setup and archive', () => {
|
|
const yaml = [
|
|
'scripts:',
|
|
' setup: |',
|
|
' echo "setup"',
|
|
' npm install',
|
|
' archive: |',
|
|
' echo "archive"',
|
|
' rm -rf node_modules'
|
|
].join('\n')
|
|
const result = parseOrcaYaml(yaml)
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "setup"\nnpm install',
|
|
archive: 'echo "archive"\nrm -rf node_modules'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('returns null when there is no scripts block', () => {
|
|
const yaml = `other:\n key: value\n`
|
|
expect(parseOrcaYaml(yaml)).toBeNull()
|
|
})
|
|
|
|
it('parses YAML with inline scalar scripts', () => {
|
|
const yaml = `scripts:\n setup: npm install\n archive: sleep 5\n`
|
|
const result = parseOrcaYaml(yaml)
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'npm install',
|
|
archive: 'sleep 5'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('returns null when scripts block has no setup or archive', () => {
|
|
const yaml = `scripts:\n unknown: |\n echo "nope"\n`
|
|
expect(parseOrcaYaml(yaml)).toBeNull()
|
|
})
|
|
|
|
it('handles multiline block scalar scripts', () => {
|
|
const yaml = ['scripts:', ' setup: |', ' line1', ' line2', ' line3'].join('\n')
|
|
const result = parseOrcaYaml(yaml)
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'line1\nline2\nline3'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('stops parsing when it hits another top-level key', () => {
|
|
const yaml = ['scripts:', ' setup: |', ' echo "setup"', 'other:', ' key: value'].join('\n')
|
|
const result = parseOrcaYaml(yaml)
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "setup"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('returns null for empty string', () => {
|
|
expect(parseOrcaYaml('')).toBeNull()
|
|
})
|
|
|
|
it('parses a top-level issueCommand block scalar', () => {
|
|
const yaml = [
|
|
'issueCommand: |',
|
|
' claude -p "Read issue #{{issue}}"',
|
|
' codex exec "Review docs/design-{{issue}}.md"'
|
|
].join('\n')
|
|
const result = parseOrcaYaml(yaml)
|
|
expect(result).toEqual({
|
|
scripts: {},
|
|
issueCommand:
|
|
'claude -p "Read issue #{{issue}}"\ncodex exec "Review docs/design-{{issue}}.md"'
|
|
})
|
|
})
|
|
|
|
it('parses issueCommand alongside scripts', () => {
|
|
const yaml = [
|
|
'scripts:',
|
|
' setup: |',
|
|
' pnpm install',
|
|
'issueCommand: |',
|
|
' claude -p "Read issue #{{issue}}"'
|
|
].join('\n')
|
|
const result = parseOrcaYaml(yaml)
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'pnpm install'
|
|
},
|
|
issueCommand: 'claude -p "Read issue #{{issue}}"'
|
|
})
|
|
})
|
|
|
|
it('parses default terminal tabs from orca.yaml', () => {
|
|
const yaml = [
|
|
'defaultTabs:',
|
|
' - title: Claude',
|
|
' color: "#f97316"',
|
|
' command: claude',
|
|
' - title: LocalHost',
|
|
' color: "#9ca3af"',
|
|
' command: pnpm dev',
|
|
' - title: Notes'
|
|
].join('\n')
|
|
|
|
expect(parseOrcaYaml(yaml)).toEqual({
|
|
scripts: {},
|
|
defaultTabs: [
|
|
{ title: 'Claude', color: '#f97316', command: 'claude' },
|
|
{ title: 'LocalHost', color: '#9ca3af', command: 'pnpm dev' },
|
|
{ title: 'Notes' }
|
|
]
|
|
})
|
|
})
|
|
|
|
it('drops invalid default tab entries and unsafe color values', () => {
|
|
const yaml = [
|
|
'defaultTabs:',
|
|
' - title: Server',
|
|
' color: "red"',
|
|
' command: pnpm dev',
|
|
' - 42',
|
|
' - title: ""'
|
|
].join('\n')
|
|
|
|
expect(parseOrcaYaml(yaml)).toEqual({
|
|
scripts: {},
|
|
defaultTabs: [{ title: 'Server', command: 'pnpm dev' }]
|
|
})
|
|
})
|
|
|
|
it('parses environmentRecipes from orca.yaml', () => {
|
|
const yaml = [
|
|
'environmentRecipes:',
|
|
' - id: cloud-sandbox',
|
|
' name: Cloud Sandbox',
|
|
' description: Starts a per-workspace VM.',
|
|
' create: ./scripts/orca-vm/start-cloud-sandbox.sh',
|
|
' suspend: ./scripts/orca-vm/suspend-cloud-sandbox.sh',
|
|
' resume: ./scripts/orca-vm/resume-cloud-sandbox.sh',
|
|
' destroy: ./scripts/orca-vm/destroy-cloud-sandbox.sh'
|
|
].join('\n')
|
|
|
|
expect(parseOrcaYaml(yaml)).toEqual({
|
|
scripts: {},
|
|
environmentRecipes: [
|
|
{
|
|
id: 'cloud-sandbox',
|
|
name: 'Cloud Sandbox',
|
|
description: 'Starts a per-workspace VM.',
|
|
create: './scripts/orca-vm/start-cloud-sandbox.sh',
|
|
suspend: './scripts/orca-vm/suspend-cloud-sandbox.sh',
|
|
resume: './scripts/orca-vm/resume-cloud-sandbox.sh',
|
|
destroy: './scripts/orca-vm/destroy-cloud-sandbox.sh'
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
it('parses legacy environmentRecipes command and cleanup aliases', () => {
|
|
const yaml = [
|
|
'environmentRecipes:',
|
|
' - id: manual-sandbox',
|
|
' name: Manual Sandbox',
|
|
' command: ./scripts/orca-vm/start-manual-sandbox.sh',
|
|
' cleanup: none'
|
|
].join('\n')
|
|
|
|
expect(parseOrcaYaml(yaml)).toEqual({
|
|
scripts: {},
|
|
environmentRecipes: [
|
|
{
|
|
id: 'manual-sandbox',
|
|
name: 'Manual Sandbox',
|
|
create: './scripts/orca-vm/start-manual-sandbox.sh',
|
|
destroyDisabled: true
|
|
}
|
|
]
|
|
})
|
|
})
|
|
|
|
it('drops invalid and duplicate environmentRecipes', () => {
|
|
const yaml = [
|
|
'environmentRecipes:',
|
|
' - id: cloud-sandbox',
|
|
' name: Cloud Sandbox',
|
|
' create: ./scripts/orca-vm/start-cloud-sandbox.sh',
|
|
' - id: cloud-sandbox',
|
|
' name: Duplicate Cloud Sandbox',
|
|
' create: ./scripts/orca-vm/start-duplicate.sh',
|
|
' - id: missing-create',
|
|
' name: Missing Create',
|
|
' - name: Missing Id',
|
|
' create: ./scripts/orca-vm/start-missing-id.sh',
|
|
' - id: "Cloud Sandbox"',
|
|
' name: Unsafe Id',
|
|
' create: ./scripts/orca-vm/start-unsafe-id.sh',
|
|
' - 42'
|
|
].join('\n')
|
|
|
|
expect(parseOrcaYaml(yaml)).toEqual({
|
|
scripts: {},
|
|
environmentRecipes: [
|
|
{
|
|
id: 'cloud-sandbox',
|
|
name: 'Cloud Sandbox',
|
|
create: './scripts/orca-vm/start-cloud-sandbox.sh'
|
|
}
|
|
],
|
|
environmentRecipeDiagnostics: [
|
|
{
|
|
index: 1,
|
|
field: 'id',
|
|
message: 'Duplicate recipe id "cloud-sandbox". Recipe ids must be unique.'
|
|
},
|
|
{ index: 2, field: 'create', message: 'Recipe "missing-create" is missing create.' },
|
|
{ index: 3, field: 'id', message: 'Recipe id is required.' },
|
|
{
|
|
index: 4,
|
|
field: 'id',
|
|
message:
|
|
'Invalid recipe id "Cloud Sandbox". Use 1-64 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter or number.'
|
|
},
|
|
{ index: 5, message: 'Recipe entry must be a mapping.' }
|
|
]
|
|
})
|
|
})
|
|
|
|
it('parses worktree.sharedDirectories from orca.yaml', () => {
|
|
const result = parseOrcaYaml(
|
|
['worktree:', ' sharedDirectories:', ' - node_modules', ' - .cache'].join('\n')
|
|
)
|
|
|
|
expect(result?.worktree?.sharedDirectories).toEqual(['node_modules', '.cache'])
|
|
})
|
|
|
|
it('normalizes and dedupes sharedDirectories entries', () => {
|
|
const result = parseOrcaYaml(
|
|
[
|
|
'worktree:',
|
|
' sharedDirectories:',
|
|
' - node_modules/',
|
|
' - ./node_modules',
|
|
' - " .cache "'
|
|
].join('\n')
|
|
)
|
|
|
|
expect(result?.worktree?.sharedDirectories).toEqual(['node_modules', '.cache'])
|
|
})
|
|
|
|
it('drops unsafe sharedDirectories entries', () => {
|
|
const result = parseOrcaYaml(
|
|
[
|
|
'worktree:',
|
|
' sharedDirectories:',
|
|
' - ../escape',
|
|
' - /etc',
|
|
' - .git',
|
|
' - .git/hooks',
|
|
' - cache/.git/hooks',
|
|
' - node_modules'
|
|
].join('\n')
|
|
)
|
|
|
|
expect(result?.worktree?.sharedDirectories).toEqual(['node_modules'])
|
|
})
|
|
|
|
// Why: `resolve()` collapses `.` when the link is created, but Git reports the
|
|
// collapsed path — keeping the raw entry would leave a link that every later
|
|
// comparison misses, which is the permanently-dirty worktree this feature fixes.
|
|
it('drops sharedDirectories entries that still need path collapsing', () => {
|
|
const result = parseOrcaYaml(
|
|
[
|
|
'worktree:',
|
|
' sharedDirectories:',
|
|
' - apps/./web/node_modules',
|
|
' - apps//web/.cache',
|
|
' - node_modules'
|
|
].join('\n')
|
|
)
|
|
|
|
expect(result?.worktree?.sharedDirectories).toEqual(['node_modules'])
|
|
})
|
|
|
|
it('returns null when sharedDirectories is the only key and holds nothing usable', () => {
|
|
expect(parseOrcaYaml('worktree:\n sharedDirectories: []\n')).toBeNull()
|
|
expect(parseOrcaYaml('worktree:\n sharedDirectories: node_modules\n')).toBeNull()
|
|
})
|
|
|
|
it('keeps sharedDirectories alongside other orca.yaml keys', () => {
|
|
const result = parseOrcaYaml(
|
|
[
|
|
'scripts:',
|
|
' setup: pnpm install',
|
|
'worktree:',
|
|
' sharedDirectories:',
|
|
' - .cache'
|
|
].join('\n')
|
|
)
|
|
|
|
expect(result?.scripts.setup).toBe('pnpm install')
|
|
expect(result?.worktree?.sharedDirectories).toEqual(['.cache'])
|
|
})
|
|
})
|
|
|
|
describe('hasUnrecognizedOrcaYamlKeys', () => {
|
|
it('returns true when the file contains only keys this version does not handle', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: |\n some config\n')
|
|
|
|
const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks')
|
|
expect(hasUnrecognizedOrcaYamlKeys('/test/repo')).toBe(true)
|
|
})
|
|
|
|
it('returns true when an unknown key has no trailing space (block-value form)', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.readFileSync).mockReturnValue('futureFeature:\n nested: value\n')
|
|
|
|
const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks')
|
|
expect(hasUnrecognizedOrcaYamlKeys('/test/repo')).toBe(true)
|
|
})
|
|
|
|
it('returns true when the file mixes recognised and unrecognised keys', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'scripts:\n setup: |\n pnpm install\nnewFeature: enabled\n'
|
|
)
|
|
|
|
const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks')
|
|
expect(hasUnrecognizedOrcaYamlKeys('/test/repo')).toBe(true)
|
|
})
|
|
|
|
it('returns false when the file contains only recognised keys', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
[
|
|
'scripts:',
|
|
' setup: |',
|
|
' pnpm install',
|
|
'issueCommand: |',
|
|
' claude -p "test"',
|
|
'defaultTabs:',
|
|
' - title: Claude',
|
|
'environmentRecipes:',
|
|
' - id: cloud-sandbox',
|
|
' name: Cloud Sandbox',
|
|
' create: ./scripts/orca-vm/start-cloud-sandbox.sh',
|
|
'worktree:',
|
|
' sharedDirectories:',
|
|
' - node_modules'
|
|
].join('\n')
|
|
)
|
|
|
|
const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks')
|
|
expect(hasUnrecognizedOrcaYamlKeys('/test/repo')).toBe(false)
|
|
})
|
|
|
|
it('returns false when the file is empty or has no top-level keys', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.readFileSync).mockReturnValue('# just a comment\n')
|
|
|
|
const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks')
|
|
expect(hasUnrecognizedOrcaYamlKeys('/test/repo')).toBe(false)
|
|
})
|
|
|
|
it('returns false when the file cannot be read', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.readFileSync).mockImplementation(() => {
|
|
throw new Error('ENOENT')
|
|
})
|
|
|
|
const { hasUnrecognizedOrcaYamlKeys } = await import('./hooks')
|
|
expect(hasUnrecognizedOrcaYamlKeys('/test/repo')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('readIssueCommand', () => {
|
|
it('prefers the local override over the shared orca.yaml command', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockImplementation(
|
|
(path) => path === TEST_ISSUE_COMMAND_PATH || path === TEST_REPO_ORCA_YAML_PATH
|
|
)
|
|
vi.mocked(fs.readFileSync).mockImplementation((path) => {
|
|
if (path === TEST_ISSUE_COMMAND_PATH) {
|
|
return 'local command\n'
|
|
}
|
|
if (path === TEST_REPO_ORCA_YAML_PATH) {
|
|
return 'issueCommand: |\n shared command\n'
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const { readIssueCommand } = await import('./hooks')
|
|
expect(readIssueCommand(TEST_REPO_PATH)).toEqual({
|
|
localContent: 'local command',
|
|
sharedContent: 'shared command',
|
|
effectiveContent: 'local command',
|
|
localFilePath: TEST_ISSUE_COMMAND_PATH,
|
|
source: 'local'
|
|
})
|
|
})
|
|
|
|
it('falls back to the shared orca.yaml command when no local override exists', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockImplementation((path) => path === TEST_REPO_ORCA_YAML_PATH)
|
|
vi.mocked(fs.readFileSync).mockImplementation((path) => {
|
|
if (path === TEST_REPO_ORCA_YAML_PATH) {
|
|
return 'issueCommand: |\n shared command\n'
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const { readIssueCommand } = await import('./hooks')
|
|
expect(readIssueCommand(TEST_REPO_PATH)).toEqual({
|
|
localContent: null,
|
|
sharedContent: 'shared command',
|
|
effectiveContent: 'shared command',
|
|
localFilePath: TEST_ISSUE_COMMAND_PATH,
|
|
source: 'shared'
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('writeIssueCommand', () => {
|
|
it('writes only the local override file and keeps .orca ignored locally', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockImplementation(
|
|
(path) => path === TEST_GITIGNORE_PATH || path === join(TEST_REPO_PATH, '.orca')
|
|
)
|
|
vi.mocked(fs.readFileSync).mockImplementation((path) => {
|
|
if (path === TEST_GITIGNORE_PATH) {
|
|
return 'node_modules/\n'
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const { writeIssueCommand } = await import('./hooks')
|
|
writeIssueCommand(TEST_REPO_PATH, 'local command')
|
|
|
|
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
TEST_GITIGNORE_PATH,
|
|
'node_modules/\n.orca\n',
|
|
'utf-8'
|
|
)
|
|
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
|
|
TEST_ISSUE_COMMAND_PATH,
|
|
'local command\n',
|
|
'utf-8'
|
|
)
|
|
})
|
|
|
|
it('deletes the local override when the override is cleared', async () => {
|
|
const { writeIssueCommand } = await import('./hooks')
|
|
const fs = await import('node:fs')
|
|
writeIssueCommand(TEST_REPO_PATH, ' ')
|
|
|
|
expect(vi.mocked(fs.rmSync)).toHaveBeenCalledWith(TEST_ISSUE_COMMAND_PATH, {
|
|
force: true
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('runner script builders', () => {
|
|
it('builds Windows runners for newline-heavy scripts without line-array splitting', async () => {
|
|
const { buildWindowsRunnerScript } = await import('./hooks')
|
|
const script = `${'\r\n'.repeat(10_000)}pnpm install\r\nnpm run build\n`
|
|
const splitSpy = vi.spyOn(String.prototype, 'split')
|
|
const replaceSpy = vi.spyOn(String.prototype, 'replace')
|
|
|
|
try {
|
|
const result = buildWindowsRunnerScript(script)
|
|
|
|
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(
|
|
([separator]) =>
|
|
(typeof separator === 'string' && separator === '\n') ||
|
|
(separator instanceof RegExp && separator.source === '\\r?\\n')
|
|
)
|
|
const usedNewlineReplace = replaceSpy.mock.calls.some(
|
|
([pattern]) =>
|
|
pattern instanceof RegExp && (pattern.source === '\\r?\\n' || pattern.source === '\\r\\n')
|
|
)
|
|
expect(usedLineSplit).toBe(false)
|
|
expect(usedNewlineReplace).toBe(false)
|
|
} finally {
|
|
splitSpy.mockRestore()
|
|
replaceSpy.mockRestore()
|
|
}
|
|
})
|
|
|
|
it('builds POSIX runners without regex-wide CRLF normalization', async () => {
|
|
const { buildPosixRunnerScript } = await import('./hooks')
|
|
const script = `${'echo setup\r\n'.repeat(10_000)}echo done`
|
|
const replaceSpy = vi.spyOn(String.prototype, 'replace')
|
|
|
|
try {
|
|
const result = buildPosixRunnerScript(script)
|
|
|
|
expect(result.startsWith('#!/usr/bin/env bash\nset -e\necho setup\n')).toBe(true)
|
|
expect(result.endsWith('echo done\n')).toBe(true)
|
|
const usedCrlfReplace = replaceSpy.mock.calls.some(
|
|
([pattern]) => pattern instanceof RegExp && pattern.source === '\\r\\n'
|
|
)
|
|
expect(usedCrlfReplace).toBe(false)
|
|
} finally {
|
|
replaceSpy.mockRestore()
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('getEffectiveHooks', () => {
|
|
// We need to dynamically import after mocking
|
|
const makeRepo = (hookSettings?: {
|
|
mode?: 'auto' | 'override'
|
|
setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default'
|
|
commandSourcePolicy?: 'shared-only' | 'local-only' | 'run-both'
|
|
scripts?: { setup: string; archive: string }
|
|
}) =>
|
|
({
|
|
id: 'test-id',
|
|
path: '/test/repo',
|
|
displayName: 'Test Repo',
|
|
badgeColor: '#000',
|
|
addedAt: Date.now(),
|
|
hookSettings
|
|
}) as unknown as Repo
|
|
|
|
it('uses hooks from orca.yaml when present', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n')
|
|
|
|
// Re-import to pick up mocks
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo()
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "yaml setup"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it("loads setup hooks from the target worktree's orca.yaml when a worktree path is provided", async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockImplementation(
|
|
(path) => path === TEST_REPO_ORCA_YAML_PATH || path === TEST_WORKTREE_ORCA_YAML_PATH
|
|
)
|
|
vi.mocked(fs.readFileSync).mockImplementation((path) => {
|
|
if (path === TEST_REPO_ORCA_YAML_PATH) {
|
|
return 'scripts:\n setup: |\n echo old-version\n'
|
|
}
|
|
if (path === TEST_WORKTREE_ORCA_YAML_PATH) {
|
|
return 'scripts:\n setup: |\n echo new-version\n'
|
|
}
|
|
return ''
|
|
})
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const result = getEffectiveHooks(makeRepo(), TEST_WORKTREE_PATH)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo new-version'
|
|
}
|
|
})
|
|
expect(result?.scripts.setup).not.toContain('old-version')
|
|
})
|
|
|
|
it('falls back to legacy local hooks when policy is unset and yaml is missing', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: 'echo "local setup"', archive: 'echo "local archive"' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "local setup"',
|
|
archive: 'echo "local archive"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('does not fall back to local hooks when policy is explicitly shared-only', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
commandSourcePolicy: 'shared-only',
|
|
scripts: { setup: 'echo "local setup"', archive: 'echo "local archive"' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('uses local settings over shared yaml settings by default when local hooks exist', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n')
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: 'echo "ui override"', archive: '' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "ui override"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('uses only local settings when command source policy is local-only', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n')
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
commandSourcePolicy: 'local-only',
|
|
scripts: { setup: 'echo "local setup"', archive: '' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "local setup"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('runs yaml before local settings when command source policy is run-both', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n')
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
commandSourcePolicy: 'run-both',
|
|
scripts: { setup: 'echo "local setup"', archive: '' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "yaml setup"\necho "local setup"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('uses local settings by default even when orca.yaml defines only one command', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n')
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: 'echo "legacy setup"', archive: 'echo "legacy archive"' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "legacy setup"',
|
|
archive: 'echo "legacy archive"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('keeps shared setup when only archive has a legacy local script', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'scripts:\n setup: |\n echo "yaml setup"\n archive: |\n echo "yaml archive"\n'
|
|
)
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: '', archive: 'echo "legacy archive"' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "yaml setup"',
|
|
archive: 'echo "legacy archive"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('uses local settings by default when yaml exists without supported hooks', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: enabled\n')
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: 'echo "legacy setup"', archive: 'echo "legacy archive"' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
setup: 'echo "legacy setup"',
|
|
archive: 'echo "legacy archive"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('treats legacy shared-first policy as orca.yaml only', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n')
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
commandSourcePolicy: 'shared-first' as never,
|
|
scripts: { setup: 'echo "legacy setup"', archive: 'echo "legacy archive"' }
|
|
})
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toEqual({
|
|
scripts: {
|
|
archive: 'echo "yaml archive"'
|
|
}
|
|
})
|
|
})
|
|
|
|
it('returns null when no hooks at all', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
|
|
const { getEffectiveHooks } = await import('./hooks')
|
|
const repo = makeRepo({ mode: 'auto', scripts: { setup: '', archive: '' } })
|
|
const result = getEffectiveHooks(repo)
|
|
|
|
expect(result).toBeNull()
|
|
})
|
|
|
|
it('falls back to legacy local setup source only when yaml is missing', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(false)
|
|
|
|
const { getSetupCommandSource } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: 'echo "legacy setup"', archive: '' }
|
|
})
|
|
const result = getSetupCommandSource(repo)
|
|
|
|
expect(result).toEqual({ source: 'local', command: 'echo "legacy setup"' })
|
|
})
|
|
|
|
it('uses local setup source by default when yaml omits setup', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n')
|
|
|
|
const { getSetupCommandSource } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: 'echo "legacy setup"', archive: '' }
|
|
})
|
|
const result = getSetupCommandSource(repo)
|
|
|
|
expect(result).toEqual({ source: 'local', command: 'echo "legacy setup"' })
|
|
})
|
|
|
|
it('uses local setup source by default when yaml exists without supported hooks', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('futureFeature: enabled\n')
|
|
|
|
const { getSetupCommandSource } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: 'echo "legacy setup"', archive: '' }
|
|
})
|
|
const result = getSetupCommandSource(repo)
|
|
|
|
expect(result).toEqual({ source: 'local', command: 'echo "legacy setup"' })
|
|
})
|
|
|
|
it('uses shared setup source when only archive has a legacy local script', async () => {
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue(
|
|
'scripts:\n setup: |\n echo "yaml setup"\n archive: |\n echo "yaml archive"\n'
|
|
)
|
|
|
|
const { getSetupCommandSource } = await import('./hooks')
|
|
const repo = makeRepo({
|
|
mode: 'override',
|
|
scripts: { setup: '', archive: 'echo "legacy archive"' }
|
|
})
|
|
const result = getSetupCommandSource(repo)
|
|
|
|
expect(result).toEqual({ source: 'yaml', command: 'echo "yaml setup"' })
|
|
})
|
|
})
|
|
|
|
describe('runHook', () => {
|
|
const makeRepo = (hookSettings?: {
|
|
mode?: 'auto' | 'override'
|
|
setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default'
|
|
scripts?: { setup: string; archive: string }
|
|
}) =>
|
|
({
|
|
id: 'test-id',
|
|
path: '/test/repo',
|
|
displayName: 'Test Repo',
|
|
badgeColor: '#000',
|
|
addedAt: Date.now(),
|
|
hookSettings
|
|
}) as unknown as Repo
|
|
|
|
it('uses the Windows command shell when running hooks', async () => {
|
|
execMock.mockImplementation((_script, _options, callback) => {
|
|
callback?.(null, '', '')
|
|
return {} as never
|
|
})
|
|
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n')
|
|
|
|
const originalPlatform = process.platform
|
|
const originalComSpec = process.env.ComSpec
|
|
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
process.env.ComSpec = 'C:\\Windows\\System32\\cmd.exe'
|
|
|
|
try {
|
|
const { runHook } = await import('./hooks')
|
|
const result = await runHook('setup', 'C:\\repo\\worktree', makeRepo())
|
|
|
|
expect(result).toEqual({ success: true, output: '' })
|
|
expect(execMock).toHaveBeenCalledWith(
|
|
'echo hello',
|
|
expect.objectContaining({
|
|
cwd: 'C:\\repo\\worktree',
|
|
shell: 'C:\\Windows\\System32\\cmd.exe'
|
|
}),
|
|
expect.any(Function)
|
|
)
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: originalPlatform
|
|
})
|
|
if (originalComSpec === undefined) {
|
|
delete process.env.ComSpec
|
|
} else {
|
|
process.env.ComSpec = originalComSpec
|
|
}
|
|
}
|
|
})
|
|
|
|
it('keeps bash as the hook shell on non-Windows platforms', async () => {
|
|
execMock.mockImplementation((_script, _options, callback) => {
|
|
callback?.(null, '', '')
|
|
return {} as never
|
|
})
|
|
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n')
|
|
|
|
const originalPlatform = process.platform
|
|
const originalShell = process.env.SHELL
|
|
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'linux'
|
|
})
|
|
process.env.SHELL = '/opt/homebrew/bin/fish'
|
|
|
|
try {
|
|
const { runHook } = await import('./hooks')
|
|
const result = await runHook('setup', '/repo/worktree', makeRepo())
|
|
|
|
expect(result).toEqual({ success: true, output: '' })
|
|
expect(execMock).toHaveBeenCalledWith(
|
|
'echo hello',
|
|
expect.objectContaining({
|
|
cwd: '/repo/worktree',
|
|
shell: '/bin/bash',
|
|
// Setup hooks run unattended: git in them must not pop the OS
|
|
// credential helper's OAuth window and loop it (issue #7652).
|
|
env: expect.objectContaining({
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
GCM_INTERACTIVE: 'never'
|
|
})
|
|
}),
|
|
expect.any(Function)
|
|
)
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: originalPlatform
|
|
})
|
|
if (originalShell === undefined) {
|
|
delete process.env.SHELL
|
|
} else {
|
|
process.env.SHELL = originalShell
|
|
}
|
|
}
|
|
})
|
|
|
|
it('runs WSL hooks through wsl.exe and translates env paths to Linux', async () => {
|
|
execMock.mockReset()
|
|
execFileMock.mockReset()
|
|
execFileMock.mockImplementation((_file, _args, options, callback) => {
|
|
callback?.(null, '', '')
|
|
expect(options).toEqual(
|
|
expect.objectContaining({
|
|
env: expect.objectContaining({
|
|
ORCA_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
|
|
ORCA_WORKTREE_PATH: '/home/jin/feature',
|
|
CONDUCTOR_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
|
|
GHOSTX_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca'
|
|
})
|
|
})
|
|
)
|
|
return {} as never
|
|
})
|
|
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n')
|
|
|
|
const originalPlatform = process.platform
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
|
|
try {
|
|
const { runHook } = await import('./hooks')
|
|
const result = await runHook('setup', '\\\\wsl.localhost\\Ubuntu\\home\\jin\\feature', {
|
|
...makeRepo(),
|
|
path: 'C:\\Users\\jinwo\\git\\orca'
|
|
})
|
|
|
|
expect(result).toEqual({ success: true, output: '' })
|
|
expect(execFileMock).toHaveBeenCalledWith(
|
|
'wsl.exe',
|
|
['-d', 'Ubuntu', '--', 'bash', '-c', "cd '/home/jin/feature' && echo hello"],
|
|
// #7652 regression: the unattended WSL hook branch must carry the
|
|
// credential guard, and WSLENV is what carries it into the distro.
|
|
expect.objectContaining({
|
|
env: expect.objectContaining({
|
|
GIT_TERMINAL_PROMPT: '0',
|
|
GCM_INTERACTIVE: 'never',
|
|
WSLENV: expect.stringContaining('GIT_TERMINAL_PROMPT')
|
|
})
|
|
}),
|
|
expect.any(Function)
|
|
)
|
|
expect(execMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: originalPlatform
|
|
})
|
|
}
|
|
})
|
|
|
|
it('runs Windows-path hooks through WSL when the project runtime targets WSL', async () => {
|
|
execMock.mockReset()
|
|
execFileMock.mockReset()
|
|
// Why: assert on the captured options after runHook resolves — an expect()
|
|
// thrown inside the mock is swallowed by runHook's own error handling.
|
|
let capturedOptions: unknown
|
|
execFileMock.mockImplementation((_file, _args, options, callback) => {
|
|
capturedOptions = options
|
|
callback?.(null, '', '')
|
|
return {} as never
|
|
})
|
|
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n')
|
|
|
|
const originalPlatform = process.platform
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
// Why: keep the WSLENV assertion hermetic on hosts that export WSLENV.
|
|
const originalWslenv = process.env.WSLENV
|
|
delete process.env.WSLENV
|
|
|
|
try {
|
|
const { runHook } = await import('./hooks')
|
|
const result = await runHook(
|
|
'setup',
|
|
'C:\\Users\\jinwo\\git\\orca-feature',
|
|
{
|
|
...makeRepo(),
|
|
path: 'C:\\Users\\jinwo\\git\\orca'
|
|
},
|
|
undefined,
|
|
{ wslDistro: 'Ubuntu' }
|
|
)
|
|
|
|
expect(result).toEqual({ success: true, output: '' })
|
|
expect(execFileMock).toHaveBeenCalledWith(
|
|
'wsl.exe',
|
|
[
|
|
'-d',
|
|
'Ubuntu',
|
|
'--',
|
|
'bash',
|
|
'-c',
|
|
"cd '/mnt/c/Users/jinwo/git/orca-feature' && echo hello"
|
|
],
|
|
expect.any(Object),
|
|
expect.any(Function)
|
|
)
|
|
expect(capturedOptions).toEqual(
|
|
expect.objectContaining({
|
|
env: expect.objectContaining({
|
|
ORCA_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
|
|
ORCA_WORKTREE_PATH: '/mnt/c/Users/jinwo/git/orca-feature',
|
|
CONDUCTOR_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
|
|
GHOSTX_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
|
|
// Why: wsl.exe only imports Windows env vars named in WSLENV, so
|
|
// setting the vars on the execFile env alone is not enough (#9206).
|
|
// /u because runHook pre-translated the values to Linux paths.
|
|
// stringContaining, not exact: promptGuardShellEnv (#7652) appends
|
|
// its own guard keys (GIT_TERMINAL_PROMPT, …) after these — the
|
|
// setup vars must remain registered alongside them.
|
|
WSLENV: expect.stringContaining(
|
|
'ORCA_ROOT_PATH/u:ORCA_WORKTREE_PATH/u:CONDUCTOR_ROOT_PATH/u:GHOSTX_ROOT_PATH/u:ORCA_WORKSPACE_NAME/u'
|
|
)
|
|
})
|
|
})
|
|
)
|
|
expect(execMock).not.toHaveBeenCalled()
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: originalPlatform
|
|
})
|
|
if (originalWslenv === undefined) {
|
|
delete process.env.WSLENV
|
|
} else {
|
|
process.env.WSLENV = originalWslenv
|
|
}
|
|
}
|
|
})
|
|
|
|
it('writes Windows-path setup runners through WSL git when the project runtime targets WSL', async () => {
|
|
gitExecFileSyncMock.mockReset()
|
|
gitExecFileSyncMock.mockReturnValue('/mnt/c/Users/jinwo/git/orca/.git/orca/setup-runner.sh\n')
|
|
|
|
const fs = await import('node:fs')
|
|
const mkdirSyncMock = vi.mocked(fs.mkdirSync)
|
|
const writeFileSyncMock = vi.mocked(fs.writeFileSync)
|
|
const chmodSyncMock = vi.mocked(fs.chmodSync)
|
|
|
|
const originalPlatform = process.platform
|
|
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:\\Users\\jinwo\\git\\orca-feature',
|
|
'echo hello',
|
|
{ wslDistro: 'Ubuntu' }
|
|
)
|
|
|
|
expect(gitExecFileSyncMock).toHaveBeenCalledWith(
|
|
['rev-parse', '--git-path', 'orca/setup-runner.sh'],
|
|
{
|
|
cwd: 'C:\\Users\\jinwo\\git\\orca-feature',
|
|
wslDistro: 'Ubuntu'
|
|
}
|
|
)
|
|
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'),
|
|
'#!/usr/bin/env bash\nset -e\necho hello\n',
|
|
'utf-8'
|
|
)
|
|
expect(chmodSyncMock).toHaveBeenCalledWith(expect.stringContaining('setup-runner.sh'), 0o755)
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: originalPlatform
|
|
})
|
|
}
|
|
})
|
|
|
|
it('settles WSL hooks when wsl.exe never reports completion', async () => {
|
|
vi.useFakeTimers()
|
|
execMock.mockReset()
|
|
execFileMock.mockReset()
|
|
const killMock = vi.fn()
|
|
execFileMock.mockImplementation(() => ({ kill: killMock }) as never)
|
|
|
|
const fs = await import('node:fs')
|
|
vi.mocked(fs.existsSync).mockReturnValue(true)
|
|
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo hello\n')
|
|
|
|
const originalPlatform = process.platform
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: 'win32'
|
|
})
|
|
|
|
try {
|
|
const { runHook } = await import('./hooks')
|
|
const promise = runHook('setup', '\\\\wsl.localhost\\Ubuntu\\home\\jin\\feature', {
|
|
...makeRepo(),
|
|
path: 'C:\\Users\\jinwo\\git\\orca'
|
|
})
|
|
let settled = false
|
|
void promise.finally(() => {
|
|
settled = true
|
|
})
|
|
|
|
await vi.advanceTimersByTimeAsync(120_000)
|
|
await Promise.resolve()
|
|
|
|
expect(settled).toBe(true)
|
|
await expect(promise).resolves.toMatchObject({
|
|
success: false,
|
|
output: expect.stringContaining('Hook timed out')
|
|
})
|
|
expect(killMock).toHaveBeenCalled()
|
|
} finally {
|
|
vi.useRealTimers()
|
|
Object.defineProperty(process, 'platform', {
|
|
configurable: true,
|
|
value: originalPlatform
|
|
})
|
|
}
|
|
})
|
|
})
|
|
|
|
describe('createSetupRunnerScript', () => {
|
|
const makeRepo = (setupAgentStartupPolicy?: 'start-immediately' | 'wait-for-setup') =>
|
|
({
|
|
id: 'test-id',
|
|
path: '/test/repo',
|
|
displayName: 'Test Repo',
|
|
badgeColor: '#000',
|
|
addedAt: Date.now(),
|
|
hookSettings: {
|
|
mode: 'auto',
|
|
setupAgentStartupPolicy,
|
|
scripts: { setup: '', archive: '' }
|
|
}
|
|
}) as unknown as Repo
|
|
|
|
it('writes POSIX setup runners for shebang-declared scripts 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',
|
|
'#!/usr/bin/env bash\r\npnpm 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('keeps batch setup scripts on cmd.exe when the terminal is Git Bash', async () => {
|
|
// Regression (#6967): a Git Bash terminal preference used to hand pre-existing
|
|
// batch setup scripts to bash, where `copy`/`xcopy`/`if errorlevel` do not exist.
|
|
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',
|
|
'copy .env.example .env\r\nxcopy /E assets dist',
|
|
undefined,
|
|
{ family: 'posix' }
|
|
)
|
|
|
|
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 copy .env.example .env\r\n'),
|
|
'utf-8'
|
|
)
|
|
expect(result).toMatchObject({
|
|
runnerScriptPath: 'C:\\repo\\.git\\orca\\setup-runner.cmd',
|
|
// Why: `shell` is the pane that types the launch command — still Git Bash here. The
|
|
// runner's own .cmd extension is what says the file is batch.
|
|
shell: { family: 'posix' }
|
|
})
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
|
}
|
|
})
|
|
|
|
it('hands a Git Bash pane a launch command MSYS cannot rewrite for a cmd runner', async () => {
|
|
// Regression (#6896): `cmd.exe /c "C:\...\setup-runner.cmd"` typed into Git Bash has its
|
|
// `/c` switch rewritten into a drive path, so cmd opens interactively and setup never runs.
|
|
gitExecFileSyncMock.mockReset()
|
|
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-runner.cmd\n')
|
|
const originalPlatform = process.platform
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
|
|
|
try {
|
|
const { createSetupRunnerScript } = await import('./hooks')
|
|
const { buildSetupRunnerCommand } = await import('../shared/setup-runner-command')
|
|
const result = createSetupRunnerScript(
|
|
makeRepo(),
|
|
'C:\\repo-worktree',
|
|
'copy .env.example .env',
|
|
undefined,
|
|
{ family: 'posix' }
|
|
)
|
|
|
|
const command = buildSetupRunnerCommand(result.runnerScriptPath, 'windows', result.shell)
|
|
|
|
expect(command).not.toContain('cmd.exe /c')
|
|
// Why: the batch runner must never be handed to bash either.
|
|
expect(command).not.toContain('bash ')
|
|
expect(command).toContain('powershell.exe')
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { configurable: true, value: originalPlatform })
|
|
}
|
|
})
|
|
|
|
it('replays interpreter flags declared on the shebang line', async () => {
|
|
// Regression: the runner is launched as `bash <path>`, so `-euo pipefail` on the script's
|
|
// own `#!` line never reaches the interpreter unless the runner re-applies it.
|
|
gitExecFileSyncMock.mockReset()
|
|
gitExecFileSyncMock.mockReturnValue('C:\\repo\\.git\\orca\\setup-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 { createSetupRunnerScript } = await import('./hooks')
|
|
createSetupRunnerScript(
|
|
makeRepo(),
|
|
'C:\\repo-worktree',
|
|
'#!/usr/bin/env -S bash -euo pipefail\nmake build | tee build.log',
|
|
undefined,
|
|
{ family: 'posix' }
|
|
)
|
|
|
|
expect(writeFileSyncMock).toHaveBeenCalledWith(
|
|
'C:\\repo\\.git\\orca\\setup-runner.sh',
|
|
'#!/usr/bin/env bash\nset -e\nset -euo pipefail\nmake build | tee build.log\n',
|
|
'utf-8'
|
|
)
|
|
} 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')
|
|
const { createSetupRunnerScript } = await import('./hooks')
|
|
|
|
expect(
|
|
createSetupRunnerScript(makeRepo(), '/test/worktree', 'echo setup').waitForAgentStartup
|
|
).toBeUndefined()
|
|
expect(
|
|
createSetupRunnerScript(makeRepo('start-immediately'), '/test/worktree', 'echo setup')
|
|
.waitForAgentStartup
|
|
).toBeUndefined()
|
|
expect(
|
|
createSetupRunnerScript(makeRepo('wait-for-setup'), '/test/worktree', 'echo setup')
|
|
.waitForAgentStartup
|
|
).toBe(true)
|
|
})
|
|
|
|
it('marks setup-runner terminals for the always-on credential guard', async () => {
|
|
gitExecFileSyncMock.mockReset()
|
|
gitExecFileSyncMock.mockReturnValue('/test/repo/.git/orca/setup-runner.sh\n')
|
|
const { createSetupRunnerScript } = await import('./hooks')
|
|
|
|
const setup = createSetupRunnerScript(makeRepo(), '/test/worktree', 'git fetch')
|
|
|
|
expect(setup.envVars).toMatchObject({
|
|
ORCA_ROOT_PATH: '/test/repo',
|
|
ORCA_WORKTREE_PATH: '/test/worktree',
|
|
ORCA_INTERNAL_TERMINAL_GIT_CREDENTIAL_GUARD_POLICY: 'guard'
|
|
})
|
|
})
|
|
})
|
|
|
|
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 a shebang declares bash and 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',
|
|
'#!/usr/bin/env bash\ngh 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 a plain issue command on the cmd runner under a Git Bash terminal', 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',
|
|
undefined,
|
|
{ family: 'posix' }
|
|
)
|
|
|
|
expect(gitExecFileSyncMock).toHaveBeenCalledWith(
|
|
['rev-parse', '--git-path', 'orca/issue-command-runner.cmd'],
|
|
{ cwd: 'C:\\repo-worktree' }
|
|
)
|
|
// Why: the runner file is batch (.cmd) while the pane that launches it is still Git Bash.
|
|
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') =>
|
|
({
|
|
id: 'test-id',
|
|
path: '/test/repo',
|
|
displayName: 'Test Repo',
|
|
badgeColor: '#000',
|
|
addedAt: Date.now(),
|
|
hookSettings: {
|
|
mode: 'auto',
|
|
setupRunPolicy,
|
|
scripts: { setup: '', archive: '' }
|
|
}
|
|
}) as unknown as Repo
|
|
|
|
it('requires an explicit decision when the repo policy is ask', async () => {
|
|
const { shouldRunSetupForCreate } = await import('./hooks')
|
|
|
|
expect(() => shouldRunSetupForCreate(makeRepo('ask'))).toThrow(
|
|
'Setup decision required for this repository'
|
|
)
|
|
})
|
|
|
|
it('uses the repo default when the caller inherits', async () => {
|
|
const { shouldRunSetupForCreate } = await import('./hooks')
|
|
|
|
expect(shouldRunSetupForCreate(makeRepo('run-by-default'))).toBe(true)
|
|
expect(shouldRunSetupForCreate(makeRepo('skip-by-default'))).toBe(false)
|
|
})
|
|
|
|
it('lets the caller override the repo default per create', async () => {
|
|
const { shouldRunSetupForCreate } = await import('./hooks')
|
|
|
|
expect(shouldRunSetupForCreate(makeRepo('skip-by-default'), 'run')).toBe(true)
|
|
expect(shouldRunSetupForCreate(makeRepo('run-by-default'), 'skip')).toBe(false)
|
|
})
|
|
})
|
|
|
|
describe('getDefaultTabsLaunch', () => {
|
|
const makeRepo = (
|
|
setupRunPolicy?: 'ask' | 'run-by-default' | 'skip-by-default',
|
|
commandSourcePolicy?: 'local-only' | 'run-both' | 'shared-only'
|
|
) =>
|
|
({
|
|
id: 'test-id',
|
|
path: '/test/repo',
|
|
displayName: 'Test Repo',
|
|
badgeColor: '#000',
|
|
addedAt: Date.now(),
|
|
hookSettings: {
|
|
mode: 'auto',
|
|
setupRunPolicy,
|
|
commandSourcePolicy,
|
|
scripts: { setup: '', archive: '' }
|
|
}
|
|
}) as unknown as Repo
|
|
|
|
it('opts into default tab command execution through the setup decision', () => {
|
|
const hooks = {
|
|
scripts: {},
|
|
defaultTabs: [{ title: 'Server', command: 'pnpm dev' }]
|
|
}
|
|
|
|
expect(getDefaultTabsLaunch(hooks, makeRepo('skip-by-default'), 'run')).toEqual({
|
|
tabs: hooks.defaultTabs,
|
|
runCommands: true
|
|
})
|
|
expect(getDefaultTabsLaunch(hooks, makeRepo('run-by-default'), 'skip')).toEqual({
|
|
tabs: hooks.defaultTabs,
|
|
runCommands: false
|
|
})
|
|
})
|
|
|
|
it('creates commandless default tabs without requiring setup approval', () => {
|
|
const hooks = {
|
|
scripts: {},
|
|
defaultTabs: [{ title: 'Notes' }]
|
|
}
|
|
|
|
expect(getDefaultTabsLaunch(hooks, makeRepo('ask'))).toEqual({
|
|
tabs: hooks.defaultTabs,
|
|
runCommands: false
|
|
})
|
|
})
|
|
|
|
it('does not run shared default tab commands when command source is local-only', () => {
|
|
const hooks = {
|
|
scripts: {},
|
|
defaultTabs: [{ title: 'Server', command: 'pnpm dev' }]
|
|
}
|
|
|
|
expect(getDefaultTabsLaunch(hooks, makeRepo('run-by-default', 'local-only'))).toEqual({
|
|
tabs: hooks.defaultTabs,
|
|
runCommands: false
|
|
})
|
|
})
|
|
})
|