mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 00:27:39 +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
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { resolveWorktreeSharedDirectories } from './worktree-shared-directories'
|
||||
|
||||
const git = (args: string[], cwd: string): void => {
|
||||
execFileSync('git', args, { cwd, stdio: 'ignore' })
|
||||
}
|
||||
|
||||
describe('resolveWorktreeSharedDirectories', () => {
|
||||
let repo: string
|
||||
let warn: ReturnType<typeof vi.spyOn>
|
||||
|
||||
const writeOrcaYaml = (body: string): void => {
|
||||
writeFileSync(join(repo, 'orca.yaml'), body)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
repo = mkdtempSync(join(tmpdir(), 'orca-shared-dirs-'))
|
||||
git(['init', '-q'], repo)
|
||||
git(['config', 'user.email', 'test@example.com'], repo)
|
||||
git(['config', 'user.name', 'Test'], repo)
|
||||
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n.cache\n')
|
||||
writeFileSync(join(repo, 'README.md'), '# tracked\n')
|
||||
git(['add', '.gitignore', 'README.md'], repo)
|
||||
git(['commit', '-qm', 'init'], repo)
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore()
|
||||
rmSync(repo, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('returns gitignored directories listed under worktree.sharedDirectories', async () => {
|
||||
mkdirSync(join(repo, 'node_modules'))
|
||||
mkdirSync(join(repo, '.cache'))
|
||||
writeOrcaYaml('worktree:\n sharedDirectories:\n - node_modules\n - .cache\n')
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['.cache', 'node_modules'])
|
||||
})
|
||||
|
||||
it('returns [] when orca.yaml is absent', async () => {
|
||||
mkdirSync(join(repo, 'node_modules'))
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
|
||||
})
|
||||
|
||||
it('returns [] when orca.yaml has no worktree key', async () => {
|
||||
mkdirSync(join(repo, 'node_modules'))
|
||||
writeOrcaYaml('scripts:\n setup: pnpm install\n')
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
|
||||
})
|
||||
|
||||
it('skips a directory that is not gitignored', async () => {
|
||||
mkdirSync(join(repo, 'shared-but-tracked'))
|
||||
writeOrcaYaml('worktree:\n sharedDirectories:\n - shared-but-tracked\n')
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('only gitignored directories'))
|
||||
})
|
||||
|
||||
it('skips a listed path that is a file, not a directory', async () => {
|
||||
writeFileSync(join(repo, '.cache'), 'not a dir')
|
||||
writeOrcaYaml('worktree:\n sharedDirectories:\n - .cache\n')
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
|
||||
expect(warn).toHaveBeenCalledWith(expect.stringContaining('must be directories'))
|
||||
})
|
||||
|
||||
it('skips entries that are absent from the primary checkout', async () => {
|
||||
writeOrcaYaml('worktree:\n sharedDirectories:\n - node_modules\n')
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
|
||||
})
|
||||
|
||||
it('drops unsafe entries before touching the filesystem', async () => {
|
||||
mkdirSync(join(repo, 'node_modules'))
|
||||
writeOrcaYaml(
|
||||
[
|
||||
'worktree:',
|
||||
' sharedDirectories:',
|
||||
' - ../escape',
|
||||
' - /etc',
|
||||
' - .git',
|
||||
' - .git/hooks',
|
||||
' - node_modules',
|
||||
''
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['node_modules'])
|
||||
})
|
||||
|
||||
it('normalizes trailing slashes, ./ prefixes and duplicates', async () => {
|
||||
mkdirSync(join(repo, 'node_modules'))
|
||||
writeOrcaYaml(
|
||||
'worktree:\n sharedDirectories:\n - node_modules/\n - ./node_modules\n - node_modules\n'
|
||||
)
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['node_modules'])
|
||||
})
|
||||
|
||||
it('returns [] for a malformed sharedDirectories value instead of throwing', async () => {
|
||||
mkdirSync(join(repo, 'node_modules'))
|
||||
writeOrcaYaml('worktree:\n sharedDirectories: node_modules\n')
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual([])
|
||||
})
|
||||
|
||||
it('resolves nested directories anchored at the repo root', async () => {
|
||||
mkdirSync(join(repo, 'apps', 'web', '.cache'), { recursive: true })
|
||||
writeFileSync(join(repo, '.gitignore'), 'node_modules/\n.cache\napps/web/.cache\n')
|
||||
writeOrcaYaml('worktree:\n sharedDirectories:\n - apps/web/.cache\n')
|
||||
|
||||
expect(await resolveWorktreeSharedDirectories(repo)).toEqual(['apps/web/.cache'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { checkIgnoredPaths } from './check-ignored-paths'
|
||||
import type { GitRuntimeOptions } from './git-runtime-options'
|
||||
import { loadHooks } from '../hooks'
|
||||
|
||||
// Why: a fresh worktree has no node_modules/.cache, and copying them is slow and
|
||||
// duplicates disk; `orca.yaml` names the ones every worktree should share instead.
|
||||
|
||||
/** Resolve `worktree.sharedDirectories` from the repo-root `orca.yaml` to
|
||||
* concrete repo-relative directories to symlink into a new worktree.
|
||||
*
|
||||
* Only directories that exist in the primary checkout **and** are gitignored are
|
||||
* returned: tracked directories are already materialized by the checkout, and
|
||||
* sharing an unignored path would surface the link as a spurious worktree diff.
|
||||
*
|
||||
* Never throws — any read/parse/git failure resolves to `[]` so worktree
|
||||
* creation is never blocked by this file. */
|
||||
export async function resolveWorktreeSharedDirectories(
|
||||
repoPath: string,
|
||||
options: GitRuntimeOptions = {}
|
||||
): Promise<string[]> {
|
||||
try {
|
||||
const configured = loadHooks(repoPath)?.worktree?.sharedDirectories ?? []
|
||||
if (configured.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
// Keep only entries that exist as directories; a listed but absent path
|
||||
// (node_modules before install) has nothing to share.
|
||||
const existing: string[] = []
|
||||
for (const relativePath of configured) {
|
||||
try {
|
||||
if ((await stat(join(repoPath, relativePath))).isDirectory()) {
|
||||
existing.push(relativePath)
|
||||
} else {
|
||||
console.warn(
|
||||
`[worktree-shared-directories] Skipping "${relativePath}": sharedDirectories entries must be directories`
|
||||
)
|
||||
}
|
||||
} catch {
|
||||
// Absent in the primary checkout — nothing to share.
|
||||
}
|
||||
}
|
||||
if (existing.length === 0) {
|
||||
return []
|
||||
}
|
||||
|
||||
const ignored = new Set(await checkIgnoredPaths(repoPath, existing, options))
|
||||
for (const relativePath of existing) {
|
||||
if (!ignored.has(relativePath)) {
|
||||
console.warn(
|
||||
`[worktree-shared-directories] Skipping "${relativePath}": only gitignored directories can be shared`
|
||||
)
|
||||
}
|
||||
}
|
||||
return existing.filter((relativePath) => ignored.has(relativePath)).sort()
|
||||
} catch (error) {
|
||||
console.warn('[worktree-shared-directories] Failed to resolve shared directories:', error)
|
||||
return []
|
||||
}
|
||||
}
|
||||
+62
-1
@@ -292,6 +292,64 @@ describe('parseOrcaYaml', () => {
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
' - 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', () => {
|
||||
@@ -335,7 +393,10 @@ describe('hasUnrecognizedOrcaYamlKeys', () => {
|
||||
'environmentRecipes:',
|
||||
' - id: cloud-sandbox',
|
||||
' name: Cloud Sandbox',
|
||||
' create: ./scripts/orca-vm/start-cloud-sandbox.sh'
|
||||
' create: ./scripts/orca-vm/start-cloud-sandbox.sh',
|
||||
'worktree:',
|
||||
' sharedDirectories:',
|
||||
' - node_modules'
|
||||
].join('\n')
|
||||
)
|
||||
|
||||
|
||||
+2
-1
@@ -67,7 +67,8 @@ const RECOGNIZED_ORCA_YAML_KEYS = new Set([
|
||||
'scripts',
|
||||
'issueCommand',
|
||||
'defaultTabs',
|
||||
'environmentRecipes'
|
||||
'environmentRecipes',
|
||||
'worktree'
|
||||
])
|
||||
|
||||
/** True when `orca.yaml` has a top-level key this version of Orca does not handle. */
|
||||
|
||||
@@ -95,8 +95,13 @@ import {
|
||||
prepareWorktreePushTargetWithExec
|
||||
} from './worktree-push-target-setup'
|
||||
import { isENOENT, registerWorktreeRootsForRepo } from './filesystem-auth'
|
||||
import { createWorktreeCopiedPaths, createWorktreeLinkedPaths } from './worktree-symlinks'
|
||||
import {
|
||||
createWorktreeCopiedPaths,
|
||||
createWorktreeLinkedPaths,
|
||||
createWorktreeSharedPaths
|
||||
} from './worktree-symlinks'
|
||||
import { resolveWorktreeIncludePaths } from '../git/worktree-include-file'
|
||||
import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories'
|
||||
import { normalizeSparseDirectories } from './sparse-checkout-directories'
|
||||
import { joinWorktreeRelativePath } from '../runtime/runtime-relative-paths'
|
||||
import type { IFilesystemProvider } from '../providers/types'
|
||||
@@ -1866,7 +1871,7 @@ export async function createRemoteWorktree(
|
||||
})
|
||||
const workspaceLineage = recordWorkspaceLineageForCreatedWorktree(store, args, worktree, now)
|
||||
|
||||
// Why: shared/symlink paths and `.worktreeinclude` copies are local-only; remote (SSH) support needs a new relay method + auth surface, so both are skipped here.
|
||||
// Why: shared/symlink paths, `orca.yaml` shared directories, and `.worktreeinclude` copies are local-only; remote (SSH) support needs a new relay method + auth surface, so all are skipped here.
|
||||
|
||||
let setup: CreateWorktreeResult['setup']
|
||||
let defaultTabs: CreateWorktreeResult['defaultTabs']
|
||||
@@ -2461,6 +2466,17 @@ export async function createLocalWorktree(
|
||||
})
|
||||
}
|
||||
|
||||
// Why: project-level `orca.yaml` shared directories add to (never replace) the per-user
|
||||
// setting, so a repo's shared dirs reach every teammate (issue #10451).
|
||||
const sharedDirectories = await timing.time('resolve_shared_directories', () =>
|
||||
resolveWorktreeSharedDirectories(repo.path, localWorktreeGitOptions)
|
||||
)
|
||||
if (sharedDirectories.length > 0) {
|
||||
await timing.time('create_shared_directories', async () => {
|
||||
await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories)
|
||||
})
|
||||
}
|
||||
|
||||
// Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics
|
||||
// (never symlink) so each worktree owns its files. Paths already linked above are skipped.
|
||||
const includePaths = await timing.time('resolve_worktreeinclude', () =>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createWorktreeCopiedPaths,
|
||||
createWorktreeLinkedPaths,
|
||||
createWorktreeSharedPaths,
|
||||
createWorktreeSymlinks,
|
||||
findExistingWorktreeSymlinkPaths,
|
||||
removeWorktreeLinkedPaths,
|
||||
@@ -394,6 +395,75 @@ describe('createWorktreeSymlinks', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorktreeSharedPaths', () => {
|
||||
let root: string
|
||||
let primary: string
|
||||
let worktree: string
|
||||
let warn: ReturnType<typeof vi.spyOn>
|
||||
let error: ReturnType<typeof vi.spyOn>
|
||||
|
||||
beforeEach(() => {
|
||||
root = mkdtempSync(join(tmpdir(), 'orca-sharedpaths-'))
|
||||
primary = join(root, 'primary')
|
||||
worktree = join(root, 'worktree')
|
||||
mkdirSync(primary, { recursive: true })
|
||||
mkdirSync(worktree, { recursive: true })
|
||||
warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
error = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
warn.mockRestore()
|
||||
error.mockRestore()
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
// Why: an APFS clone would give the worktree its own node_modules, defeating
|
||||
// one-install-serves-all. Share mode must symlink even where cloning works.
|
||||
posixIt('symlinks on macOS instead of APFS clone-copying', async () => {
|
||||
mkdirSync(join(primary, 'node_modules'))
|
||||
writeFileSync(join(primary, 'node_modules', 'marker'), 'ORIG\n')
|
||||
const cloneWorktreePath = vi.fn()
|
||||
|
||||
await createWorktreeSharedPaths(primary, worktree, ['node_modules'], {
|
||||
platform: 'darwin',
|
||||
cloneWorktreePath
|
||||
})
|
||||
|
||||
expect(cloneWorktreePath).not.toHaveBeenCalled()
|
||||
expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(true)
|
||||
})
|
||||
|
||||
posixIt('shares one directory so worktree writes reach the primary checkout', async () => {
|
||||
mkdirSync(join(primary, 'node_modules'))
|
||||
|
||||
await createWorktreeSharedPaths(primary, worktree, ['node_modules'], { platform: 'linux' })
|
||||
|
||||
writeFileSync(join(worktree, 'node_modules', 'installed'), 'SHARED\n')
|
||||
expect(readFileSync(join(primary, 'node_modules', 'installed'), 'utf8')).toBe('SHARED\n')
|
||||
})
|
||||
|
||||
posixIt('skips a path already materialized by the per-user symlink pass', async () => {
|
||||
mkdirSync(join(primary, 'node_modules'))
|
||||
mkdirSync(join(worktree, 'node_modules'))
|
||||
|
||||
await createWorktreeSharedPaths(primary, worktree, ['node_modules'], { platform: 'linux' })
|
||||
|
||||
expect(lstatSync(join(worktree, 'node_modules')).isSymbolicLink()).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects unsafe paths without touching the filesystem', async () => {
|
||||
writeFileSync(join(root, 'outside.txt'), 'DO_NOT_TOUCH')
|
||||
|
||||
await createWorktreeSharedPaths(primary, worktree, ['../outside.txt', '/etc/passwd'], {
|
||||
platform: 'linux'
|
||||
})
|
||||
|
||||
expect(readFileSync(join(root, 'outside.txt'), 'utf8')).toBe('DO_NOT_TOUCH')
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('createWorktreeCopiedPaths', () => {
|
||||
let root: string
|
||||
let primary: string
|
||||
|
||||
@@ -18,7 +18,9 @@ type WorktreeLinkedPathOptions = {
|
||||
// 'link': symlink when APFS clone is unavailable (user-configured shared paths).
|
||||
// 'copy': real copy when APFS clone is unavailable (.worktreeinclude paths, which
|
||||
// are per-worktree copies by cross-tool convention — edits must not leak back).
|
||||
type WorktreeMaterializeMode = 'link' | 'copy'
|
||||
// 'share': always symlink (orca.yaml sharedDirectories). An APFS clone would give
|
||||
// each worktree an independent node_modules, defeating one-install-serves-all.
|
||||
type WorktreeMaterializeMode = 'link' | 'copy' | 'share'
|
||||
|
||||
type SafeRelativePathResult =
|
||||
| {
|
||||
@@ -78,7 +80,11 @@ async function createWorktreeLinkedPath(
|
||||
// worktree would leak back into the primary checkout (or escape it entirely if
|
||||
// the link points outside). Resolve the real source so we copy content.
|
||||
const copySource = mode === 'copy' && sourceIsSymbolicLink ? await realpath(source) : source
|
||||
if (options.platform === 'darwin' && (!sourceIsSymbolicLink || mode === 'copy')) {
|
||||
if (
|
||||
mode !== 'share' &&
|
||||
options.platform === 'darwin' &&
|
||||
(!sourceIsSymbolicLink || mode === 'copy')
|
||||
) {
|
||||
try {
|
||||
const cloneWorktreePath =
|
||||
options.cloneWorktreePath ??
|
||||
@@ -204,6 +210,19 @@ export async function createWorktreeCopiedPaths(
|
||||
await materializeWorktreePaths(primaryPath, worktreePath, paths, 'copy', options)
|
||||
}
|
||||
|
||||
/** Symlink `orca.yaml` `worktree.sharedDirectories` into a freshly-created
|
||||
* worktree. Unlike createWorktreeLinkedPaths this never APFS clone-copies: a
|
||||
* clone would give each worktree its own node_modules, and the point of a
|
||||
* shared directory is that one install serves every worktree. */
|
||||
export async function createWorktreeSharedPaths(
|
||||
primaryPath: string,
|
||||
worktreePath: string,
|
||||
paths: readonly string[],
|
||||
options: WorktreeLinkedPathOptions = {}
|
||||
): Promise<void> {
|
||||
await materializeWorktreePaths(primaryPath, worktreePath, paths, 'share', options)
|
||||
}
|
||||
|
||||
/** Create filesystem symlinks from the primary checkout into a freshly-created
|
||||
* worktree for each configured path. Failures on individual paths are logged
|
||||
* and skipped so a missing/stale entry never blocks worktree creation.
|
||||
|
||||
@@ -789,10 +789,12 @@ import { listRepoWorktrees } from '../repo-worktrees'
|
||||
import {
|
||||
createWorktreeCopiedPaths,
|
||||
createWorktreeLinkedPaths,
|
||||
createWorktreeSharedPaths,
|
||||
findExistingWorktreeSymlinkPaths,
|
||||
removeWorktreeLinkedPaths
|
||||
} from '../ipc/worktree-symlinks'
|
||||
import { resolveWorktreeIncludePaths } from '../git/worktree-include-file'
|
||||
import { resolveWorktreeSharedDirectories } from '../git/worktree-shared-directories'
|
||||
import { deleteWorktreeHistoryDir } from '../terminal-history'
|
||||
import {
|
||||
cleanupUnusedWorktreePushTargetRemote,
|
||||
@@ -18924,6 +18926,16 @@ export class OrcaRuntimeService {
|
||||
await createWorktreeLinkedPaths(repo.path, created.path, symlinkPaths)
|
||||
}
|
||||
|
||||
// Why: project-level `orca.yaml` shared directories add to (never replace) the
|
||||
// per-user setting, so a repo's shared dirs reach every teammate (issue #10451).
|
||||
const sharedDirectories = await resolveWorktreeSharedDirectories(
|
||||
repo.path,
|
||||
localWorktreeGitOptions
|
||||
)
|
||||
if (sharedDirectories.length > 0) {
|
||||
await createWorktreeSharedPaths(repo.path, created.path, sharedDirectories)
|
||||
}
|
||||
|
||||
// Why: project-level `.worktreeinclude` travels with the repo (issue #7549); copy semantics
|
||||
// (never symlink) so each worktree owns its files. Paths already linked above are skipped.
|
||||
const worktreeIncludePaths = await resolveWorktreeIncludePaths(
|
||||
|
||||
+42
-2
@@ -21,6 +21,40 @@ export const ORCA_VM_RECIPE_ID_PATTERN = /^[a-z0-9][a-z0-9._-]{0,63}$/
|
||||
export const ORCA_VM_RECIPE_ID_RULE =
|
||||
'Use 1-64 lowercase letters, numbers, dots, underscores, or hyphens, starting with a letter or number.'
|
||||
|
||||
// Why: bound the work one repo file can request; entries beyond this are ignored.
|
||||
const MAX_SHARED_DIRECTORIES = 100
|
||||
|
||||
/** Normalize `worktree.sharedDirectories` into deduped repo-root-relative paths.
|
||||
* `\` becomes `/`, a `./` prefix and trailing `/` are stripped. Absolute paths,
|
||||
* `..` traversal and `.git` are dropped here so callers get only safe entries. */
|
||||
function normalizeSharedDirectories(value: unknown): string[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
}
|
||||
|
||||
const seen = new Set<string>()
|
||||
for (const entry of value.slice(0, MAX_SHARED_DIRECTORIES)) {
|
||||
const raw = asTrimmedString(entry)
|
||||
if (!raw) {
|
||||
continue
|
||||
}
|
||||
const normalized = raw.replace(/\\/g, '/').replace(/^\.\//, '').replace(/\/+$/, '')
|
||||
const segments = normalized.split('/')
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.startsWith('/') ||
|
||||
/^[a-zA-Z]:/.test(normalized) ||
|
||||
segments.includes('..') ||
|
||||
segments.includes('') ||
|
||||
segments[0] === '.git'
|
||||
) {
|
||||
continue
|
||||
}
|
||||
seen.add(normalized)
|
||||
}
|
||||
return Array.from(seen)
|
||||
}
|
||||
|
||||
function normalizeDefaultTabs(value: unknown): OrcaDefaultTabTemplate[] {
|
||||
if (!Array.isArray(value)) {
|
||||
return []
|
||||
@@ -146,6 +180,10 @@ export function parseOrcaYaml(content: string): OrcaHooks | null {
|
||||
const environmentRecipeParse = normalizeVmRecipes(record.environmentRecipes)
|
||||
const environmentRecipes = environmentRecipeParse.recipes
|
||||
const environmentRecipeDiagnostics = environmentRecipeParse.diagnostics
|
||||
const worktreeRecord = asRecord(record.worktree)
|
||||
const sharedDirectories = worktreeRecord
|
||||
? normalizeSharedDirectories(worktreeRecord.sharedDirectories)
|
||||
: []
|
||||
|
||||
if (
|
||||
!setup &&
|
||||
@@ -153,7 +191,8 @@ export function parseOrcaYaml(content: string): OrcaHooks | null {
|
||||
!issueCommand &&
|
||||
defaultTabs.length === 0 &&
|
||||
environmentRecipes.length === 0 &&
|
||||
environmentRecipeDiagnostics.length === 0
|
||||
environmentRecipeDiagnostics.length === 0 &&
|
||||
sharedDirectories.length === 0
|
||||
) {
|
||||
return null
|
||||
}
|
||||
@@ -166,6 +205,7 @@ export function parseOrcaYaml(content: string): OrcaHooks | null {
|
||||
...(issueCommand ? { issueCommand } : {}),
|
||||
...(defaultTabs.length > 0 ? { defaultTabs } : {}),
|
||||
...(environmentRecipes.length > 0 ? { environmentRecipes } : {}),
|
||||
...(environmentRecipeDiagnostics.length > 0 ? { environmentRecipeDiagnostics } : {})
|
||||
...(environmentRecipeDiagnostics.length > 0 ? { environmentRecipeDiagnostics } : {}),
|
||||
...(sharedDirectories.length > 0 ? { worktree: { sharedDirectories } } : {})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2075,6 +2075,13 @@ export type OrcaHooks = {
|
||||
defaultTabs?: OrcaDefaultTabTemplate[] // Terminal tabs to create once for a new worktree
|
||||
environmentRecipes?: OrcaVmRecipe[] // Project-scoped per-workspace environment recipes
|
||||
environmentRecipeDiagnostics?: OrcaVmRecipeDiagnostic[] // Non-fatal validation issues from environmentRecipes
|
||||
worktree?: OrcaWorktreeDefaults // Project-scoped defaults applied when a worktree is created
|
||||
}
|
||||
|
||||
export type OrcaWorktreeDefaults = {
|
||||
// Why: shared (symlinked) rather than copied — large rebuildable dirs like
|
||||
// node_modules should be one install serving every worktree.
|
||||
sharedDirectories?: string[]
|
||||
}
|
||||
|
||||
export type OrcaDefaultTabTemplate = {
|
||||
|
||||
Reference in New Issue
Block a user