diff --git a/src/main/git/worktree-shared-directories.test.ts b/src/main/git/worktree-shared-directories.test.ts new file mode 100644 index 00000000000..2eb5242d2b8 --- /dev/null +++ b/src/main/git/worktree-shared-directories.test.ts @@ -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 + + 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']) + }) +}) diff --git a/src/main/git/worktree-shared-directories.ts b/src/main/git/worktree-shared-directories.ts new file mode 100644 index 00000000000..53c6b3417de --- /dev/null +++ b/src/main/git/worktree-shared-directories.ts @@ -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 { + 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 [] + } +} diff --git a/src/main/hooks.test.ts b/src/main/hooks.test.ts index d117470bffe..026d1a8369c 100644 --- a/src/main/hooks.test.ts +++ b/src/main/hooks.test.ts @@ -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') ) diff --git a/src/main/hooks.ts b/src/main/hooks.ts index ef5aaf1e5ea..3f8a210ccf9 100644 --- a/src/main/hooks.ts +++ b/src/main/hooks.ts @@ -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. */ diff --git a/src/main/ipc/worktree-remote.ts b/src/main/ipc/worktree-remote.ts index 707929ee7a2..fc919b75eca 100644 --- a/src/main/ipc/worktree-remote.ts +++ b/src/main/ipc/worktree-remote.ts @@ -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', () => diff --git a/src/main/ipc/worktree-symlinks.test.ts b/src/main/ipc/worktree-symlinks.test.ts index 865c3f17b11..17bac722d10 100644 --- a/src/main/ipc/worktree-symlinks.test.ts +++ b/src/main/ipc/worktree-symlinks.test.ts @@ -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 + let error: ReturnType + + 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 diff --git a/src/main/ipc/worktree-symlinks.ts b/src/main/ipc/worktree-symlinks.ts index fb81cc37df4..e7a9fbee024 100644 --- a/src/main/ipc/worktree-symlinks.ts +++ b/src/main/ipc/worktree-symlinks.ts @@ -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 { + 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. diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 48920729504..1ffb2e22b09 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -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( diff --git a/src/shared/orca-yaml.ts b/src/shared/orca-yaml.ts index c9179c6d606..f29b7cc1c87 100644 --- a/src/shared/orca-yaml.ts +++ b/src/shared/orca-yaml.ts @@ -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() + 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 } } : {}) } } diff --git a/src/shared/types.ts b/src/shared/types.ts index 7a8253a0e85..7efbb73179a 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -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 = {