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:
Brennan Benson
2026-07-24 16:25:30 -07:00
parent e133d93b7b
commit cd22cd00ae
10 changed files with 417 additions and 8 deletions
@@ -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
View File
@@ -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
View File
@@ -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. */
+18 -2
View File
@@ -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', () =>
+70
View File
@@ -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
+21 -2
View File
@@ -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.
+12
View File
@@ -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
View File
@@ -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 } } : {})
}
}
+7
View File
@@ -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 = {