mirror of
https://github.com/stablyai/orca.git
synced 2026-10-22 08:03:37 +00:00
* feat(worktrees): support project-level worktree.sharedDirectories in orca.yaml Follow-up to #7549: `.worktreeinclude` copies gitignored paths into each new worktree, which is right for `.env`/`.vscode/` but wrong for large rebuildable directories. Copying `node_modules` per worktree is slow and duplicates disk, and each worktree's install then diverges. Adds `worktree.sharedDirectories` to `orca.yaml` — a versioned, in-repo list of gitignored directories that are symlinked (shared) into every new local worktree, so one install serves them all. Adds to, never replaces, the per-user Worktree Shared Paths setting. `createWorktreeSharedPaths` uses a new 'share' materialization mode that always symlinks. The existing 'link' mode APFS clone-copies on macOS, which would give each worktree an independent node_modules and defeat the point; 'link' and 'copy' behavior are unchanged. Entries must exist as gitignored directories in the primary checkout; absolute paths, `..` traversal, and `.git` are rejected. Resolution never throws, so a malformed orca.yaml cannot block worktree creation. Remote (SSH) creation skips this, as it does symlink paths and `.worktreeinclude`. Closes #10451 * fix(worktrees): keep worktrees deletable after sharing a directory A directory-only ignore rule (`node_modules/`, the common spelling) matches the primary checkout's real directory, so the shared directory resolves and gets symlinked — but it never matches the worktree's symlink, so Git reports that link as untracked. Deletion only tolerated the per-user shared paths, so every worktree in such a repo became permanently dirty: the clean preflight threw "uncommitted or untracked changes" and `git worktree remove` refused without --force. Feed the configured `orca.yaml` shared directories into the same tolerate-and-unlink machinery the per-user shared paths already use, at both deletion call sites. The names are read unfiltered, since the create-time resolver drops exactly the entry deletion needs most. * test(worktrees): register createWorktreeSharedPaths in the runtime symlink mock orca-runtime.ts imports createWorktreeSharedPaths, but the vi.mock factory for ../ipc/worktree-symlinks never listed it. Vitest resolves omitted exports lazily, so this only stays green because no runtime test configures a repo with worktree.sharedDirectories — the first one that does would fail on a mock resolution error rather than on its own assertion. * fix(source-control): don't count shared symlinks as uncommitted changes A directory-only ignore rule (`node_modules/`) matches the primary checkout's real directory but never the worktree's symlink, so Git reports the shared link as untracked for the life of the worktree. That made every affected worktree read as dirty: a phantom row in the diff view, and Create PR blocked with `blockedReason: 'dirty'` telling the user to commit an entry they cannot commit, because it is a symlink Orca created. Status and the review-creation preflight now drop untracked entries that are both declared shared (per-user shared paths or orca.yaml sharedDirectories) and actually symlinks on disk. Both conditions are required, so a regular file at a declared name, or a symlink nobody declared, still counts as user work. The decision fails closed: anything not positively identified stays dirty. The preflight moves to `--porcelain -z` so paths with spaces or non-ASCII bytes are compared raw rather than C-quoted, with a parser that consumes the origin field a rename emits instead of reading it as its own record. Symlink detection moves to a leaf module: importing it from ipc/worktree-symlinks would pull APFS cloning, and its child_process dependency, into the status graph. SSH is unaffected and left alone — remote worktree creation skips the symlink and shared-directory passes, so a remote worktree never has one. * fix(source-control): wire shared links into local status * fix(worktrees): resolve the status repo once and reject uncollapsed shared paths `git:status` resolved the registered worktree's repo twice per call — once inside `getLocalGitOptionsForRegisteredWorktree` and again for the shared-link lookup — walking every repo's worktree meta on a polling path. `apps/./web` also survived `sharedDirectories` normalization: `resolve()` collapses it when the symlink is created but Git reports the collapsed path, so every later comparison misses and the link reads as permanent untracked work. Also stop resolving shared links for SSH repos in review creation: `repo.path` names a path on the remote host. Adds the missing wiring coverage for review creation and runtime status, plus the untracked-only conjunct in both filters — all four were mutation-verified to leave the suite green before these tests. * test(worktrees): pin the resolver-to-status seam for shared directories The resolver's output and the status filter were only tested apart — status used a hardcoded `['node_modules']`. Feed the resolved directories back through `getWorktreeSharedLinkPaths` into a real `getStatus` so a resolver that ever returned a differently-spelled path can no longer leave the link showing as a phantom untracked row. * fix(worktrees): try a directory junction before a symlink on Windows A plain `fs.symlink` needs Developer Mode or admin on Windows, so an ordinary Windows user got EPERM, the per-path catch logged and continued, and the worktree came up with no shared directory and no signal. A directory junction needs no privilege, and the rest of the codebase already uses one for win32 directory links. The symlink stays as a fallback rather than being replaced: a junction cannot target a UNC path, and a WSL project's repo lives behind one, so replacing it outright would trade the local-volume bug for a WSL regression. Safe for the removal path either way — Windows reports a junction as both a symlink and a directory, so the `isSymbolicLink()` unlink that runs before `git worktree remove` still fires and still refuses to follow it. * fix(worktrees): keep NUL bytes and tolerated links out of the removal error The removal preflight switches to `git status --porcelain -z` whenever it has shared links to tolerate, then attached that raw stdout to the error. `.trim()` does not strip interior NULs, so the message reached the user as `?? node_modules<NUL>?? precious.txt<NUL>` — raw control bytes, and it named the shared link, the one entry that is not the user's work and cannot be committed away. Parse the NUL-delimited output once and use it for both the clean verdict and the error text, so the two can never disagree about what blocks removal. The `-z` switch stays: it is what keeps paths with spaces or non-ASCII names comparable against the configured entry. * chore(worktrees): drop stray reformatting and note why the SSH guard exists Committing the merge staged 792 files, so lint-staged ran the formatter across all of them and rewrapped three renderer files that were already unformatted on main. Nothing was lost — they were byte-identical to main ignoring whitespace — but they showed up in the pull request as unrelated changed files. Restored to main's exact bytes. Committed with --no-verify on purpose: the pre-commit formatter is what introduced the rewrapping, so letting it run again would simply reapply it. Every check it would have run was run by hand instead — lint, typecheck, and the IPC and source-control suites all pass, and the three restored files are expected to fail a format check because that is main's current state. Also records why the connection guard on the shared-link lookup is not dead code: the remote dirty check ignores those paths, so the guard's only effect is avoiding a stray local read and the bad cache entry it would leave behind. * refactor(source-control): drop a scan-everything guard and freeze the cached list The dirty check built a filtered array only to read its length, so it always scanned every status record; asking whether any record is untracked stops at the first one and reads the same either way. The cached shared-directory list was also handhanded out by reference, so a caller that mutated it would corrupt every read for the rest of the cache window. Marking the return readonly prevents that at compile time; copying on return would work too but would allocate on the status-polling path, and there is exactly one caller, which only spreads it.
1385 lines
44 KiB
TypeScript
1385 lines
44 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\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(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('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('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
|
|
})
|
|
})
|
|
})
|