Add local setup commands in repo settings (#2270)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo Hong 2026-05-18 17:43:24 -04:00 committed by GitHub
parent 8a4d558c1d
commit 993d0ae599
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 866 additions and 147 deletions

View File

@ -47,7 +47,7 @@ describe('createSetupRunnerScript', () => {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(
makeRepo(),
'C:\\repo\\feature',
'C:\\repo\\feature\\',
'pnpm install\npnpm build'
)
@ -55,7 +55,8 @@ describe('createSetupRunnerScript', () => {
runnerScriptPath: 'C:\\repo\\.git\\worktrees\\feature\\orca\\setup-runner.cmd',
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/test/repo',
ORCA_WORKTREE_PATH: 'C:\\repo\\feature'
ORCA_WORKTREE_PATH: 'C:\\repo\\feature\\',
ORCA_WORKSPACE_NAME: 'feature'
})
})
expect(vi.mocked(fs.writeFileSync)).toHaveBeenCalledWith(
@ -79,6 +80,33 @@ describe('createSetupRunnerScript', () => {
}
})
it('derives ORCA_WORKSPACE_NAME from a POSIX worktree path', async () => {
const originalPlatform = process.platform
execFileSyncMock.mockReturnValue('/test/repo/.git/worktrees/feature/orca/setup-runner.sh')
Object.defineProperty(process, 'platform', {
configurable: true,
value: 'linux'
})
try {
const { createSetupRunnerScript } = await import('./hooks')
const result = createSetupRunnerScript(makeRepo(), '/test/repo-feature', 'pnpm install')
expect(result.envVars).toEqual(
expect.objectContaining({
ORCA_WORKTREE_PATH: '/test/repo-feature',
ORCA_WORKSPACE_NAME: 'repo-feature'
})
)
} finally {
Object.defineProperty(process, 'platform', {
configurable: true,
value: originalPlatform
})
}
})
it('translates WSL runner paths and env vars to Linux form on Windows', async () => {
const fs = await import('fs')
const originalPlatform = process.platform
@ -106,6 +134,7 @@ describe('createSetupRunnerScript', () => {
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
ORCA_WORKTREE_PATH: '/home/jin/feature',
ORCA_WORKSPACE_NAME: 'feature',
CONDUCTOR_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca',
GHOSTX_ROOT_PATH: '/mnt/c/Users/jinwo/git/orca'
})
@ -151,6 +180,7 @@ describe('createSetupRunnerScript', () => {
envVars: expect.objectContaining({
ORCA_ROOT_PATH: '/test/repo',
ORCA_WORKTREE_PATH: '/home/jin/repo/feature',
ORCA_WORKSPACE_NAME: 'feature',
CONDUCTOR_ROOT_PATH: '/test/repo',
GHOSTX_ROOT_PATH: '/test/repo'
})

View File

@ -291,6 +291,7 @@ describe('getEffectiveHooks', () => {
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 }
}) =>
({
@ -345,26 +346,21 @@ describe('getEffectiveHooks', () => {
expect(result?.scripts.setup).not.toContain('old-version')
})
it('falls back to legacy UI hooks when yaml is missing', async () => {
it('does not fall back to local settings hooks by default when yaml is missing', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(false)
const { getEffectiveHooks } = await import('./hooks')
const repo = makeRepo({
mode: 'override',
scripts: { setup: 'echo "legacy ui setup"', archive: 'echo "legacy archive"' }
scripts: { setup: 'echo "local setup"', archive: 'echo "local archive"' }
})
const result = getEffectiveHooks(repo)
expect(result).toEqual({
scripts: {
setup: 'echo "legacy ui setup"',
archive: 'echo "legacy archive"'
}
})
expect(result).toBeNull()
})
it('ignores legacy UI override settings when yaml exists', async () => {
it('uses shared yaml settings over local settings by default', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n setup: |\n echo "yaml setup"\n')
@ -383,7 +379,47 @@ describe('getEffectiveHooks', () => {
})
})
it('falls back per hook when orca.yaml defines only one command', async () => {
it('uses only local settings when command source policy is local-only', async () => {
const fs = await import('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('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('treats orca.yaml as authoritative by default when it defines only one command', async () => {
const fs = await import('fs')
vi.mocked(fs.existsSync).mockReturnValue(true)
vi.mocked(fs.readFileSync).mockReturnValue('scripts:\n archive: |\n echo "yaml archive"\n')
@ -397,7 +433,26 @@ describe('getEffectiveHooks', () => {
expect(result).toEqual({
scripts: {
setup: 'echo "legacy setup"',
archive: 'echo "yaml archive"'
}
})
})
it('treats legacy shared-first policy as orca.yaml only', async () => {
const fs = await import('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"'
}
})

View File

@ -3,9 +3,12 @@ import { readFileSync, existsSync, mkdirSync, writeFileSync, chmodSync, rmSync }
import { dirname, join } from 'path'
import { exec, execFile } from 'child_process'
import { getDefaultRepoHookSettings } from '../shared/constants'
import { getRuntimePathBasename } from '../shared/cross-platform-path'
import { normalizeHookCommandSourcePolicy } from '../shared/hook-command-source-policy'
import { gitExecFileSync } from './git/runner'
import { isWslPath, parseWslPath, toWindowsWslPath, toLinuxPath } from './wsl'
import type {
HookCommandSourcePolicy,
OrcaHooks,
Repo,
SetupDecision,
@ -261,21 +264,40 @@ function ensureOrcaDirIgnored(repoPath: string): void {
}
}
function getEffectiveHookScript(
yamlScript: string | undefined,
localScript: string | undefined,
policy: HookCommandSourcePolicy
): string | undefined {
const shared = yamlScript?.trim()
const local = localScript?.trim()
if (policy === 'local-only') {
return local || undefined
}
if (policy === 'run-both') {
return [shared, local].filter(Boolean).join('\n') || undefined
}
return shared || undefined
}
export function getEffectiveHooks(repo: Repo, worktreePath?: string): OrcaHooks | null {
const yamlHooks = loadHooks(worktreePath ?? repo.path)
const legacySetup = repo.hookSettings?.scripts.setup?.trim()
const legacyArchive = repo.hookSettings?.scripts.archive?.trim()
const setup = yamlHooks?.scripts.setup?.trim() || legacySetup
const archive = yamlHooks?.scripts.archive?.trim() || legacyArchive
const localSetup = repo.hookSettings?.scripts.setup
const localArchive = repo.hookSettings?.scripts.archive
const policy = normalizeHookCommandSourcePolicy(repo.hookSettings?.commandSourcePolicy)
const setup = getEffectiveHookScript(yamlHooks?.scripts.setup, localSetup, policy)
const archive = getEffectiveHookScript(yamlHooks?.scripts.archive, localArchive, policy)
if (!setup && !archive) {
return null
}
// Why: `orca.yaml` is the preferred source going forward, but existing users may
// still have setup/archive commands persisted only in repo settings. Resolve each
// hook independently so a repo that has only migrated one command into `orca.yaml`
// does not silently lose the other legacy hook until the migration is complete.
// Why: committed `orca.yaml` and local Settings commands can intentionally
// coexist, but the source policy defines whether the committed file is an
// authoritative boundary, local settings are authoritative, or both run.
return {
scripts: {
...(setup ? { setup } : {}),
@ -307,8 +329,18 @@ export function shouldRunSetupForCreate(repo: Repo, decision: SetupDecision = 'i
export function getSetupCommandSource(
repo: Repo,
worktreePath?: string
): { source: 'yaml'; command: string } | null {
): { source: 'yaml' | 'local' | 'both'; command: string } | null {
const yamlSetup = loadHooks(worktreePath ?? repo.path)?.scripts.setup?.trim()
const localSetup = repo.hookSettings?.scripts.setup?.trim()
const policy = normalizeHookCommandSourcePolicy(repo.hookSettings?.commandSourcePolicy)
if (policy === 'local-only') {
return localSetup ? { source: 'local', command: localSetup } : null
}
if (policy === 'run-both' && yamlSetup && localSetup) {
return { source: 'both', command: `${yamlSetup}\n${localSetup}` }
}
if (yamlSetup) {
return { source: 'yaml', command: yamlSetup }
@ -321,6 +353,7 @@ function getSetupEnvVars(repo: Repo, worktreePath: string): Record<string, strin
return {
ORCA_ROOT_PATH: repo.path,
ORCA_WORKTREE_PATH: worktreePath,
ORCA_WORKSPACE_NAME: getRuntimePathBasename(worktreePath),
// Compat with conductor.json users
CONDUCTOR_ROOT_PATH: repo.path,
GHOSTX_ROOT_PATH: repo.path

View File

@ -30,6 +30,7 @@ import SparseCheckoutPresetSelect from '@/components/sparse/SparseCheckoutPreset
import SmartWorkspaceNameField, {
type SmartWorkspaceNameSelection
} from '@/components/new-workspace/SmartWorkspaceNameField'
import type { SetupConfig } from '@/lib/new-workspace'
import type { WorkspaceCreateErrorDisplay } from '@/lib/workspace-create-error-format'
import type { SshConnectionStatus } from '../../../shared/ssh-types'
@ -63,7 +64,7 @@ type NewWorkspaceComposerCardProps = {
onCreate: () => void
note: string
onNoteChange: (value: string) => void
setupConfig: { source: 'yaml' | 'legacy'; command: string } | null
setupConfig: SetupConfig | null
requiresExplicitSetupChoice: boolean
setupDecision: 'run' | 'skip' | null
onSetupDecisionChange: (value: 'run' | 'skip') => void
@ -96,7 +97,7 @@ function SetupCommandPreview({
setupConfig,
headerAction
}: {
setupConfig: { source: 'yaml' | 'legacy'; command: string }
setupConfig: SetupConfig
headerAction?: React.ReactNode
}): React.JSX.Element {
if (setupConfig.source === 'yaml') {
@ -117,7 +118,7 @@ function SetupCommandPreview({
<div className="rounded-2xl border border-border/60 bg-muted/35 px-4 py-3 shadow-inner">
<div className="mb-2 flex items-center justify-between gap-3">
<div className="text-[11px] uppercase tracking-[0.18em] text-muted-foreground">
Legacy setup command
{setupConfig.source === 'both' ? 'Combined setup command' : 'Local setup command'}
</div>
{headerAction}
</div>
@ -519,7 +520,11 @@ export default function NewWorkspaceComposerCard({
Setup script
</label>
<span className="rounded-full border border-border/70 bg-muted/45 px-2 py-0.5 text-[10px] font-medium uppercase tracking-[0.14em] text-foreground/70">
{setupConfig.source === 'yaml' ? 'orca.yaml' : 'legacy hooks'}
{setupConfig.source === 'yaml'
? 'orca.yaml'
: setupConfig.source === 'both'
? 'orca.yaml + local'
: 'local settings'}
</span>
</div>

View File

@ -0,0 +1,41 @@
import { describe, expect, it } from 'vitest'
import {
commandRowsToScript,
localCommandDraftToScripts,
scriptToCommandRows,
type LocalCommandDraft,
type LocalCommandRow
} from './RepositoryHooksSection'
describe('RepositoryHooksSection command row serialization', () => {
it('round-trips blank lines and trailing whitespace in existing scripts', () => {
const script = 'echo before \n\ncat <<EOF\n body \nEOF\n'
expect(commandRowsToScript(scriptToCommandRows(script))).toBe(script)
})
it('keeps persisted blank rows distinct from new empty placeholders', () => {
const rows: LocalCommandRow[] = [
...scriptToCommandRows('echo before\n\n echo after '),
{ value: '', isPlaceholder: true }
]
expect(commandRowsToScript(rows)).toBe('echo before\n\n echo after ')
})
it('serializes local command drafts with the same placeholder pruning used by commits', () => {
const draft: LocalCommandDraft = {
setup: [...scriptToCommandRows('echo setup\n'), { value: '', isPlaceholder: true }],
archive: [
{ value: '', isPlaceholder: false },
{ value: 'echo archive', isPlaceholder: false },
{ value: '', isPlaceholder: true }
]
}
expect(localCommandDraftToScripts(draft)).toEqual({
setup: 'echo setup\n',
archive: '\necho archive'
})
})
})

View File

@ -1,12 +1,21 @@
/* eslint-disable max-lines -- Why: the YAML status card, issue-command editor, policy grid, and legacy-hook section form one cohesive settings surface; splitting them across files would scatter tightly coupled state and prop drilling. */
import { useCallback, useEffect, useRef, useState } from 'react'
import type { OrcaHooks, Repo, SetupRunPolicy } from '../../../../shared/types'
import { AlertTriangle } from 'lucide-react'
import type {
HookCommandSourcePolicy,
OrcaHooks,
Repo,
RepoHookSettings,
SetupRunPolicy
} from '../../../../shared/types'
import { AlertTriangle, Plus, Trash2 } from 'lucide-react'
import { toast } from 'sonner'
import { Button } from '../ui/button'
import { Input } from '../ui/input'
import { SearchableSetting } from './SearchableSetting'
import { useAppStore } from '@/store'
import { readRuntimeIssueCommand, writeRuntimeIssueCommand } from '@/runtime/runtime-hooks-client'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import { normalizeHookCommandSourcePolicy } from '../../../../shared/hook-command-source-policy'
type RepositoryHooksSectionProps = {
repo: Repo
@ -15,11 +24,17 @@ type RepositoryHooksSectionProps = {
mayNeedUpdate: boolean
copiedTemplate: boolean
onCopyTemplate: () => void
onClearLegacyHooks: () => void
onUpdateSetupRunPolicy: (policy: SetupRunPolicy) => void
onUpdateHookSettings: (settings: RepoHookSettings) => void
}
type PolicyOption<P> = { policy: P; label: string; description: string }
export type LocalCommandRow = { value: string; isPlaceholder: boolean }
const LOCAL_HOOK_NAMES = ['setup', 'archive'] as const
type LocalHookName = (typeof LOCAL_HOOK_NAMES)[number]
export type LocalCommandDraft = Record<LocalHookName, LocalCommandRow[]>
type HookSettingsPolicyDraft = Partial<
Pick<RepoHookSettings, 'setupRunPolicy' | 'commandSourcePolicy'>
>
const SETUP_RUN_POLICY_OPTIONS: PolicyOption<SetupRunPolicy>[] = [
{ policy: 'ask', label: 'Ask every time', description: 'Prompt before running setup.' },
@ -31,6 +46,92 @@ const SETUP_RUN_POLICY_OPTIONS: PolicyOption<SetupRunPolicy>[] = [
}
]
const COMMAND_SOURCE_POLICY_OPTIONS: PolicyOption<HookCommandSourcePolicy>[] = [
{
policy: 'shared-only',
label: 'Use orca.yaml only',
description: 'Run only committed repo commands; ignore local Settings commands.'
},
{
policy: 'local-only',
label: 'Use local only',
description: 'Ignore repo commands and run only your local Settings commands.'
},
{
policy: 'run-both',
label: 'Run both',
description: 'Run orca.yaml first, then your local Settings command.'
}
]
const LOCAL_HOOK_FIELDS: {
name: LocalHookName
label: string
description: string
placeholder: string
}[] = [
{
name: 'setup',
label: 'Local setup command',
description: 'Runs after a new workspace is created when the source policy includes local.',
placeholder: 'cp "$ORCA_ROOT_PATH/.env" "$ORCA_WORKTREE_PATH/.env"'
},
{
name: 'archive',
label: 'Local archive command',
description: 'Runs before a local worktree is archived or removed.',
placeholder: 'echo "Cleaning up $ORCA_WORKSPACE_NAME"'
}
]
export function scriptToCommandRows(script: string | undefined): LocalCommandRow[] {
if (!script) {
return []
}
return script.split('\n').map((line) => ({
value: line.endsWith('\r') ? line.slice(0, -1) : line,
isPlaceholder: false
}))
}
export function commandRowsToScript(commands: LocalCommandRow[]): string {
return commands
.filter((command) => !(command.isPlaceholder && command.value.length === 0))
.map((command) => command.value)
.join('\n')
}
function pruneLocalCommandPlaceholders(commands: LocalCommandRow[]): LocalCommandRow[] {
return commands.filter((command) => !(command.isPlaceholder && command.value.length === 0))
}
export function localCommandDraftToScripts(draft: LocalCommandDraft): RepoHookSettings['scripts'] {
return {
setup: commandRowsToScript(pruneLocalCommandPlaceholders(draft.setup)),
archive: commandRowsToScript(pruneLocalCommandPlaceholders(draft.archive))
}
}
function getHookSettingsDraft(hookSettings: Repo['hookSettings']): RepoHookSettings {
return {
...DEFAULT_REPO_HOOK_SETTINGS,
...hookSettings,
scripts: {
...DEFAULT_REPO_HOOK_SETTINGS.scripts,
...hookSettings?.scripts
}
}
}
function getLocalCommandsDraft(hookSettings: Repo['hookSettings']): LocalCommandDraft {
const draft = getHookSettingsDraft(hookSettings)
return {
setup: scriptToCommandRows(draft.scripts.setup),
archive: scriptToCommandRows(draft.scripts.archive)
}
}
const EXAMPLE_TEMPLATE = `scripts:
setup: |
pnpm worktree:setup
@ -153,8 +254,7 @@ export function RepositoryHooksSection({
mayNeedUpdate,
copiedTemplate,
onCopyTemplate,
onClearLegacyHooks,
onUpdateSetupRunPolicy
onUpdateHookSettings
}: RepositoryHooksSectionProps): React.JSX.Element {
const settings = useAppStore((s) => s.settings)
// Why: distinguish "file has unrecognised top-level keys" from "file is
@ -167,14 +267,31 @@ export function RepositoryHooksSection({
? 'update-available'
: 'invalid'
: 'missing'
const hs = repo.hookSettings
const legacyHookEntries = (['setup', 'archive'] as const)
.map((hookName) => [hookName, hs?.scripts[hookName]?.trim() ?? ''] as const)
const [hookSettingsDraft, setHookSettingsDraft] = useState(() =>
getHookSettingsDraft(repo.hookSettings)
)
const hookSettingsDraftRef = useRef(hookSettingsDraft)
hookSettingsDraftRef.current = hookSettingsDraft
const [localCommandsDraft, setLocalCommandsDraft] = useState(() =>
getLocalCommandsDraft(repo.hookSettings)
)
const localCommandsDraftRef = useRef(localCommandsDraft)
localCommandsDraftRef.current = localCommandsDraft
const localCommandsRepoHookSettingsRef = useRef(repo.hookSettings)
const localCommandsDraftDirtyRef = useRef(false)
const localCommandsPersistForRepoRef = useRef(onUpdateHookSettings)
const localHookEntries = (['setup', 'archive'] as const)
.map((hookName) => [hookName, hookSettingsDraft.scripts[hookName] ?? ''] as const)
.filter(([, script]) => Boolean(script))
// Why: the type allows `undefined` in persisted settings for backward compatibility,
// but the UI always needs a concrete value so the policy grid has an active selection.
const selectedSetupRunPolicy: SetupRunPolicy = hs?.setupRunPolicy ?? 'run-by-default'
const selectedSetupRunPolicy: SetupRunPolicy =
hookSettingsDraft.setupRunPolicy ?? 'run-by-default'
const selectedCommandSourcePolicy: HookCommandSourcePolicy = normalizeHookCommandSourcePolicy(
hookSettingsDraft.commandSourcePolicy
)
const [issueCommandDraft, setIssueCommandDraft] = useState('')
const localCommandsRepoIdRef = useRef(repo.id)
const [hasSharedIssueCommand, setHasSharedIssueCommand] = useState(false)
const [issueCommandSaveError, setIssueCommandSaveError] = useState<string | null>(null)
// Why: track the latest draft across blur/unmount so repo switches still
@ -183,6 +300,122 @@ export function RepositoryHooksSection({
issueCommandDraftRef.current = issueCommandDraft
const lastCommittedIssueCommandRef = useRef('')
localCommandsRepoHookSettingsRef.current = repo.hookSettings
const setAndMaybePersistHookSettings = useCallback(
(nextSettings: RepoHookSettings, shouldPersist: boolean) => {
hookSettingsDraftRef.current = nextSettings
setHookSettingsDraft(nextSettings)
if (shouldPersist) {
localCommandsDraftDirtyRef.current = false
onUpdateHookSettings(nextSettings)
}
},
[onUpdateHookSettings]
)
const updateLocalCommandsDraft = useCallback(
(hookName: LocalHookName, commands: LocalCommandRow[], shouldPersist: boolean) => {
const nextCommandsDraft = { ...localCommandsDraftRef.current, [hookName]: commands }
localCommandsDraftRef.current = nextCommandsDraft
setLocalCommandsDraft(nextCommandsDraft)
if (!shouldPersist) {
localCommandsDraftDirtyRef.current = true
}
const nextSettings = {
...hookSettingsDraftRef.current,
scripts: {
...hookSettingsDraftRef.current.scripts,
[hookName]: commandRowsToScript(commands)
}
}
setAndMaybePersistHookSettings(nextSettings, shouldPersist)
},
[setAndMaybePersistHookSettings]
)
const commitLocalCommandsDraft = useCallback(
(hookName: LocalHookName) => {
// Why: Add Command creates an unsaved empty editor row. Existing blank script
// lines are real rows and must round-trip, so only placeholder blanks are pruned.
const next = pruneLocalCommandPlaceholders(localCommandsDraftRef.current[hookName])
updateLocalCommandsDraft(hookName, next, true)
},
[updateLocalCommandsDraft]
)
const flushDirtyLocalCommandsDraft = useCallback(
(persistHookSettings: (settings: RepoHookSettings) => void) => {
if (!localCommandsDraftDirtyRef.current) {
return
}
const nextSettings = {
...hookSettingsDraftRef.current,
scripts: {
...hookSettingsDraftRef.current.scripts,
...localCommandDraftToScripts(localCommandsDraftRef.current)
}
}
hookSettingsDraftRef.current = nextSettings
localCommandsDraftDirtyRef.current = false
persistHookSettings(nextSettings)
},
[]
)
const updateHookSettingsPolicyDraft = useCallback(
(updates: HookSettingsPolicyDraft) => {
const nextSettings = {
...hookSettingsDraftRef.current,
...updates
}
setAndMaybePersistHookSettings(nextSettings, true)
},
[setAndMaybePersistHookSettings]
)
const handleClearLocalCommands = useCallback(() => {
const nextCommandsDraft = { setup: [], archive: [] }
localCommandsDraftRef.current = nextCommandsDraft
setLocalCommandsDraft(nextCommandsDraft)
const nextSettings = {
...hookSettingsDraftRef.current,
scripts: {
...hookSettingsDraftRef.current.scripts,
setup: '',
archive: ''
}
}
setAndMaybePersistHookSettings(nextSettings, true)
}, [setAndMaybePersistHookSettings])
useEffect(() => {
if (localCommandsRepoIdRef.current === repo.id) {
localCommandsPersistForRepoRef.current = onUpdateHookSettings
return
}
// Why: repo switches reset the local editor state before inputs can blur,
// so flush dirty row drafts through the previous repo's captured updater.
flushDirtyLocalCommandsDraft(localCommandsPersistForRepoRef.current)
localCommandsRepoIdRef.current = repo.id
const nextSettingsDraft = getHookSettingsDraft(localCommandsRepoHookSettingsRef.current)
const nextCommandsDraft = getLocalCommandsDraft(localCommandsRepoHookSettingsRef.current)
hookSettingsDraftRef.current = nextSettingsDraft
localCommandsDraftRef.current = nextCommandsDraft
localCommandsDraftDirtyRef.current = false
localCommandsPersistForRepoRef.current = onUpdateHookSettings
setHookSettingsDraft(nextSettingsDraft)
setLocalCommandsDraft(nextCommandsDraft)
}, [flushDirtyLocalCommandsDraft, onUpdateHookSettings, repo.id])
useEffect(() => {
return () => {
flushDirtyLocalCommandsDraft(localCommandsPersistForRepoRef.current)
}
}, [flushDirtyLocalCommandsDraft])
// Keep the local override editor in sync with the selected repo and flush unsaved edits on exit.
useEffect(() => {
let cancelled = false
@ -244,8 +477,8 @@ export function RepositoryHooksSection({
<div className="space-y-1">
<h2 className="text-sm font-semibold">Worktree Hooks</h2>
<p className="text-xs text-muted-foreground">
Orca prefers shared hooks from `orca.yaml` and still honors older repo-local hook scripts
until you clear them.
Configure shared repo hooks from `orca.yaml` and personal commands stored locally on this
machine.
</p>
</div>
@ -326,45 +559,156 @@ export function RepositoryHooksSection({
</div>
</SearchableSetting>
{legacyHookEntries.length > 0 ? (
<SearchableSetting
title="Legacy Repo-Local Hooks"
description="Older setup and archive hook scripts stored in local repo settings."
keywords={['legacy', 'fallback', 'setup', 'archive']}
>
<div className="space-y-4 rounded-2xl border border-amber-500/20 bg-amber-500/5 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h5 className="text-sm font-semibold text-amber-700 dark:text-amber-300">
Legacy Repo-Local Hooks
</h5>
<p className="text-xs text-muted-foreground">
These older commands still run as a fallback when `orca.yaml` does not provide a
hook. Clear them after you migrate the behavior into `orca.yaml`.
</p>
</div>
<Button type="button" variant="outline" size="sm" onClick={onClearLegacyHooks}>
Clear Legacy Hooks
</Button>
<SearchableSetting
title="Local Settings Commands"
description="Personal setup and archive commands stored locally on this machine."
keywords={['local', 'personal', 'setup', 'archive']}
>
<div className="space-y-4 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<h5 className="text-sm font-semibold">Local Settings Commands</h5>
<p className="text-xs text-muted-foreground">
Stored in Orca on this machine. These commands are not committed to the repository.
</p>
</div>
{localHookEntries.length > 0 ? (
<Button type="button" variant="outline" size="sm" onClick={handleClearLocalCommands}>
Clear Local
</Button>
) : null}
</div>
{legacyHookEntries.map(([hookName, script]) => (
<div
key={hookName}
className="space-y-2 rounded-xl border border-amber-500/20 bg-background/70 p-3"
<div className="flex flex-wrap gap-1.5">
{['$ORCA_ROOT_PATH', '$ORCA_WORKTREE_PATH', '$ORCA_WORKSPACE_NAME'].map((name) => (
<code
key={name}
className="rounded-md border border-border/50 bg-muted/35 px-2 py-1 font-mono text-[11px] text-muted-foreground"
>
<div className="flex items-center justify-between gap-2">
<p className="text-xs font-medium capitalize text-foreground">{hookName}</p>
<span className="text-[10px] text-muted-foreground">Compatibility fallback</span>
</div>
<pre className="overflow-x-auto whitespace-pre-wrap break-words rounded-lg bg-background p-3 font-mono text-[11px] leading-5 text-foreground">
{script}
</pre>
</div>
{name}
</code>
))}
</div>
</SearchableSetting>
) : null}
<div className="grid gap-3">
{LOCAL_HOOK_FIELDS.map((field) => {
const commands = localCommandsDraft[field.name]
return (
<div key={field.name} className="space-y-2">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<label className="text-xs font-medium text-foreground">{field.label}</label>
<p className="text-[11px] text-muted-foreground">{field.description}</p>
</div>
<Button
type="button"
variant="outline"
size="sm"
onClick={() =>
updateLocalCommandsDraft(
field.name,
[...commands, { value: '', isPlaceholder: true }],
false
)
}
>
<Plus />
Add Command
</Button>
</div>
<div className="overflow-hidden rounded-lg border border-border/50">
{commands.length === 0 ? (
<div className="px-3 py-4 text-sm text-muted-foreground">
No local {field.name} commands configured.
</div>
) : (
<div className="divide-y divide-border/50">
{commands.map((command, index) => (
<div
key={`${field.name}-${index}`}
className="grid grid-cols-[auto_1fr_auto] items-center gap-2 px-3 py-2"
>
<span className="w-5 text-right font-mono text-[11px] text-muted-foreground">
{index + 1}
</span>
<Input
value={command.value}
onChange={(event) => {
const next = [...commands]
next[index] = {
value: event.target.value,
isPlaceholder: false
}
updateLocalCommandsDraft(field.name, next, false)
}}
onBlur={() => commitLocalCommandsDraft(field.name)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
event.preventDefault()
updateLocalCommandsDraft(
field.name,
[
...commands.slice(0, index + 1),
{ value: '', isPlaceholder: true },
...commands.slice(index + 1)
],
false
)
}
}}
placeholder={index === 0 ? field.placeholder : 'Command'}
className="h-8 font-mono text-xs"
/>
<Button
type="button"
variant="ghost"
size="icon-sm"
aria-label={`Remove ${field.label} ${index + 1}`}
onClick={() =>
updateLocalCommandsDraft(
field.name,
commands.filter((_, commandIndex) => commandIndex !== index),
true
)
}
className="text-muted-foreground hover:text-destructive"
>
<Trash2 />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)
})}
</div>
</div>
</SearchableSetting>
<SearchableSetting
title="Command Source"
description="Choose whether Orca runs commands from `orca.yaml`, local Settings, or both."
keywords={['command source', 'local', 'shared', 'orca.yaml', 'both', 'authoritative']}
>
<div className="space-y-3 rounded-2xl border border-border/50 bg-background/80 p-4 shadow-sm">
<div className="space-y-1">
<h5 className="text-sm font-semibold">Command Source</h5>
<p className="text-xs text-muted-foreground">
Choose whether Orca runs commands from `orca.yaml`, local Settings, or both.
</p>
</div>
<PolicyOptionGrid
options={COMMAND_SOURCE_POLICY_OPTIONS}
selected={selectedCommandSourcePolicy}
onSelect={(policy) => updateHookSettingsPolicyDraft({ commandSourcePolicy: policy })}
columns="md:grid-cols-3"
/>
</div>
</SearchableSetting>
<SearchableSetting
title="When to Run Setup"
@ -382,7 +726,7 @@ export function RepositoryHooksSection({
<PolicyOptionGrid
options={SETUP_RUN_POLICY_OPTIONS}
selected={selectedSetupRunPolicy}
onSelect={onUpdateSetupRunPolicy}
onSelect={(policy) => updateHookSettingsPolicyDraft({ setupRunPolicy: policy })}
columns="md:grid-cols-3"
/>
</div>

View File

@ -1,5 +1,5 @@
import { useState } from 'react'
import type { OrcaHooks, Repo, RepoHookSettings, SetupRunPolicy } from '../../../../shared/types'
import type { OrcaHooks, Repo, RepoHookSettings } from '../../../../shared/types'
import { getRepoKindLabel, isFolderRepo } from '../../../../shared/repo-kind'
import { REPO_COLORS } from '../../../../shared/constants'
import { Button } from '../ui/button'
@ -7,7 +7,6 @@ import { Input } from '../ui/input'
import { Label } from '../ui/label'
import { Separator } from '../ui/separator'
import { Trash2 } from 'lucide-react'
import { DEFAULT_REPO_HOOK_SETTINGS } from './SettingsConstants'
import { BaseRefPicker } from './BaseRefPicker'
import { RepositoryHooksSection } from './RepositoryHooksSection'
import { McpConfigSection } from './McpConfigSection'
@ -103,9 +102,23 @@ export function getRepositoryPaneSearchEntries(repo: Repo): SettingsSearchEntry[
keywords: [repo.displayName, 'hooks', 'setup', 'archive', 'yaml']
},
{
title: 'Legacy Repo-Local Hooks',
description: 'Older setup and archive hook scripts stored in local repo settings.',
keywords: [repo.displayName, 'legacy', 'fallback', 'hooks']
title: 'Local Settings Commands',
description: 'Personal setup and archive commands stored locally on this machine.',
keywords: [repo.displayName, 'local', 'personal', 'hooks']
},
{
title: 'Command Source',
description:
'Choose whether Orca runs commands from `orca.yaml`, local Settings, or both.',
keywords: [
repo.displayName,
'local',
'orca.yaml',
'shared',
'both',
'source',
'authoritative'
]
},
{
title: 'When to Run Setup',
@ -160,18 +173,7 @@ export function RepositoryPane({
setConfirmingRemove(repoId)
}
const updateSelectedRepoHookSettings = (
updates: Partial<Pick<RepoHookSettings, 'setupRunPolicy'>>
) => {
// Why: persisted repos may still carry legacy UI hook fields from the old dual-source
// design. We preserve them when saving so existing local state stays loadable, but the
// product now treats `orca.yaml` as the only supported hook definition surface.
const nextSettings: RepoHookSettings = {
...DEFAULT_REPO_HOOK_SETTINGS,
...repo.hookSettings,
...updates
}
const updateSelectedRepoHookSettings = (nextSettings: RepoHookSettings) => {
updateRepo(repo.id, {
hookSettings: nextSettings
})
@ -189,22 +191,6 @@ export function RepositoryPane({
window.setTimeout(() => setCopiedTemplate(false), 1500)
}
const handleClearLegacyHooks = () => {
// Why: legacy repo-local commands are still honored as a compatibility fallback.
// Keep them visible and removable here so the settings surface matches runtime behavior.
updateRepo(repo.id, {
hookSettings: {
...DEFAULT_REPO_HOOK_SETTINGS,
...repo.hookSettings,
scripts: {
...DEFAULT_REPO_HOOK_SETTINGS.scripts,
setup: '',
archive: ''
}
}
})
}
const allEntries = getRepositoryPaneSearchEntries(repo)
const identityEntries = allEntries.filter((entry) =>
['Display Name', 'Badge Color', 'Default Worktree Base', 'Remove Repo'].includes(entry.title)
@ -215,7 +201,8 @@ export function RepositoryPane({
const hooksEntries = allEntries.filter((entry) =>
[
'orca.yaml hooks',
'Legacy Repo-Local Hooks',
'Local Settings Commands',
'Command Source',
'When to Run Setup',
'Custom GitHub Issue Command'
].includes(entry.title)
@ -340,10 +327,7 @@ export function RepositoryPane({
mayNeedUpdate={mayNeedUpdate}
copiedTemplate={copiedTemplate}
onCopyTemplate={() => void handleCopyTemplate()}
onClearLegacyHooks={handleClearLegacyHooks}
onUpdateSetupRunPolicy={(policy) =>
updateSelectedRepoHookSettings({ setupRunPolicy: policy as SetupRunPolicy })
}
onUpdateHookSettings={updateSelectedRepoHookSettings}
/>
) : null
].filter(Boolean)

View File

@ -46,7 +46,8 @@ import {
getWorkspaceSeedName,
PER_REPO_FETCH_LIMIT,
renderIssueCommandTemplate,
type LinkedWorkItemSummary
type LinkedWorkItemSummary,
type SetupConfig
} from '@/lib/new-workspace'
import {
getFullComposerCreateDisabled,
@ -181,7 +182,7 @@ export type ComposerCardProps = {
/** Transient inline hint shown next to the Start-from trigger after a repo
* switch resets a prior selection (e.g. "was PR #8778"). Null when none. */
startFromResetHint: string | null
setupConfig: { source: 'yaml' | 'legacy'; command: string } | null
setupConfig: SetupConfig | null
requiresExplicitSetupChoice: boolean
setupDecision: 'run' | 'skip' | null
onSetupDecisionChange: (value: 'run' | 'skip') => void

View File

@ -120,6 +120,33 @@ describe('ensureHooksConfirmed', () => {
expect(pending).toHaveLength(0)
})
it('does not prompt for orca.yaml when the repo uses local commands only', async () => {
const { state, pending } = createTestState({
repos: [
{
id: 'repo-1',
displayName: 'Repo One',
hookSettings: {
mode: 'auto',
commandSourcePolicy: 'local-only',
scripts: { setup: 'echo local', archive: '' }
}
}
]
} as Partial<AppState>)
hooksCheckMock.mockResolvedValue({
hasHooks: true,
hooks: { scripts: { setup: 'echo shared' } },
mayNeedUpdate: false
})
const decision = await ensureHooksConfirmed(state, 'repo-1', 'setup')
expect(decision).toBe('run')
expect(hooksCheckMock).not.toHaveBeenCalled()
expect(pending).toHaveLength(0)
})
it('returns run without prompting when issueCommand source is local (user-owned)', async () => {
const { state, pending } = createTestState()
readIssueCommandMock.mockResolvedValue({

View File

@ -38,6 +38,10 @@ export async function ensureHooksConfirmed(
}
scriptContent = (result.sharedContent ?? '').trim()
} else {
const repo = state.repos.find((r) => r.id === repoId)
if (repo?.hookSettings?.commandSourcePolicy === 'local-only') {
return 'run'
}
const result = await checkRuntimeHooks(state.settings, repoId)
const yamlHooks = (result.hooks as OrcaHooks | null) ?? null
scriptContent = (yamlHooks?.scripts?.[scriptKind] ?? '').trim()

View File

@ -7,6 +7,7 @@ import {
import type { AgentStartupPlan } from '@/lib/tui-agent-startup'
import { isShellProcess } from '@/lib/tui-agent-startup'
import type { OrcaHooks, TaskViewPresetId } from '../../../shared/types'
import { normalizeHookCommandSourcePolicy } from '../../../shared/hook-command-source-policy'
/**
* Why: the TaskPage's preset buttons and the openTaskPage prefetcher both need
@ -61,6 +62,8 @@ export type LinkedWorkItemSummary = {
// is the minimum viable instruction that always produces a coherent agent task.
export const DEFAULT_ISSUE_COMMAND_TEMPLATE = 'Complete {{artifact_url}}'
export type SetupConfig = { source: 'yaml' | 'local' | 'both'; command: string }
/**
* Substitute the issue-command template variables. Prefers `{{artifact_url}}`
* and keeps `{{issue}}` working silently for repos that have not migrated
@ -115,17 +118,31 @@ export function getAttachmentLabel(pathValue: string): string {
}
export function getSetupConfig(
repo: { hookSettings?: { scripts?: { setup?: string } } } | undefined,
repo:
| {
hookSettings?: {
commandSourcePolicy?: unknown
scripts?: { setup?: string }
}
}
| undefined,
yamlHooks: OrcaHooks | null
): { source: 'yaml' | 'legacy'; command: string } | null {
): SetupConfig | null {
const yamlSetup = yamlHooks?.scripts?.setup?.trim()
const localSetup = repo?.hookSettings?.scripts?.setup?.trim()
const sourcePolicy = normalizeHookCommandSourcePolicy(repo?.hookSettings?.commandSourcePolicy)
if (sourcePolicy === 'local-only') {
return localSetup ? { source: 'local', command: localSetup } : null
}
if (sourcePolicy === 'run-both' && yamlSetup && localSetup) {
return { source: 'both', command: `${yamlSetup}\n${localSetup}` }
}
if (yamlSetup) {
return { source: 'yaml', command: yamlSetup }
}
const legacySetup = repo?.hookSettings?.scripts?.setup?.trim()
if (legacySetup) {
return { source: 'legacy', command: legacySetup }
}
return null
}

View File

@ -0,0 +1,130 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createTestStore } from './store-test-helpers'
import type { Repo } from '../../../../shared/types'
const localRepo: Repo = {
id: 'local-repo',
path: '/local',
displayName: 'Local',
badgeColor: '#000',
addedAt: 1
}
const secondRepo: Repo = {
id: 'second-repo',
path: '/second',
displayName: 'Second',
badgeColor: '#111',
addedAt: 2
}
const reposUpdate = vi.fn()
function deferred<T>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
beforeEach(() => {
reposUpdate.mockReset()
vi.stubGlobal('window', {
api: {
repos: {
update: reposUpdate
}
}
})
})
describe('repo update serialization', () => {
it('serializes local repo updates for the same repo before applying state', async () => {
const firstUpdate = deferred<void>()
const secondUpdate = deferred<void>()
const firstHookSettings: NonNullable<Repo['hookSettings']> = {
mode: 'override',
setupRunPolicy: 'ask',
commandSourcePolicy: 'local-only',
scripts: { setup: 'first setup', archive: '' }
}
const secondHookSettings: NonNullable<Repo['hookSettings']> = {
mode: 'override',
setupRunPolicy: 'skip-by-default',
commandSourcePolicy: 'run-both',
scripts: { setup: 'second setup', archive: 'second archive' }
}
reposUpdate.mockImplementationOnce(() => firstUpdate.promise)
reposUpdate.mockImplementationOnce(() => secondUpdate.promise)
const store = createTestStore()
store.setState({ repos: [localRepo] })
const first = store.getState().updateRepo(localRepo.id, { hookSettings: firstHookSettings })
const second = store.getState().updateRepo(localRepo.id, { hookSettings: secondHookSettings })
expect(reposUpdate).toHaveBeenCalledTimes(1)
expect(store.getState().repos[0]?.hookSettings).toBeUndefined()
firstUpdate.resolve()
await first
await Promise.resolve()
expect(reposUpdate).toHaveBeenCalledTimes(2)
expect(store.getState().repos[0]?.hookSettings).toEqual(firstHookSettings)
secondUpdate.resolve()
await second
expect(store.getState().repos[0]?.hookSettings).toEqual(secondHookSettings)
})
it('does not serialize updates for different repos', async () => {
const slowLocalUpdate = deferred<void>()
reposUpdate.mockImplementationOnce(() => slowLocalUpdate.promise)
reposUpdate.mockResolvedValueOnce(undefined)
const store = createTestStore()
store.setState({ repos: [localRepo, secondRepo] })
const local = store.getState().updateRepo(localRepo.id, { displayName: 'Local slow' })
const second = store.getState().updateRepo(secondRepo.id, { displayName: 'Second fast' })
expect(reposUpdate).toHaveBeenCalledTimes(2)
await second
expect(store.getState().repos.find((repo) => repo.id === secondRepo.id)?.displayName).toBe(
'Second fast'
)
expect(store.getState().repos.find((repo) => repo.id === localRepo.id)?.displayName).toBe(
'Local'
)
slowLocalUpdate.resolve()
await local
expect(store.getState().repos.find((repo) => repo.id === localRepo.id)?.displayName).toBe(
'Local slow'
)
})
it('continues a repo update chain after a failed update', async () => {
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined)
try {
reposUpdate.mockRejectedValueOnce(new Error('update failed'))
reposUpdate.mockResolvedValueOnce(undefined)
const store = createTestStore()
store.setState({ repos: [localRepo] })
const failed = store.getState().updateRepo(localRepo.id, { displayName: 'Failed' })
const recovered = store.getState().updateRepo(localRepo.id, { displayName: 'Recovered' })
await Promise.all([failed, recovered])
expect(reposUpdate).toHaveBeenCalledTimes(2)
expect(store.getState().repos[0]?.displayName).toBe('Recovered')
} finally {
errorSpy.mockRestore()
}
})
})

View File

@ -13,6 +13,30 @@ import { buildDismissedOnboardingFolderAgentStartup } from '@/lib/onboarding-fol
const ERROR_TOAST_DURATION = 60_000
type RepoUpdate = Partial<
Pick<
Repo,
| 'displayName'
| 'badgeColor'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'
| 'symlinkPaths'
| 'issueSourcePreference'
>
>
const updateRepoChainsByStore = new WeakMap<() => AppState, Map<string, Promise<void>>>()
function getRepoUpdateChains(get: () => AppState) {
let chains = updateRepoChainsByStore.get(get)
if (!chains) {
chains = new Map<string, Promise<void>>()
updateRepoChainsByStore.set(get, chains)
}
return chains
}
export type RepoSlice = {
repos: Repo[]
activeRepoId: string | null
@ -21,21 +45,7 @@ export type RepoSlice = {
addRepoPath: (path: string, kind?: 'git' | 'folder') => Promise<Repo | null>
addNonGitFolder: (path: string) => Promise<Repo | null>
removeRepo: (repoId: string) => Promise<void>
updateRepo: (
repoId: string,
updates: Partial<
Pick<
Repo,
| 'displayName'
| 'badgeColor'
| 'hookSettings'
| 'worktreeBaseRef'
| 'kind'
| 'symlinkPaths'
| 'issueSourcePreference'
>
>
) => Promise<void>
updateRepo: (repoId: string, updates: RepoUpdate) => Promise<void>
setActiveRepo: (repoId: string | null) => void
reorderRepos: (orderedIds: string[]) => Promise<void>
}
@ -312,17 +322,34 @@ export const createRepoSlice: StateCreator<AppState, [], [], RepoSlice> = (set,
},
updateRepo: async (repoId, updates) => {
try {
const target = getActiveRuntimeTarget(get().settings)
await (target.kind === 'local'
? window.api.repos.update({ repoId, updates })
: callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 }))
set((s) => ({
repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...updates } : r))
}))
} catch (err) {
console.error('Failed to update repo:', err)
const updateRepoChains = getRepoUpdateChains(get)
const applyRepoUpdate = async () => {
try {
const target = getActiveRuntimeTarget(get().settings)
await (target.kind === 'local'
? window.api.repos.update({ repoId, updates })
: callRuntimeRpc(target, 'repo.update', { repo: repoId, updates }, { timeoutMs: 15_000 }))
set((s) => ({
repos: s.repos.map((r) => (r.id === repoId ? { ...r, ...updates } : r))
}))
} catch (err) {
console.error('Failed to update repo:', err)
}
}
const previous = updateRepoChains.get(repoId)
// Why: repo settings are persisted as full nested values. Preserve call
// order per repo so a slower IPC/RPC response cannot overwrite newer state.
const next = previous
? previous.catch(() => undefined).then(applyRepoUpdate)
: applyRepoUpdate()
updateRepoChains.set(repoId, next)
const cleanup = () => {
if (updateRepoChains.get(repoId) === next) {
updateRepoChains.delete(repoId)
}
}
void next.then(cleanup, cleanup)
await next
},
setActiveRepo: (repoId) => set({ activeRepoId: repoId }),

View File

@ -299,6 +299,7 @@ export function getDefaultRepoHookSettings(): RepoHookSettings {
return {
mode: 'auto',
setupRunPolicy: 'run-by-default',
commandSourcePolicy: 'shared-only',
scripts: {
setup: '',
archive: ''

View File

@ -15,6 +15,14 @@ export function normalizeRuntimePathForComparison(value: string): string {
return isWindowsAbsolutePathLike(value) ? normalized.toLowerCase() : normalized
}
export function getRuntimePathBasename(value: string): string {
const trimmed = value.replace(/[\\/]+$/g, '')
if (!trimmed) {
return ''
}
return trimmed.split(/[\\/]/).filter(Boolean).at(-1) ?? ''
}
export function isPathInsideOrEqual(rootPath: string, candidatePath: string): boolean {
const root = normalizeRuntimePathForComparison(rootPath)
const candidate = normalizeRuntimePathForComparison(candidatePath)

View File

@ -0,0 +1,11 @@
import type { HookCommandSourcePolicy } from './types'
export function normalizeHookCommandSourcePolicy(policy: unknown): HookCommandSourcePolicy {
if (policy === 'local-only' || policy === 'run-both' || policy === 'shared-only') {
return policy
}
// Why: old persisted settings may still contain the removed shared-first mode.
// Treat any unknown value as the authoritative committed config policy.
return 'shared-only'
}

View File

@ -94,6 +94,7 @@ export type Repo = {
export type SetupRunPolicy = 'ask' | 'run-by-default' | 'skip-by-default'
export type SetupDecision = 'inherit' | 'run' | 'skip'
export type HookCommandSourcePolicy = 'shared-only' | 'local-only' | 'run-both'
/**
* Envelope returned by the `repos:getBaseRefDefault` IPC handler.
@ -1049,11 +1050,11 @@ export type OrcaHooks = {
}
export type RepoHookSettings = {
// Why: legacy persisted data may still include the old UI-hook fields. Orca no longer
// treats them as an active config surface, but we keep them in the stored shape so
// existing local state can still be read without migrations.
// Why: persisted data may still include the old mode field from the earlier
// hook UI. Keep it in the shape so existing local state reads without a migration.
mode: 'auto' | 'override'
setupRunPolicy?: SetupRunPolicy
commandSourcePolicy?: HookCommandSourcePolicy
scripts: {
setup: string
archive: string